Skip to content
Merged
13 changes: 13 additions & 0 deletions features/daytime/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
plugins {
id("titan.column")
}

dependencies {
// DaytimeModule reads daytime.* directly from the io.avaje.config.Config facade;
// titan.column does not pull it in because most columns don't need it.
implementation(libs.avaje.config)
implementation(libs.slf4j.api)

// ListAppender, for capturing the module's WARN lines in DaytimeModuleTest.
testImplementation(libs.logback.classic)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* 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.daytime;

import java.time.Instant;
import java.time.ZoneId;

/**
* Maps a wall-clock time linearly onto a Minecraft day: 06:00 is tick 0 (sunrise), 12:00 tick
* 6000, 18:00 tick 12000 and 00:00 tick 18000.
*
* <p>Only the instant and the zone go in, so the result is the same after a restart, and it
* follows the wall clock through daylight-saving changes.
*/
final class DayTimeMapping {

static final long TICKS_PER_DAY = 24000L;
static final long NOON_TICKS = 6000L;

private static final long SECONDS_PER_DAY = 86400L;
private static final long SUNRISE_SECOND_OF_DAY = 6L * 3600L;

long ticksAt(Instant instant, ZoneId zone) {
long secondOfDay = instant.atZone(zone).toLocalTime().toSecondOfDay();
long secondsSinceSunrise = Math.floorMod(secondOfDay - SUNRISE_SECOND_OF_DAY, SECONDS_PER_DAY);
return secondsSinceSunrise * TICKS_PER_DAY / SECONDS_PER_DAY;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* 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.daytime;

import io.avaje.config.Config;
import io.avaje.inject.PostConstruct;
import io.avaje.inject.PreDestroy;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.util.Objects;
import net.minestom.server.instance.Instance;
import net.minestom.server.timer.Scheduler;
import net.minestom.server.timer.Task;
import net.minestom.server.timer.TaskSchedule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Sets the lobby's time of day once per second from the wall clock of the configured zone, and
* holds it at midday while {@code daytime.enabled} is {@code false}.
*/
@Singleton
public final class DaytimeModule {

private static final Logger LOGGER = LoggerFactory.getLogger(DaytimeModule.class);
private static final TaskSchedule UPDATE_INTERVAL = TaskSchedule.tick(20);

private final Instance lobby;
private final Scheduler scheduler;
private final Clock clock;
private final DayTimeMapping mapping = new DayTimeMapping();
// Written by start() and by the scheduler thread in update().
private volatile ZoneId zone;
private volatile String rejectedZone;
private Task task;

@Inject
public DaytimeModule(Instance lobby, Scheduler scheduler, Clock clock) {
this.lobby = Objects.requireNonNull(lobby, "lobby");
this.scheduler = Objects.requireNonNull(scheduler, "scheduler");
this.clock = Objects.requireNonNull(clock, "clock");
}

@PostConstruct
void start() {
// Aborts startup on an invalid zone; later changes fall back to this last valid one.
this.zone = Config.getAs(DaytimeSettings.ZONE_KEY, ZoneId::of);
freezeMinestomClock();
// The first run sets the time now, then once per second; MapProvider's midday is only a fallback.
update();
this.task = this.scheduler.scheduleTask(this::update, UPDATE_INTERVAL, UPDATE_INTERVAL);
}

@PreDestroy
void stop() {
this.task.cancel();
}

private void freezeMinestomClock() {
var minestomClock = this.lobby.defaultClock();
if (minestomClock == null) {
LOGGER.warn("The lobby instance has no default clock; its time may keep running between updates");
} else {
minestomClock.rate(0f);
}
}

private void update() {
long ticks = enabled() ? this.mapping.ticksAt(this.clock.instant(), currentZone()) : DayTimeMapping.NOON_TICKS;
this.lobby.setTime(ticks);
}

private static boolean enabled() {
return Config.getBool(DaytimeSettings.ENABLED_KEY);
}

private ZoneId currentZone() {
String raw = Config.get(DaytimeSettings.ZONE_KEY, this.zone.getId());
if (raw.isBlank()) {
warnOnceAbout(raw, "value is blank");
return this.zone;
}
try {
this.zone = ZoneId.of(raw);
this.rejectedZone = null;
} catch (DateTimeException e) {
warnOnceAbout(raw, e.getMessage());
}
return this.zone;
}

private void warnOnceAbout(String rejected, String reason) {
if (!rejected.equals(this.rejectedZone)) {
this.rejectedZone = rejected;
LOGGER.warn("Ignoring invalid {} '{}' ({}), keeping zone {}", DaytimeSettings.ZONE_KEY, rejected, reason, this.zone);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* 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.daytime;

/**
* The {@code daytime} module's configuration keys.
*/
final class DaytimeSettings {

static final String ENABLED_KEY = "daytime.enabled";
static final String ZONE_KEY = "daytime.zone";

private DaytimeSettings() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* 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.
*/
/**
* The {@code daytime} column: the lobby's time of day follows the real wall clock. See
* {@code docs/lobby-modules.md}, "Wie eine Column Plattform-Beans bekommt", for how a column
* declares its platform dependencies.
*/
@InjectModule(name = "daytimeColumn", requires = {Instance.class, Scheduler.class, Clock.class})
package net.onelitefeather.titan.feature.daytime;

import io.avaje.inject.InjectModule;
import java.time.Clock;
import net.minestom.server.instance.Instance;
import net.minestom.server.timer.Scheduler;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# The "daytime" module: the lobby's time of day follows the real wall clock.
daytime:
# When false, the lobby stays at midday instead. Takes effect without a restart.
enabled: true
# The time zone whose wall clock the lobby follows (a java.time zone id, e.g. Europe/Berlin).
# An invalid value aborts startup; changed to an invalid value at runtime, the last valid zone stays.
zone: Europe/Berlin
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* 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.daytime;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;

/**
* A test-only {@link Clock} whose {@link #instant()} is set by the test and which counts how often
* it was read, so a test can drive {@link DaytimeModule} without wall-clock time or
* {@link Thread#sleep(long)}.
*/
final class AdjustableClock extends Clock {

private final ZoneId zone;
private Instant instant;
private int reads;

AdjustableClock(Instant instant, ZoneId zone) {
this.instant = instant;
this.zone = zone;
}

/**
* Moves this clock's current time forward by {@code duration}.
*
* @param duration the amount of time to advance by
*/
void advance(Duration duration) {
this.instant = this.instant.plus(duration);
}

int reads() {
return this.reads;
}

@Override
public ZoneId getZone() {
return this.zone;
}

@Override
public Clock withZone(ZoneId zone) {
return new AdjustableClock(this.instant, zone);
}

@Override
public Instant instant() {
this.reads++;
return this.instant;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* 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.daytime;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import net.onelitefeather.titan.core.testfixtures.architecture.ColumnArchitectureRules;

/** Applies the architecture rules every column shares to this column's own package. */
@AnalyzeClasses(packages = "net.onelitefeather.titan.feature.daytime", importOptions = ImportOption.DoNotIncludeTests.class)
class ColumnArchitectureTest {

@ArchTest
static final ArchRule featuresRegisterListenersOnlyThroughFeatureNode = ColumnArchitectureRules.FEATURES_REGISTER_LISTENERS_ONLY_THROUGH_FEATURE_NODE;

@ArchTest
static final ArchRule classesWithPostConstructAreSingleton = ColumnArchitectureRules.CLASSES_WITH_POST_CONSTRUCT_ARE_SINGLETON;

@ArchTest
static final ArchRule featureModulesDoNotUseBeanScope = ColumnArchitectureRules.FEATURE_MODULES_DO_NOT_USE_BEAN_SCOPE;

ColumnArchitectureTest() {
}
}
Loading
Loading