Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5b5cd42
docs(openspec): propose seasonal-lobby-world
TheMeinerLP Sep 29, 2026
0fc94ee
feat(season): select the lobby world by name
TheMeinerLP Sep 29, 2026
16a0669
feat(season): add season column with calendar
TheMeinerLP Sep 29, 2026
9225dda
Merge branch 'feat/season-map-pool' into feat/season
TheMeinerLP Sep 29, 2026
ec504cc
feat(season): read and validate seasons from config
TheMeinerLP Sep 29, 2026
c76d16b
feat(season): decide restarts from started and desired world
TheMeinerLP Sep 29, 2026
a700d88
feat(season): restart the lobby into the season world when empty
TheMeinerLP Sep 29, 2026
ed8c7dc
Merge branch 'feat/season-column' into feat/season
TheMeinerLP Sep 29, 2026
f09873f
docs(openspec): record world choice spike result for seasonal-lobby-w…
TheMeinerLP Sep 29, 2026
42f4438
feat(season): resolve the lobby world through a service-loaded choice
TheMeinerLP Sep 29, 2026
78a2a67
feat(season): register the season world choice for the service loader
TheMeinerLP Sep 29, 2026
ed859da
docs(openspec): tick world choice tasks of seasonal-lobby-world
TheMeinerLP Sep 29, 2026
98cd686
docs(openspec): tick implemented tasks of seasonal-lobby-world
TheMeinerLP Sep 29, 2026
3af8487
feat(season): keep the season column out of the local variant
TheMeinerLP Sep 29, 2026
bf453df
feat(season): cover the season wiring in the cloudnet variant
TheMeinerLP Sep 29, 2026
5d17a29
docs(season): document adding a season world
TheMeinerLP Sep 29, 2026
9f66bd4
docs(openspec): tick variant and docs tasks of seasonal-lobby-world
TheMeinerLP Sep 29, 2026
f465210
test(season): cover enabling a season live inside its window
TheMeinerLP Sep 29, 2026
2c68de8
docs(openspec): tick verification task of seasonal-lobby-world
TheMeinerLP Sep 29, 2026
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,31 @@ the new value took effect, with no restart of any kind appearing in the log. **R
the previous jar and restore `flags.properties` - a leftover `features.*` section or
`config.watch.*` setting in `application.yaml` does not affect the previous jar.

### Seasonal lobby world

The `cloudnet` variant can run the lobby in a season-specific world during a time window, for
example a winter map in December. To add one:

1. Build the world in the setup server.
2. Put it in `worlds/<name>/` with its `map.json`, next to `worlds/world/`.
3. Configure it in `application.yaml`:

```yaml
seasons:
zone: Europe/Berlin # default; time zone of the windows
winter: # any id ("zone" is reserved)
world: winter # directory under worlds/
from: 2026-12-01T00:00:00 # inclusive, local time in seasons.zone
to: 2027-01-07T00:00:00 # exclusive
enabled: true # kill switch, no restart of the configuration needed
```

An invalid enabled season aborts startup with the key and the reason. Switching worlds needs a
restart: once a minute (and after every disconnect) the lobby compares its started world with the
one the calendar wants, and stops only when nobody is online so the supervisor starts it again. A
busy service therefore waits; an operator can end it sooner with `/stop`. `apps/local` does not
include this feature.

## Development

### Building from Source
Expand Down
3 changes: 3 additions & 0 deletions apps/cloudnet/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ dependencies {
testImplementation(libs.mockito)
testImplementation(libs.avaje.inject)
testImplementation(libs.avaje.config)
// The season wiring test asserts on captured log lines.
testImplementation(libs.slf4j.api)
testImplementation(libs.logback.classic)
// TestTitanNode - see the example feature's own test.
testImplementation(testFixtures(project(":core")))
testImplementation(libs.junit.api)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
package net.onelitefeather.titan.apps.cloudnet;

import io.avaje.inject.BeanScope;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import net.minestom.testing.Env;
Expand Down Expand Up @@ -70,6 +72,24 @@ void variantPropertiesListsLuckpermsPlatform() {
Assertions.assertTrue(descriptor.modules().contains("luckpermsPlatform"), "expected modules must include luckpermsPlatform, were: " + descriptor.modules());
}

@DisplayName("variant.properties lists seasonColumn and the season column is loaded")
@Test
void seasonColumnIsExpectedAndLoaded() {
ClassLoader loader = getClass().getClassLoader();
VariantDescriptor descriptor = VariantDescriptor.fromClasspath(loader).orElseThrow(() -> new AssertionError("this variant must ship META-INF/titan/variant.properties"));

Assertions.assertTrue(descriptor.modules().contains("seasonColumn"), "expected modules must include seasonColumn, were: " + descriptor.modules());
Assertions.assertTrue(LoadedModules.discover(loader).contains("seasonColumn"), "the season column must be on the classpath and load");
}

@DisplayName("The shipped application.yaml carries the seasons defaults")
@Test
void applicationYamlCarriesTheSeasonsDefaults() throws IOException {
String yaml = new String(getClass().getClassLoader().getResourceAsStream("application.yaml").readAllBytes(), StandardCharsets.UTF_8);

Assertions.assertTrue(yaml.contains("seasons:"), "the merged application.yaml must contain the seasons defaults");
}

@DisplayName("An additionally expected but missing column aborts startup, naming it")
@Test
void anAdditionallyExpectedButMissingColumnAbortsNamingIt() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Copyright 2025 OneLiteFeather Network
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.onelitefeather.titan.feature.season;

import java.nio.file.Path;
import java.time.Clock;
import net.onelitefeather.titan.core.module.LobbyWorldChoice;

/** Reaches the column's package-private test constructor from another package. */
public final class SeasonChoices {

private SeasonChoices() {
}

public static LobbyWorldChoice at(Clock clock, Path worldsDirectory) {
return new SeasonWorldChoice(clock, worldsDirectory);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Copyright 2025 OneLiteFeather Network
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.onelitefeather.titan.feature.season;

import net.onelitefeather.titan.common.map.MapEntry;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/**
* Lives in the column's package name because {@code SeasonSettings} is package-private; a column
* sees only core, so this is the one place that can compare it with {@code common}.
*/
class SeasonMapFileNameTest {

@DisplayName("The column's map file name equals MapEntry.MAP_FILE_NAME")
@Test
void mapFileNameMatchesMapEntry() {
Assertions.assertEquals(MapEntry.MAP_FILE_NAME, SeasonSettings.MAP_FILE_NAME, "the season column would validate a file the map loader never reads");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Copyright 2025 OneLiteFeather Network
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.onelitefeather.titan.feature.season;

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import io.avaje.config.Config;
import io.avaje.inject.BeanScope;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicInteger;
import net.minestom.server.event.Event;
import net.minestom.server.event.EventNode;
import net.minestom.server.timer.Scheduler;
import net.minestom.testing.Env;
import net.minestom.testing.extension.MicrotusExtension;
import net.onelitefeather.titan.common.map.MapProvider;
import net.onelitefeather.titan.core.feature.FeatureFlags;
import net.onelitefeather.titan.core.module.FeatureNode;
import net.onelitefeather.titan.core.permission.PermissionService;
import net.onelitefeather.titan.core.testfixtures.TestTitanNode;
import net.onelitefeather.titan.platform.luckperms.LuckPermsPermissionService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.LoggerFactory;

/**
* Builds the real Avaje Inject {@link BeanScope} of the {@code cloudnet} variant and drives the
* season column through it: the started world comes from the window, and once the window ends the
* restart is pending and, with nobody online, the (fake) stop is requested. The clock, the
* {@code worlds/} directory, the scheduler, the player count and the stop are the only things
* replaced. Lives in the column's package name because its seams are package-private.
*/
@ExtendWith(MicrotusExtension.class)
@Timeout(30)
class SeasonRestartWiringTest {

private static final Instant IN_WINTER = Instant.parse("2026-12-24T12:00:00Z");
private static final Instant AFTER_WINTER = Instant.parse("2027-01-07T12:00:00Z");

private final Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
private final ListAppender<ILoggingEvent> moduleLines = new ListAppender<>();

@TempDir
Path worlds;

private String originalZone;
private Env env;
private TestTitanNode titan;
private AdjustableClock clock;
private AtomicInteger stops;
private int online;
private BeanScope scope;

private static final class AdjustableClock extends Clock {

private Instant instant;

AdjustableClock(Instant instant) {
this.instant = instant;
}

@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}

@Override
public Clock withZone(ZoneId zone) {
return this;
}

@Override
public Instant instant() {
return this.instant;
}
}

@BeforeEach
void setUp(Env env) throws IOException {
this.originalZone = Config.get("seasons.zone");
this.env = env;
this.titan = TestTitanNode.attach(env);
this.clock = new AdjustableClock(IN_WINTER);
this.stops = new AtomicInteger();
this.online = 0;
this.moduleLines.start();
this.rootLogger.addAppender(this.moduleLines);
Files.writeString(Files.createDirectories(this.worlds.resolve("winter-map")).resolve("map.json"), "{}");
Config.setProperty("seasons.winter.world", "winter-map");
Config.setProperty("seasons.winter.from", "2026-12-01T00:00:00");
Config.setProperty("seasons.winter.to", "2027-01-07T00:00:00");
}

@AfterEach
void tearDown() {
if (this.scope != null) {
this.scope.close();
}
this.titan.close();
this.rootLogger.detachAppender(this.moduleLines);
Config.asProperties().stringPropertyNames().stream().filter(key -> key.startsWith("seasons.")).forEach(Config::clearProperty);
Config.setProperty("seasons.zone", this.originalZone);
}

@SuppressWarnings({"rawtypes", "unchecked"})
private void startScope() {
ServerStop stop = this.stops::incrementAndGet;
OnlinePlayers players = () -> this.online;
// Named mock, not the plain mock(Type) overload - see docs/lobby-modules.md, "Permission-Plattform".
this.scope = BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).bean(Clock.class, this.clock).bean(Scheduler.class, this.env.process().scheduler()).bean(FeatureNode.TITAN_NODE, EventNode.class, (EventNode<Event>) this.titan.node()).bean(SeasonSchedule.class, new SeasonSchedule(this.clock, this.worlds)).bean(ServerStop.class, stop).bean(OnlinePlayers.class, players).build();
}

private void tickOneMinute() {
for (int i = 0; i < SeasonModule.CHECK_INTERVAL_TICKS; i++) {
this.env.tick();
}
}

private boolean logged(String fragment) {
return this.moduleLines.list.stream().anyMatch(line -> line.getFormattedMessage().contains(fragment));
}

@DisplayName("The lobby starts in the season world while its window is open")
@Test
void lobbyStartsInTheSeasonWorld() {
startScope();

Assertions.assertTrue(logged("Lobby world winter-map (season winter)"), "the started world must be the season world, got: " + this.moduleLines.list);
}

@DisplayName("When the window ends with players online the restart is pending, not stopped")
@Test
void windowEndWithPlayersMakesTheRestartPending() {
this.online = 1;
startScope();

this.clock.instant = AFTER_WINTER;
tickOneMinute();

Assertions.assertTrue(logged("Restart to the default world pending"), "a restart to the default world must be pending, got: " + this.moduleLines.list);
Assertions.assertEquals(0, this.stops.get(), "a busy lobby must not be stopped");
}

@DisplayName("When the window ends and nobody is online the server is stopped")
@Test
void windowEndWithoutPlayersStopsTheServer() {
startScope();

this.clock.instant = AFTER_WINTER;
tickOneMinute();

Assertions.assertEquals(1, this.stops.get(), "an empty lobby must request exactly one stop");
}
}
Loading
Loading