Initial commit

This commit is contained in:
George
2026-06-07 12:18:45 +01:00
commit 9f9b85e1f2
305 changed files with 23050 additions and 0 deletions
@@ -0,0 +1,29 @@
package com.livingworld;
import net.neoforged.fml.common.Mod;
import net.neoforged.bus.api.IEventBus;
import com.livingworld.bootstrap.LivingWorldBootstrap;
import com.livingworld.core.LivingWorldConstants;
import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger;
/**
* Mod entrypoint for Living World.
* <p>
* This class contains only mod loader wiring. All simulation logic is
* delegated to the bootstrap system and core services.
*/
@Mod(LivingWorldConstants.MOD_ID)
public class LivingWorldMod {
public static final String MOD_ID = LivingWorldConstants.MOD_ID;
public LivingWorldMod(IEventBus eventBus) {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting...");
// Delegate startup work to the bootstrap system.
new LivingWorldBootstrap().initialize();
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully.");
}
}
@@ -0,0 +1,2 @@
/** Living World API package — public interfaces and utilities. */
package com.livingworld.api;
@@ -0,0 +1,48 @@
package com.livingworld.bootstrap;
import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger;
/**
* Bootstrap class for Living World mod startup lifecycle.
*
* Each method logs its lifecycle stage. No gameplay logic, region/ecosystem,
* or persistence systems are implemented yet.
*/
public class LivingWorldBootstrap {
/**
* Called once during mod construction.
*/
public void initialize() {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "initialize — mod bootstrap initialized.");
}
/**
* Called during common (dedicated & integrated server) setup phase.
*/
public void onCommonSetup() {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "onCommonSetup — common setup phase reached.");
}
/**
* Called when the server is starting.
*/
public void onServerStarting() {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "onServerStarting — server starting.");
}
/**
* Called when the server has finished starting.
*/
public void onServerStarted() {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "onServerStarted — server started.");
}
/**
* Called when the server is stopping.
*/
public void onServerStopping() {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "onServerStopping — server stopping.");
}
}
@@ -0,0 +1,2 @@
/** Bootstrap package — mod initialization and lifecycle wiring. */
package com.livingworld.bootstrap;
@@ -0,0 +1,2 @@
/** Commands package — mod command definitions. */
package com.livingworld.commands;
@@ -0,0 +1,60 @@
package com.livingworld.config;
import com.livingworld.core.services.ConfigService;
import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger;
/**
* Default implementation of {@link ConfigService}.
*
* <p>Holds a single {@link SimulationConfig} instance. The constructor creates
* a fresh default configuration and validates it immediately so that invalid
* defaults are caught at startup rather than at runtime.</p>
*/
public class DefaultConfigService implements ConfigService {
private SimulationConfig config;
/**
* Creates a new service with default simulation settings.
*
* @throws RuntimeException if the default configuration fails validation
*/
public DefaultConfigService() {
this.config = new SimulationConfig();
validate(); // validates and logs success
}
@Override
public SimulationConfig getSimulationConfig() {
return config;
}
/**
* Reloads configuration by creating a fresh default {@link SimulationConfig}
* and revalidating it.
*/
@Override
public void reload() {
this.config = new SimulationConfig();
validate(); // validates and logs success
}
/**
* Validates the current configuration by delegating to
* {@link SimulationConfig#validate()}. On success logs an informational
* message via {@link LivingWorldLogger}.
*
* @throws RuntimeException if validation fails (wraps {@link IllegalArgumentException})
*/
@Override
public void validate() {
try {
config.validate();
LivingWorldLogger.info(DiagnosticCategory.CONFIG, "Configuration validated successfully: " + config);
} catch (IllegalArgumentException e) {
LivingWorldLogger.error(DiagnosticCategory.CONFIG, "Configuration validation failed", e);
throw new RuntimeException("Configuration validation failed", e);
}
}
}
@@ -0,0 +1,166 @@
package com.livingworld.config;
/**
* Configuration class for simulation parameters.
*
* This class holds plain integer and boolean fields that control how the
* living world simulation behaves at runtime. It is intentionally free of
* any Minecraft or NeoForge dependencies so it can be instantiated in pure
* JUnit tests.
*/
public final class SimulationConfig {
// ------------------------------------------------------------------
// Field defaults
// ------------------------------------------------------------------
/** Size of a region in chunks (must be >= 1). */
private int regionSizeChunks = 8;
/** Interval between simulation cycles, in game ticks (must be >= 1). */
private int simulationIntervalTicks = 100;
/** Maximum number of regions processed per cycle (must be >= 1). */
private int maxRegionsPerCycle = 50;
/** Maximum time budget for a single cycle in milliseconds (must be >= 1). */
private int maxMillisecondsPerCycle = 25;
/** Emergency stop threshold when simulation is overrunning (>= maxMillisecondsPerCycle). */
private int emergencyStopMilliseconds = 40;
/** Enable diagnostic / debug commands. */
private boolean enableDebugCommands = true;
/** Enable the built-in profiler. */
private boolean enableProfiler = true;
// ------------------------------------------------------------------
// Constructors
// ------------------------------------------------------------------
/** Default constructor uses all default values. */
public SimulationConfig() {
// Intentionally empty; fields are initialised at declaration.
}
// ------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------
public int getRegionSizeChunks() {
return regionSizeChunks;
}
public int getSimulationIntervalTicks() {
return simulationIntervalTicks;
}
public int getMaxRegionsPerCycle() {
return maxRegionsPerCycle;
}
public int getMaxMillisecondsPerCycle() {
return maxMillisecondsPerCycle;
}
public int getEmergencyStopMilliseconds() {
return emergencyStopMilliseconds;
}
public boolean isEnableDebugCommands() {
return enableDebugCommands;
}
public boolean isEnableProfiler() {
return enableProfiler;
}
// ------------------------------------------------------------------
// Setters (package-private for testability)
// ------------------------------------------------------------------
public void setRegionSizeChunks(final int regionSizeChunks) {
this.regionSizeChunks = regionSizeChunks;
}
public void setSimulationIntervalTicks(final int simulationIntervalTicks) {
this.simulationIntervalTicks = simulationIntervalTicks;
}
public void setMaxRegionsPerCycle(final int maxRegionsPerCycle) {
this.maxRegionsPerCycle = maxRegionsPerCycle;
}
public void setMaxMillisecondsPerCycle(final int maxMillisecondsPerCycle) {
this.maxMillisecondsPerCycle = maxMillisecondsPerCycle;
}
public void setEmergencyStopMilliseconds(final int emergencyStopMilliseconds) {
this.emergencyStopMilliseconds = emergencyStopMilliseconds;
}
public void setEnableDebugCommands(final boolean enableDebugCommands) {
this.enableDebugCommands = enableDebugCommands;
}
public void setEnableProfiler(final boolean enableProfiler) {
this.enableProfiler = enableProfiler;
}
// ------------------------------------------------------------------
// Validation
// ------------------------------------------------------------------
/**
* Validates every field against its constraints. Throws
* {@link IllegalArgumentException} with a descriptive message if any
* constraint is violated.
*
* @throws IllegalArgumentException if validation fails
*/
public void validate() {
if (regionSizeChunks < 1) {
throw new IllegalArgumentException(
"regionSizeChunks must be >= 1, got: " + regionSizeChunks);
}
if (simulationIntervalTicks < 1) {
throw new IllegalArgumentException(
"simulationIntervalTicks must be >= 1, got: " + simulationIntervalTicks);
}
if (maxRegionsPerCycle < 1) {
throw new IllegalArgumentException(
"maxRegionsPerCycle must be >= 1, got: " + maxRegionsPerCycle);
}
if (maxMillisecondsPerCycle < 1) {
throw new IllegalArgumentException(
"maxMillisecondsPerCycle must be >= 1, got: " + maxMillisecondsPerCycle);
}
if (emergencyStopMilliseconds < maxMillisecondsPerCycle) {
throw new IllegalArgumentException(
"emergencyStopMilliseconds (" + emergencyStopMilliseconds
+ ") must be >= maxMillisecondsPerCycle (" + maxMillisecondsPerCycle + ")");
}
}
// ------------------------------------------------------------------
// Utility / Debug
// ------------------------------------------------------------------
@Override
public String toString() {
return "SimulationConfig{"
+ "regionSizeChunks=" + regionSizeChunks
+ ", simulationIntervalTicks=" + simulationIntervalTicks
+ ", maxRegionsPerCycle=" + maxRegionsPerCycle
+ ", maxMillisecondsPerCycle=" + maxMillisecondsPerCycle
+ ", emergencyStopMilliseconds=" + emergencyStopMilliseconds
+ ", enableDebugCommands=" + enableDebugCommands
+ ", enableProfiler=" + enableProfiler
+ '}';
}
}
@@ -0,0 +1,2 @@
/** Configuration package — mod configuration classes and settings. */
package com.livingworld.config;
@@ -0,0 +1,26 @@
package com.livingworld.core;
/**
* Constants used throughout the Living World mod.
*/
public final class LivingWorldConstants {
private LivingWorldConstants() {
// Utility class — no instantiation.
}
/** The unique mod identifier string. */
public static final String MOD_ID = "livingworld";
/** Human-readable mod name displayed in the mod list. */
public static final String MOD_NAME = "Living World";
/** Current schema version for data persistence and migration tracking. */
public static final int CURRENT_CORE_SCHEMA_VERSION = 1;
/** Default region size measured in chunks (8x8 chunks). */
public static final int DEFAULT_REGION_SIZE_CHUNKS = 8;
/** Default simulation update interval in ticks (100 ticks ≈ 5 seconds). */
public static final int DEFAULT_SIMULATION_INTERVAL_TICKS = 100;
}
@@ -0,0 +1,2 @@
/** Core lifecycle package — mod event lifecycle management. */
package com.livingworld.core.lifecycle;
@@ -0,0 +1,2 @@
/** Core package — fundamental mod infrastructure and shared utilities. */
package com.livingworld.core;
@@ -0,0 +1,2 @@
/** Core registry package — block, item, and entity registration utilities. */
package com.livingworld.core.registry;
@@ -0,0 +1,39 @@
package com.livingworld.core.services;
import com.livingworld.config.SimulationConfig;
/**
* Service interface for managing simulation configuration.
*
* Implementations of this interface provide access to the current
* {@link SimulationConfig}, support reloading from external sources,
* and validate that all values are within acceptable ranges.
*/
public interface ConfigService {
/**
* Returns the current simulation configuration.
*
* @return the active {@link SimulationConfig} instance
*/
SimulationConfig getSimulationConfig();
/**
* Reloads configuration from its external source (e.g., config file, mod settings).
* Implementations should handle I/O and deserialization internally.
*/
void reload();
/**
* Validates the current configuration by delegating to {@link SimulationConfig#validate()}.
*
* @throws RuntimeException if validation fails (wraps {@link IllegalArgumentException})
*/
default void validate() {
try {
getSimulationConfig().validate();
} catch (IllegalArgumentException e) {
throw new RuntimeException("Configuration validation failed", e);
}
}
}
@@ -0,0 +1,69 @@
package com.livingworld.core.services;
import com.livingworld.core.simulation.PersistenceService;
import com.livingworld.core.simulation.RegionManager;
import com.livingworld.core.simulation.SimulationManager;
import com.livingworld.events.LivingWorldEventBus;
import com.livingworld.modules.ModuleRegistry;
/**
* Central list of built-in service keys for the Living World core services.
*
* <p>This class is a final utility class with a private constructor. Each field
* defines a {@link ServiceKey} that will be used to register and retrieve core
* services once their implementations exist.</p>
*
* <p>Only CONFIG can use its real type (ConfigService) since that interface already exists.
* All other keys currently use Object as a placeholder with TODO comments pointing to the
* relevant milestone where the actual service implementation will be created.</p>
*
* <h3>Migration Guide</h3>
* <p>As each service interface is created during its milestone, migrate its key from
* {@code Object} to the real type. For example:</p>
* <pre>{@code
* // Before (placeholder):
* @SuppressWarnings("unchecked")
* public static final ServiceKey<Object> REGIONS = new ServiceKey<>("regions", Object.class);
*
* // After (typed - when RegionManager interface is created):
* public static final ServiceKey<RegionManager> REGIONS = new ServiceKey<>("regions", RegionManager.class);
* }</pre>
* <p>Benefits of typed keys: compile-time type safety, no unchecked casts in caller code,
* and the {@link ServiceRegistry#find(ServiceKey)} method returns {@code Optional<T>} with
* the correct type.</p>
*/
public final class CoreServices {
private CoreServices() {
// Utility class — no instantiation allowed.
}
/** ConfigService key — interface already exists. */
public static final ServiceKey<ConfigService> CONFIG = new ServiceKey<>("config", ConfigService.class);
public static final ServiceKey<RegionManager> REGIONS =
new ServiceKey<>("regions", RegionManager.class);
public static final ServiceKey<SimulationManager> SIMULATION =
new ServiceKey<>("simulation", SimulationManager.class);
public static final ServiceKey<PersistenceService> PERSISTENCE =
new ServiceKey<>("persistence", PersistenceService.class);
public static final ServiceKey<LivingWorldEventBus> EVENTS =
new ServiceKey<>("events", LivingWorldEventBus.class);
public static final ServiceKey<ModuleRegistry> MODULES =
new ServiceKey<>("modules", ModuleRegistry.class);
public static final ServiceKey<TimeService> TIME =
new ServiceKey<>("time", TimeService.class);
// TODO: Replace Object with DebugService when created (Milestone 15)
@SuppressWarnings("unchecked")
public static final ServiceKey<Object> DEBUG = new ServiceKey<>("debug", Object.class);
// TODO: Replace Object with HistoryService when created (post-Volume 1)
@SuppressWarnings("unchecked")
public static final ServiceKey<Object> HISTORY = new ServiceKey<>("history", Object.class);
}
@@ -0,0 +1,35 @@
package com.livingworld.core.services;
/**
* Immutable key used to identify and look up services in a service registry.
*
* @param <T> the type of the service this key identifies
*/
public record ServiceKey<T>(String id, Class<T> serviceType) {
/**
* Creates a new service key with validation.
*
* @param id the unique identifier for the service (must not be null or blank)
* @param serviceType the class type of the service (must not be null)
* @throws IllegalArgumentException if {@code id} is null or blank, or {@code serviceType} is null
*/
public ServiceKey {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Service key id must not be null or blank");
}
if (serviceType == null) {
throw new IllegalArgumentException("Service key serviceType must not be null");
}
}
/**
* Returns a stable string representation of this service key.
*
* @return formatted string with id and fully-qualified service type name
*/
@Override
public String toString() {
return "ServiceKey{id='" + id + "', type='" + serviceType.getName() + "'}";
}
}
@@ -0,0 +1,129 @@
package com.livingworld.core.services;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* A plain Java service registry that stores and retrieves services by their {@link ServiceKey}.
*
* <p>The registry supports registration, lookup, and a lock mechanism to prevent further
* modifications once all services have been registered.</p>
*
* <p>This class is not thread-safe. If multiple threads access the registry concurrently,
* external synchronization is required.</p>
*/
public final class ServiceRegistry {
private Map<ServiceKey<?>, Object> services = new HashMap<>();
private boolean locked = false;
/**
* Registers a service with the given key.
*
* @param key the service key identifying the service (must not be null)
* @param service the service instance to register (must not be null)
* @throws IllegalArgumentException if {@code key} is null, {@code service} is null
* @throws IllegalStateException if this registry is already locked, or a service with the same key is already registered
*/
@SuppressWarnings("unchecked")
public <T> void register(ServiceKey<T> key, T service) {
if (key == null) {
throw new IllegalArgumentException("Service key must not be null");
}
if (service == null) {
throw new IllegalArgumentException("Service instance must not be null for key: " + key);
}
if (locked) {
throw new IllegalStateException("Cannot register services after the registry is locked");
}
// Use the key itself as the map key. Since ServiceKey overrides equals/hashCode,
// duplicate registration of the same key will be detected.
if (services.containsKey(key)) {
throw new IllegalStateException("A service with key " + key + " is already registered");
}
services.put(key, service);
}
/**
* Returns the service identified by the given key.
*
* @param <T> the type of the service
* @param key the service key identifying the service (must not be null)
* @return the registered service instance
* @throws IllegalArgumentException if {@code key} is null
* @throws IllegalStateException if no service is registered for the given key
*/
@SuppressWarnings("unchecked")
public <T> T get(ServiceKey<T> key) {
if (key == null) {
throw new IllegalArgumentException("Service key must not be null");
}
Object service = services.get(key);
if (service == null) {
throw new IllegalStateException("No service registered for key: " + key);
}
return (T) service;
}
/**
* Returns an {@link Optional} containing the service identified by the given key,
* or empty if no service is registered.
*
* @param <T> the type of the service
* @param key the service key identifying the service (must not be null)
* @return an optional containing the service, or empty if not found
* @throws IllegalArgumentException if {@code key} is null
*/
@SuppressWarnings("unchecked")
public <T> Optional<T> find(ServiceKey<T> key) {
if (key == null) {
throw new IllegalArgumentException("Service key must not be null");
}
return Optional.ofNullable((T) services.get(key));
}
/**
* Returns whether a service is registered for the given key.
*
* @param key the service key to check (must not be null)
* @return true if a service is registered for the key, false otherwise
* @throws IllegalArgumentException if {@code key} is null
*/
public boolean isRegistered(ServiceKey<?> key) {
if (key == null) {
throw new IllegalArgumentException("Service key must not be null");
}
return services.containsKey(key);
}
/**
* Returns the number of services currently registered.
*
* @return the count of registered services
*/
public int getServiceCount() {
return services.size();
}
/**
* Locks this registry, preventing any further registrations.
*
* <p>After locking, the internal service map is replaced with an unmodifiable
* view to guarantee no external code can mutate registered services.</p>
*/
public void lock() {
locked = true;
this.services = Collections.unmodifiableMap(new HashMap<>(services));
}
/**
* Returns whether this registry is locked.
*
* @return true if the registry is locked, false otherwise
*/
public boolean isLocked() {
return locked;
}
}
@@ -0,0 +1,44 @@
package com.livingworld.core.services;
import com.livingworld.core.simulation.LivingWorldCalendar;
/**
* Service interface for managing simulation time.
*
* <p>This service provides access to the simulation tick counter and calendar,
* as well as methods to advance the simulation clock.</p>
*/
public interface TimeService {
/**
* Returns the current simulation tick counter value.
*
* @return the current simulation tick (non-negative)
*/
long getSimulationTick();
/**
* Returns the current {@link LivingWorldCalendar} instance.
*
* <p>The calendar tracks day, month, year based on the simulation tick.</p>
*
* @return the current living world calendar
*/
LivingWorldCalendar getCalendar();
/**
* Advances the simulation clock by a single tick.
*
* <p>This method increments the simulation tick counter and updates the
* corresponding calendar date.</p>
*/
void advanceSimulationTick();
/**
* Advances the simulation clock by the specified number of ticks.
*
* @param ticks number of ticks to advance (must be non-negative)
* @throws IllegalArgumentException if ticks is negative
*/
void advanceSimulationTicks(long ticks);
}
@@ -0,0 +1,2 @@
/** Core services package — shared service layer abstractions. */
package com.livingworld.core.services;
@@ -0,0 +1,60 @@
package com.livingworld.core.simulation;
import com.livingworld.core.services.TimeService;
/**
* Default implementation of {@link TimeService}.
*
* <p>This service owns an internal {@link LivingWorldCalendar} instance and provides
* thread-safe access to it by returning defensive copies via {@link #getCalendar()}.</p>
*/
public class DefaultTimeService implements TimeService {
// ------------------------------------------------------------------
// Internal state
// ------------------------------------------------------------------
/** Internal mutable calendar instance. Used for tracking simulation time. */
private final LivingWorldCalendar internalCalendar;
// ------------------------------------------------------------------
// Constructors
// ------------------------------------------------------------------
/**
* Creates a new {@code DefaultTimeService} with a calendar starting at epoch.
*
* <p>The initial state is: day=1, month=1, year=1, simulationTick=0.</p>
*/
public DefaultTimeService() {
this.internalCalendar = new LivingWorldCalendar();
}
// ------------------------------------------------------------------
// TimeService interface implementation
// ------------------------------------------------------------------
@Override
public long getSimulationTick() {
return internalCalendar.getSimulationTick();
}
@Override
public LivingWorldCalendar getCalendar() {
// Return a defensive copy to prevent external mutation of internal state
return internalCalendar.copy();
}
@Override
public void advanceSimulationTick() {
internalCalendar.advanceTicks(1);
}
@Override
public void advanceSimulationTicks(long ticks) {
if (ticks < 0L) {
throw new IllegalArgumentException("ticks must be >= 0, got: " + ticks);
}
internalCalendar.advanceTicks(ticks);
}
}
@@ -0,0 +1,171 @@
package com.livingworld.core.simulation;
/**
* Simple calendar for tracking simulation time in the living world.
*
* <p>Uses a fixed 30-day month / 12-month year structure unless configured otherwise later.
* The {@code simulationTick} field serves as a monotonic counter that advances
* with each tick operation and can be used for persistence or external tracking.</p>
*/
public class LivingWorldCalendar {
/** Days per month (30 days/month). */
private static final int DAYS_PER_MONTH = 30;
/** Months per year (12 months/year). */
private static final int MONTHS_PER_YEAR = 12;
// ------------------------------------------------------------------
// Fields
// ------------------------------------------------------------------
/** Monotonic simulation tick counter. Starts at 0 by default. */
private long simulationTick;
/** Current day of the month (1-30). */
private int day;
/** Current month of the year (1-12). */
private int month;
/** Current year number (starts at 1). */
private int year;
// ------------------------------------------------------------------
// Constructors
// ------------------------------------------------------------------
/**
* Default constructor starts at the epoch date.
*
* <p>Initial values: day=1, month=1, year=1, simulationTick=0.</p>
*/
public LivingWorldCalendar() {
this.simulationTick = 0L;
this.day = 1;
this.month = 1;
this.year = 1;
}
// ------------------------------------------------------------------
// State getters (read-only for calendar access)
// ------------------------------------------------------------------
/** Returns the current simulation tick counter. */
public long getSimulationTick() {
return simulationTick;
}
/** Returns the current day of the month. */
public int getDay() {
return day;
}
/** Returns the current month of the year. */
public int getMonth() {
return month;
}
/** Returns the current year number. */
public int getYear() {
return year;
}
// ------------------------------------------------------------------
// Calendar operations
// ------------------------------------------------------------------
/**
* Advances the simulation tick by the specified amount and updates the calendar.
*
* <p>The calendar progresses day-by-day as ticks are added. Days overflow into months,
* and months overflow into years. Uses a fixed 30 days per month.</p>
*
* @param ticks number of simulation ticks to advance (must be non-negative)
* @throws IllegalArgumentException if ticks is negative
*/
public void advanceTicks(long ticks) {
if (ticks < 0L) {
throw new IllegalArgumentException("ticks must be >= 0, got: " + ticks);
}
this.simulationTick += ticks;
// Advance day-by-day based on the number of ticks
long totalDaysFromEpoch = ((long) year - 1) * (MONTHS_PER_YEAR * DAYS_PER_MONTH)
+ ((long) month - 1) * DAYS_PER_MONTH + (day - 1);
long targetTotalDaysFromEpoch = totalDaysFromEpoch + ticks;
// Recalculate date from absolute index
year = 1 + (int) (targetTotalDaysFromEpoch / (MONTHS_PER_YEAR * DAYS_PER_MONTH));
long remainingAfterYears = targetTotalDaysFromEpoch % (MONTHS_PER_YEAR * DAYS_PER_MONTH);
month = 1 + (int) (remainingAfterYears / DAYS_PER_MONTH);
day = 1 + (int) (remainingAfterYears % DAYS_PER_MONTH);
}
// ------------------------------------------------------------------
// Deep copy implementation
// ------------------------------------------------------------------
/**
* Returns a deep copy of this calendar instance.
*
* <p>All fields are copied to a new {@code LivingWorldCalendar} object.</p>
*
* @return a new {@code LivingWorldCalendar} with the same state
*/
public LivingWorldCalendar copy() {
LivingWorldCalendar copy = new LivingWorldCalendar();
copy.simulationTick = this.simulationTick;
copy.day = this.day;
copy.month = this.month;
copy.year = this.year;
return copy;
}
// ------------------------------------------------------------------
// Display / Formatting
// ------------------------------------------------------------------
/**
* Returns a human-readable string representation of the calendar date.
*
* <p>Format: {@code "YYYY-MM-DD"} (e.g., "2025-06-15").</p>
*
* @return formatted date string in "YYYY-MM-DD" format
*/
public String toDisplayString() {
return year + "-" + String.format("%02d", month) + "-"
+ String.format("%02d", day);
}
/**
* Returns a string representation of this calendar's state.
*
* @return {@code "LivingWorldCalendar{simulationTick=..., day=..., month=..., year=...}"}}
*/
@Override
public String toString() {
return "LivingWorldCalendar{"
+ "simulationTick=" + simulationTick
+ ", day=" + day
+ ", month=" + month
+ ", year=" + year
+ '}';
}
// ------------------------------------------------------------------
// Utility
// ------------------------------------------------------------------
/**
* Returns the total number of days that have elapsed since epoch.
*
* @return total days elapsed (simulationTick / 1, assuming 1 tick = 1 day)
*/
public long getDaysElapsed() {
return simulationTick;
}
}
@@ -0,0 +1,39 @@
package com.livingworld.core.simulation;
import java.util.Optional;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
/**
* Handles region persistence (save/load) and migration support.
*
* <p><b>TODO (Milestone 8):</b> Implement actual file or database I/O,
* platform-specific save paths, and schema versioning.</p>
*/
public interface PersistenceService {
/**
* Saves a region to persistent storage.
*
* @param region the region to save (must not be null)
*/
void save(Region region);
/**
* Loads a region from persistent storage.
*
* @param coordinate the region coordinate to load (must not be null)
* @return an optional containing the loaded region if found, otherwise empty
*/
Optional<Region> load(RegionCoordinate coordinate);
/**
* Returns whether this persistence service supports data migration.
*
* <p><b>TODO:</b> Implement schema version detection and migration logic.</p>
*
* @return true if migration is supported
*/
boolean supportsMigration();
}
@@ -0,0 +1,39 @@
package com.livingworld.core.simulation;
import java.util.List;
import java.util.Optional;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
/**
* Manages region resolution and dirty-state tracking.
*
* <p><b>TODO (Milestone 8):</b> Implement actual region loading, unloading,
* chunk scanning, and persistence integration.</p>
*/
public interface RegionManager {
/**
* Resolves a region by its coordinate.
*
* @param coordinate the region coordinate (must not be null)
* @return an optional containing the region if found, otherwise empty
*/
Optional<Region> resolve(RegionCoordinate coordinate);
/**
* Resolves multiple regions by their coordinates.
*
* @param coordinates list of region coordinates to resolve (must not be null)
* @return a list of resolved regions (may contain fewer than requested if some are missing)
*/
List<Region> resolveAll(List<RegionCoordinate> coordinates);
/**
* Marks a region as dirty, indicating unsaved changes.
*
* @param region the region to mark dirty (must not be null)
*/
void markDirty(Region region);
}
@@ -0,0 +1,75 @@
package com.livingworld.core.simulation;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionFlags;
import com.livingworld.regions.RegionMetrics;
/**
* Utility class for computing update priority scores based on region ecosystem state.
*
* <p>This calculator uses a deterministic formula that considers player activity,
* ecosystem events, pollution, soil quality, resource depletion, and recovery pressure.</p>
*/
public final class RegionPriorityCalculator {
private static final int BASE_PRIORITY = 5;
private static final int PLAYER_ACTIVITY_BONUS = 100;
private static final int ACTIVE_ECOSYSTEM_EVENT_BONUS = 75;
private static final int HIGH_POLLUTION_BONUS = 50;
private static final int LOW_SOIL_QUALITY_BONUS = 50;
/**
* Calculates the update priority for a region based on its ecosystem state.
*
* <p>Priority score formula:
* base = 5
* + hasPlayerActivity ? 100 : 0
* + hasActiveEcosystemEvent ? 75 : 0
* + isHighPollution ? 50 : 0
* + isLowSoilQuality ? 50 : 0
* + pollutionScore / 2
* + resourceDepletion / 3
* + recoveryPressure / 4</p>
*
* @param region the region to calculate priority for (must not be null)
* @return the computed integer priority score
*/
public static int calculatePriority(Region region) {
if (region == null) {
throw new IllegalArgumentException("region must not be null");
}
RegionMetrics metrics = region.getMetrics();
RegionFlags flags = region.getFlags();
int priority = BASE_PRIORITY;
// Discrete bonuses for flagged conditions
if (flags.isHasPlayerActivity()) {
priority += PLAYER_ACTIVITY_BONUS;
}
if (flags.isHasActiveEcosystemEvent()) {
priority += ACTIVE_ECOSYSTEM_EVENT_BONUS;
}
if (flags.isHasHighPollution()) {
priority += HIGH_POLLUTION_BONUS;
}
if (flags.isHasLowSoilQuality()) {
priority += LOW_SOIL_QUALITY_BONUS;
}
// Continuous bonuses from metrics
priority += (int) (metrics.getPollutionScore() / 2.0);
priority += (int) (metrics.getResourceDepletion() / 3.0);
priority += (int) (metrics.getRecoveryPressure() / 4.0);
return priority;
}
/**
* Private constructor to prevent instantiation.
*/
private RegionPriorityCalculator() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
}
}
@@ -0,0 +1,55 @@
package com.livingworld.core.simulation;
import java.util.Comparator;
import java.util.Set;
import com.livingworld.regions.RegionCoordinate;
/**
* Immutable job representing a pending region update request.
*
* <p>A RegionUpdateJob captures why an update is needed and when it was queued,
* along with which modules should be re-evaluated for that region.</p>
*/
public record RegionUpdateJob(
RegionCoordinate coordinate,
int priority,
long queuedAtSimulationTick,
Set<String> requestedModules,
UpdateReason reason
) {
/**
* Creates a new region update job.
*
* @param coordinate the region coordinate (must not be null)
* @param priority priority for ordering jobs (higher = more urgent; must be non-negative)
* @param queuedAtSimulationTick timestamp of when this job was queued (must be non-negative)
* @param requestedModules set of module names to update (null becomes empty set)
* @param reason reason for the update request (must not be null)
*/
public RegionUpdateJob {
if (coordinate == null) {
throw new IllegalArgumentException("coordinate must not be null");
}
if (priority < 0) {
throw new IllegalArgumentException("priority must be non-negative");
}
if (queuedAtSimulationTick < 0L) {
throw new IllegalArgumentException("queuedAtSimulationTick must be non-negative");
}
if (reason == null) {
throw new IllegalArgumentException("reason must not be null");
}
requestedModules = requestedModules == null ? Set.of() : Set.copyOf(requestedModules);
}
/**
* Comparator for sorting jobs by priority in descending order.
* Higher priority values are processed first.
*/
public static final Comparator<RegionUpdateJob> BY_PRIORITY_DESC =
Comparator.comparingInt(RegionUpdateJob::priority)
.reversed()
.thenComparingLong(RegionUpdateJob::queuedAtSimulationTick);
}
@@ -0,0 +1,180 @@
package com.livingworld.core.simulation;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import com.livingworld.core.services.TimeService;
import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger;
import com.livingworld.events.LivingWorldEventBus;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.ModuleRegistry;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.SimulationModule;
import com.livingworld.regions.Region;
/**
* Central coordinator for the living world simulation lifecycle.
*
* <p>This class orchestrates tick processing, region updates, module execution,
* event publishing, and time advancement. It does not implement ecosystem rules —
* those live in individual {@link com.livingworld.modules.SimulationModule} classes.</p>
*/
public final class SimulationManager {
private final SimulationScheduler scheduler;
private final RegionManager regionManager;
private final ModuleRegistry moduleRegistry;
private final LivingWorldEventBus eventBus;
private final TimeService timeService;
private final PersistenceService persistenceService;
private final SimulationProfiler profiler;
public SimulationManager(SimulationScheduler scheduler,
RegionManager regionManager,
ModuleRegistry moduleRegistry,
LivingWorldEventBus eventBus,
TimeService timeService,
PersistenceService persistenceService,
SimulationProfiler profiler) {
this.scheduler = requireNonNull(scheduler, "scheduler");
this.regionManager = requireNonNull(regionManager, "regionManager");
this.moduleRegistry = requireNonNull(moduleRegistry, "moduleRegistry");
this.eventBus = requireNonNull(eventBus, "eventBus");
this.timeService = requireNonNull(timeService, "timeService");
this.persistenceService = requireNonNull(persistenceService, "persistenceService");
this.profiler = profiler;
}
private static <T> T requireNonNull(T value, String name) {
if (value == null) {
throw new IllegalArgumentException(name + " must not be null");
}
return value;
}
public void onMinecraftServerTick() {
this.scheduler.onMinecraftTick();
if (this.scheduler.shouldRunSimulationCycle()) {
runSimulationCycle();
}
}
/**
* Runs a single simulation cycle.
*/
public void runSimulationCycle() {
long startTimeNanos = System.nanoTime();
long simulationTick = this.timeService.getSimulationTick();
if (this.profiler != null) {
this.profiler.startCycle(simulationTick);
}
List<RegionUpdateJob> jobs = this.scheduler.pollJobsForCycle();
int nextJobIndex = 0;
try {
for (; nextJobIndex < jobs.size(); nextJobIndex++) {
if (hasExceededTimeBudget(startTimeNanos)) {
requeueJobs(jobs, nextJobIndex);
break;
}
RegionUpdateJob job = jobs.get(nextJobIndex);
Optional<Region> region = this.regionManager.resolve(job.coordinate());
if (region.isEmpty()) {
continue;
}
runModulesForRegion(region.get(), job, simulationTick);
}
this.timeService.advanceSimulationTick();
this.scheduler.recordCompletedSimulationCycle();
} finally {
long durationMs = elapsedMilliseconds(startTimeNanos);
this.scheduler.endCycle(durationMs);
if (this.profiler != null) {
this.profiler.endCycle(durationMs);
}
}
}
private boolean hasExceededTimeBudget(long startTimeNanos) {
return elapsedMilliseconds(startTimeNanos) >= this.scheduler.getMaxMillisecondsPerCycle();
}
private static long elapsedMilliseconds(long startTimeNanos) {
return (System.nanoTime() - startTimeNanos) / 1_000_000L;
}
private void requeueJobs(List<RegionUpdateJob> jobs, int firstUnprocessedIndex) {
for (int i = firstUnprocessedIndex; i < jobs.size(); i++) {
this.scheduler.queueRegion(jobs.get(i));
}
}
private void runModulesForRegion(Region region, RegionUpdateJob job, long simulationTick) {
Set<String> requestedModules = job.requestedModules();
boolean changed = false;
for (SimulationModule module : this.moduleRegistry.getEnabledModules()) {
if (!requestedModules.isEmpty() && !requestedModules.contains(module.getModuleId())) {
continue;
}
try {
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
if (result == null) {
throw new IllegalStateException(
"Module " + module.getModuleId() + " returned a null update result");
}
changed |= result.changedRegion();
result.generatedEvents().forEach(this.eventBus::publish);
result.warnings().forEach(warning -> LivingWorldLogger.warn(
DiagnosticCategory.SIMULATION,
"[" + module.getModuleId() + "] " + warning));
} catch (Exception e) {
LivingWorldLogger.error(DiagnosticCategory.SIMULATION,
"Module update failed for " + module.getModuleId()
+ " in region " + region.getCoordinate(), e);
}
}
region.updateLastSimulatedTick(simulationTick);
if (changed) {
this.regionManager.markDirty(region);
}
}
/**
* Runs forced simulation ticks for debugging or testing.
*/
public void runForcedSimulationTicks(int ticks) {
if (ticks <= 0 || ticks > 100) {
throw new IllegalArgumentException(
String.format("forced ticks must be in range [1, 100], got: %d", ticks));
}
for (int i = 0; i < ticks; i++) {
runSimulationCycle();
}
}
public long getMinecraftTickCounter() {
return this.scheduler.getMinecraftTickCounter();
}
public long getSimulationTickCounter() {
return this.scheduler.getSimulationTickCounter();
}
@Override
public String toString() {
return "SimulationManager{"
+ "minecraftTick=" + getMinecraftTickCounter()
+ ", simulationTick=" + getSimulationTickCounter()
+ '}';
}
}
@@ -0,0 +1,23 @@
package com.livingworld.core.simulation;
/**
* Optional profiler for tracking simulation cycle performance.
*
* <p>If no implementation is provided, all calls are no-ops.</p>
*/
public interface SimulationProfiler {
/**
* Called at the start of a simulation cycle.
*
* @param simulationTick the current simulation tick
*/
void startCycle(long simulationTick);
/**
* Called when a simulation cycle completes.
*
* @param durationMs the wall-clock duration in milliseconds
*/
void endCycle(long durationMs);
}
@@ -0,0 +1,125 @@
package com.livingworld.core.simulation;
import com.livingworld.config.SimulationConfig;
import com.livingworld.debug.LivingWorldLogger;
import java.util.ArrayList;
import java.util.List;
import java.util.PriorityQueue;
/**
* Scheduler that drives the simulation tick cycle.
*
* <p>This class integrates Minecraft game ticks with living world simulation ticks,
* manages a priority queue of update jobs, and enforces per-cycle budget limits.</p>
*/
public final class SimulationScheduler {
private final SimulationConfig config;
private long minecraftTickCounter;
private long simulationTickCounter;
private final PriorityQueue<RegionUpdateJob> updateQueue;
public SimulationScheduler(SimulationConfig config) {
if (config == null) {
throw new IllegalArgumentException("config must not be null");
}
this.config = config;
this.minecraftTickCounter = 0L;
this.simulationTickCounter = 0L;
this.updateQueue = new PriorityQueue<>(RegionUpdateJob.BY_PRIORITY_DESC);
}
public void onMinecraftTick() {
this.minecraftTickCounter++;
}
void recordCompletedSimulationCycle() {
this.simulationTickCounter++;
}
public boolean shouldRunSimulationCycle() {
return minecraftTickCounter % config.getSimulationIntervalTicks() == 0;
}
public void queueRegion(RegionUpdateJob job) {
if (job == null) {
throw new IllegalArgumentException("job must not be null");
}
this.updateQueue.add(job);
}
/**
* Polls jobs for processing during the current simulation cycle.
*
* @return list of polled jobs, may be empty if no jobs are queued or budget exceeded
*/
public List<RegionUpdateJob> pollJobsForCycle() {
final int maxCount = config.getMaxRegionsPerCycle();
final ArrayList<RegionUpdateJob> result = new ArrayList<>(maxCount);
for (int i = 0; i < maxCount && !this.updateQueue.isEmpty(); i++) {
RegionUpdateJob job = this.updateQueue.poll();
if (job != null) {
result.add(job);
} else {
break;
}
}
return result;
}
public void endCycle(long durationMs) {
if (durationMs > config.getMaxMillisecondsPerCycle()) {
LivingWorldLogger.warn(String.format(
"Simulation cycle exceeded time budget: %dms elapsed vs %dms allowed",
durationMs, config.getMaxMillisecondsPerCycle()));
}
final int emergencyMs = config.getEmergencyStopMilliseconds();
if (durationMs > emergencyMs) {
LivingWorldLogger.warn(String.format("EMERGENCY STOP: Cycle duration %dms exceeded emergency threshold %dms",
durationMs, emergencyMs));
}
}
public long getMinecraftTickCounter() {
return this.minecraftTickCounter;
}
public long getSimulationTickCounter() {
return this.simulationTickCounter;
}
int getSimulationIntervalTicks() {
return config.getSimulationIntervalTicks();
}
int getMaxRegionsPerCycle() {
return config.getMaxRegionsPerCycle();
}
public int getMaxMillisecondsPerCycle() {
return config.getMaxMillisecondsPerCycle();
}
public int getEmergencyStopMilliseconds() {
return config.getEmergencyStopMilliseconds();
}
public void clearQueue() {
this.updateQueue.clear();
}
public int getQueuedJobCount() {
return this.updateQueue.size();
}
@Override
public String toString() {
return "SimulationScheduler{"
+ "minecraftTickCounter=" + minecraftTickCounter
+ ", simulationTickCounter=" + simulationTickCounter
+ ", queuedJobs=" + updateQueue.size()
+ '}';
}
}
@@ -0,0 +1,32 @@
package com.livingworld.core.simulation;
/**
* Enumeration of reasons that trigger an update in the simulation.
*
* <p>This enum captures why a particular region or module needs to be re-evaluated
* or refreshed during the simulation tick cycle.</p>
*/
public enum UpdateReason {
/** Normal periodic rolling update from the simulation tick counter. */
NORMAL_ROLLING_UPDATE,
/** A player has entered the proximity threshold of a region. */
PLAYER_NEARBY,
/** Pollution levels in a region have crossed a critical threshold. */
HIGH_POLLUTION,
/** Soil quality has degraded below acceptable thresholds. */
LOW_SOIL_QUALITY,
/** An active ecosystem event is currently affecting the region. */
ACTIVE_ECOSYSTEM_EVENT,
/** Debug trigger via command to force an immediate update. */
FORCED_DEBUG_COMMAND,
/** Save data requires migration due to schema changes. */
SAVE_MIGRATION_REQUIRED;
}
@@ -0,0 +1,2 @@
/** Core simulation package — simulation engine placeholders. */
package com.livingworld.core.simulation;
@@ -0,0 +1,2 @@
/** Data migration package — data versioning and migration utilities. */
package com.livingworld.data.migration;
@@ -0,0 +1,2 @@
/** Data package — data persistence and management layer. */
package com.livingworld.data;
@@ -0,0 +1,2 @@
/** Data saved package — saved data structures and storage. */
package com.livingworld.data.saved;
@@ -0,0 +1,61 @@
package com.livingworld.data.serialization;
/**
* Abstraction for reading save data from a persistence source.
*
* <p>This interface hides the underlying storage format (JSON, NBT, etc.) from
* modules. Modules read their data through this abstraction without knowing
* how or where it is stored.</p>
*/
public interface PersistenceReader {
/**
* Reads a string value for the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param defaultValue the default value to return if the key is missing
* @return the string value, or defaultValue if the key is missing
* @throws IllegalArgumentException if key is null or blank
*/
String readString(String key, String defaultValue);
/**
* Reads an integer value for the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param defaultValue the default value to return if the key is missing
* @return the integer value, or defaultValue if the key is missing
* @throws IllegalArgumentException if key is null or blank
*/
int readInt(String key, int defaultValue);
/**
* Reads a long value for the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param defaultValue the default value to return if the key is missing
* @return the long value, or defaultValue if the key is missing
* @throws IllegalArgumentException if key is null or blank
*/
long readLong(String key, long defaultValue);
/**
* Reads a double value for the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param defaultValue the default value to return if the key is missing
* @return the double value, or defaultValue if the key is missing
* @throws IllegalArgumentException if key is null or blank
*/
double readDouble(String key, double defaultValue);
/**
* Reads a boolean value for the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param defaultValue the default value to return if the key is missing
* @return the boolean value, or defaultValue if the key is missing
* @throws IllegalArgumentException if key is null or blank
*/
boolean readBoolean(String key, boolean defaultValue);
}
@@ -0,0 +1,56 @@
package com.livingworld.data.serialization;
/**
* Abstraction for writing save data to a persistence target.
*
* <p>This interface hides the underlying storage format (JSON, NBT, etc.) from
* modules. Modules write their data through this abstraction without knowing
* how or where it is persisted.</p>
*/
public interface PersistenceWriter {
/**
* Writes a string value with the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param value the string to write
* @throws IllegalArgumentException if key is null or blank
*/
void writeString(String key, String value);
/**
* Writes an integer value with the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param value the integer to write
* @throws IllegalArgumentException if key is null or blank
*/
void writeInt(String key, int value);
/**
* Writes a long value with the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param value the long to write
* @throws IllegalArgumentException if key is null or blank
*/
void writeLong(String key, long value);
/**
* Writes a double value with the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param value the double to write
* @throws IllegalArgumentException if key is null or blank
*/
void writeDouble(String key, double value);
/**
* Writes a boolean value with the given key.
*
* @param key the identifier for this value (must not be null or blank)
* @param value the boolean to write
* @throws IllegalArgumentException if key is null or blank
*/
void writeBoolean(String key, boolean value);
}
@@ -0,0 +1,2 @@
/** Data serialization package — data serialization and deserialization utilities. */
package com.livingworld.data.serialization;
@@ -0,0 +1,43 @@
package com.livingworld.debug;
/**
* Diagnostic categories for grouping logs and profiler entries.
* <p>
* Categories map directly to the mod's architectural modules so that logs
* can be filtered by subsystem at runtime.
*/
public enum DiagnosticCategory {
// ── Core systems ──────────────────────────────────────────
BOOTSTRAP("Bootstrap"),
CONFIG("Config"),
REGIONS("Regions"),
SIMULATION("Simulation"),
PERSISTENCE("Persistence"),
EVENTS("Events"),
COMMANDS("Commands"),
NETWORKING("Networking"),
PLATFORM("Platform"),
// ── Ecosystem sub-modules ─────────────────────────────────
ECOSYSTEM("Ecosystem"),
VEGETATION("Vegetation"),
SOIL("Soil"),
WATER("Water"),
RESOURCES("Resources"),
POLLUTION("Pollution"),
RECOVERY("Recovery"),
WORLD_EFFECTS("WorldEffects"),
TESTING("Testing");
private final String displayName;
DiagnosticCategory(String displayName) {
this.displayName = displayName;
}
/** Returns a human-readable name for the category. */
public String displayName() {
return displayName;
}
}
@@ -0,0 +1,74 @@
package com.livingworld.debug;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Central logging wrapper for the Living World mod.
* <p>
* All core systems should log through this class to ensure consistent
* formatting and category grouping. Debug logs respect a future debug
* configuration flag (TODO: wire config later).
*/
public final class LivingWorldLogger {
private static Logger logger = LoggerFactory.getLogger("LivingWorld");
// Prevent instantiation
private LivingWorldLogger() {
}
/** Set the underlying SLF4J logger. */
public static void setLogger(Logger newLogger) {
if (newLogger == null) {
throw new IllegalArgumentException("Logger must not be null");
}
logger = newLogger;
}
// ── Basic logging methods ───────────────────────────────
public static void info(String message) {
logger.info(message);
}
public static void warn(String message) {
logger.warn(message);
}
public static void error(String message) {
logger.error(message);
}
public static void error(String message, Throwable throwable) {
logger.error(message, throwable);
}
public static void debug(String message) {
// TODO: Respect debug config flag when ConfigService is available.
logger.debug(message);
}
// ── Category-aware logging methods ──────────────────────
public static void info(DiagnosticCategory category, String message) {
logger.info("[{}] {}", category.displayName(), message);
}
public static void warn(DiagnosticCategory category, String message) {
logger.warn("[{}] {}", category.displayName(), message);
}
public static void error(DiagnosticCategory category, String message) {
logger.error("[{}] {}", category.displayName(), message);
}
public static void error(DiagnosticCategory category, String message, Throwable throwable) {
logger.error("[{}] {}", category.displayName(), message, throwable);
}
public static void debug(DiagnosticCategory category, String message) {
// TODO: Respect debug config flag when ConfigService is available.
logger.debug("[{}] {}", category.displayName(), message);
}
}
@@ -0,0 +1,2 @@
/** Debug package — debugging utilities and diagnostic tools. */
package com.livingworld.debug;
@@ -0,0 +1,80 @@
package com.livingworld.events;
/**
* Immutable base implementation of {@link LivingWorldEvent}.
*
* <p>This class provides a simple, validated event object that ecosystem modules
* and other simulation components can instantiate directly. All fields are final
* and the class is immutable after construction.</p>
*/
public final class BaseLivingWorldEvent implements LivingWorldEvent {
private final String eventType;
private final long simulationTick;
private final String sourceModuleId;
/**
* Creates a new base living world event.
*
* @param eventType the event type identifier (must not be null or blank)
* @param simulationTick the simulation tick at which this event occurred (must not be negative)
* @param sourceModuleId the module ID that generated this event (may be "core" for core events, must not be null or blank)
* @throws IllegalArgumentException if any field violates validation rules
*/
public BaseLivingWorldEvent(String eventType, long simulationTick, String sourceModuleId) {
if (eventType == null || eventType.isBlank()) {
throw new IllegalArgumentException("eventType must not be null or blank");
}
if (simulationTick < 0) {
throw new IllegalArgumentException("simulationTick must not be negative");
}
if (sourceModuleId == null || sourceModuleId.isBlank()) {
throw new IllegalArgumentException("sourceModuleId must not be null or blank");
}
this.eventType = eventType;
this.simulationTick = simulationTick;
this.sourceModuleId = sourceModuleId;
}
/**
* Returns the event type identifier.
*
* @return the event type (never null or blank)
*/
@Override
public String eventType() {
return eventType;
}
/**
* Returns the simulation tick at which this event occurred.
*
* @return the simulation tick (never negative)
*/
@Override
public long simulationTick() {
return simulationTick;
}
/**
* Returns the module ID that generated this event.
*
* @return the source module ID (may be "core" for core-generated events, never null or blank)
*/
@Override
public String sourceModuleId() {
return sourceModuleId;
}
/**
* Returns a string representation of this event.
*
* @return a formatted string with eventType, simulationTick, and sourceModuleId
*/
@Override
public String toString() {
return "BaseLivingWorldEvent{eventType='" + eventType + "', simulationTick=" + simulationTick +
", sourceModuleId='" + sourceModuleId + "'}";
}
}
@@ -0,0 +1,31 @@
package com.livingworld.events;
/**
* Represents a Living World simulation event.
*
* <p>Events are immutable data objects that carry information about changes,
* occurrences, or state transitions within the Living World simulation layer.</p>
*/
public interface LivingWorldEvent {
/**
* Returns the event type identifier.
*
* @return the event type string (must not be blank)
*/
String eventType();
/**
* Returns the simulation tick at which this event occurred.
*
* @return the simulation tick (never negative)
*/
long simulationTick();
/**
* Returns the module ID that generated or published this event.
*
* @return the source module ID (may be "core" for core-generated events, but must not be blank)
*/
String sourceModuleId();
}
@@ -0,0 +1,172 @@
package com.livingworld.events;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger;
/**
* Central event bus for publishing and subscribing to Living World simulation events.
*
* <p>This class routes events by type to registered listeners, provides metrics
* on published count and listener counts per event type, and includes a simple
* recursion guard to prevent event storms within the same tick.</p>
*/
public final class LivingWorldEventBus {
private static final int MAX_EVENTS_PER_DISPATCH = 10_000;
private final Map<String, List<LivingWorldEventListener>> listeners;
private final Deque<LivingWorldEvent> pendingEvents;
private int publishedCount;
private boolean dispatching;
/**
* Creates a new empty event bus.
*/
public LivingWorldEventBus() {
this.listeners = new java.util.LinkedHashMap<>();
this.pendingEvents = new ArrayDeque<>();
}
// ------------------------------------------------------------------
// Registration
// ------------------------------------------------------------------
/**
* Registers a listener for events of the given type.
*
* <p>If listeners are already registered for the event type, this adds the
* new listener to the end of the list. Listeners are invoked in registration
* order.</p>
*
* @param eventType the event type to listen for (must not be blank)
* @param listener the listener to register (must not be null)
* @throws IllegalArgumentException if eventType is null or blank, or listener is null
*/
public void register(String eventType, LivingWorldEventListener listener) {
if (eventType == null || eventType.isBlank()) {
throw new IllegalArgumentException("eventType must not be null or blank");
}
if (listener == null) {
throw new IllegalArgumentException("listener must not be null");
}
listeners.computeIfAbsent(eventType, k -> new ArrayList<>()).add(listener);
}
// ------------------------------------------------------------------
// Publishing
// ------------------------------------------------------------------
/**
* Publishes an event to all registered listeners for its type.
*
* <p>The event is dispatched in a loop to each listener. If the number of
* listeners grows very large, consider using {@code CopyOnWriteArrayList}
* instead of {@code ArrayList} for the listeners map (currently TODO).</p>
*
* @param event the event to publish (must not be null)
* @throws IllegalArgumentException if event is null
*/
public void publish(LivingWorldEvent event) {
if (event == null) {
throw new IllegalArgumentException("event must not be null");
}
pendingEvents.addLast(event);
if (dispatching) {
return;
}
dispatching = true;
int dispatched = 0;
try {
while (!pendingEvents.isEmpty()) {
if (dispatched >= MAX_EVENTS_PER_DISPATCH) {
int dropped = pendingEvents.size();
pendingEvents.clear();
LivingWorldLogger.warn(DiagnosticCategory.EVENTS,
"Stopped recursive event dispatch after " + MAX_EVENTS_PER_DISPATCH
+ " events; dropped " + dropped + " queued events");
break;
}
LivingWorldEvent nextEvent = pendingEvents.removeFirst();
publishedCount++;
dispatched++;
List<LivingWorldEventListener> listenersForType = listeners.get(nextEvent.eventType());
if (listenersForType == null) {
continue;
}
for (LivingWorldEventListener listener : List.copyOf(listenersForType)) {
try {
listener.onEvent(nextEvent);
} catch (Exception e) {
LivingWorldLogger.error(DiagnosticCategory.EVENTS,
"Event listener failed for type " + nextEvent.eventType(), e);
}
}
}
} finally {
dispatching = false;
}
}
// ------------------------------------------------------------------
// Metrics / Inspection
// ------------------------------------------------------------------
/**
* Returns the total number of events published so far.
*
* @return the published event count (never negative)
*/
public int getPublishedEventCount() {
return publishedCount;
}
/**
* Returns the number of listeners registered for the given event type.
*
* @param eventType the event type to query (must not be null or blank)
* @return the listener count, or 0 if no listeners are registered for this type
* @throws IllegalArgumentException if eventType is null or blank
*/
public int getListenerCount(String eventType) {
if (eventType == null || eventType.isBlank()) {
throw new IllegalArgumentException("eventType must not be null or blank");
}
List<LivingWorldEventListener> typeListeners = this.listeners.get(eventType);
return typeListeners != null ? typeListeners.size() : 0;
}
/**
* Returns a snapshot of all registered event types and their listener counts.
*
* @return an unmodifiable map from eventType to listener count (never null)
*/
public Map<String, Integer> getAllListenerCounts() {
return listeners.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().size()
));
}
/**
* Retained for source compatibility. Dispatch state is now cleared
* automatically after each publication batch.
*/
public void clearSuppressionForCurrentTick() {
// No-op.
}
}
@@ -0,0 +1,22 @@
package com.livingworld.events;
/**
* Functional interface for receiving Living World simulation events.
*
* <p>This interface is implemented by components that need to react to events
* generated during the simulation tick cycle. Ecosystem modules and other
* simulation components can implement this to subscribe to specific event types.</p>
*/
@FunctionalInterface
public interface LivingWorldEventListener {
/**
* Callback invoked when a Living World event occurs.
*
* <p>The default implementation does nothing, allowing {@code @Override}
* implementations to handle only relevant events while ignoring others.</p>
*
* @param event the event that occurred (never null)
*/
void onEvent(LivingWorldEvent event);
}
@@ -0,0 +1,2 @@
/** Events package — custom mod event definitions. */
package com.livingworld.events;
@@ -0,0 +1,2 @@
/** Integration Minecraft package — Minecraft integration layer. */
package com.livingworld.integration.minecraft;
@@ -0,0 +1,69 @@
package com.livingworld.modules;
/**
* Minimal placeholder for a historical record entry.
*
* <p>TODO: This class is temporary and will be moved to an appropriate package
* (e.g. {@code com.livingworld.core} or {@code com.livingworld.data}) once the
* data layer design is finalised.</p>
*/
public final class HistoryRecord {
private final String moduleId;
private final long tick;
private final String recordType;
private final String data;
/**
* Creates a new HistoryRecord.
*
* @param moduleId the module that generated this record (must not be null or blank)
* @param tick the simulation tick at which this record was created
* @param recordType the type/category of this record (may be null, defaults to "generic")
* @param data free-form data string for this record (may be null, defaults to empty)
*/
public HistoryRecord(String moduleId, long tick, String recordType, String data) {
if (moduleId == null || moduleId.isBlank()) {
throw new IllegalArgumentException("moduleId must not be null or blank");
}
this.moduleId = moduleId;
this.tick = tick;
this.recordType = recordType != null ? recordType : "generic";
this.data = data != null ? data : "";
}
/**
* Returns the module ID that generated this record.
*/
public String moduleId() {
return moduleId;
}
/**
* Returns the simulation tick at which this record was created.
*/
public long tick() {
return tick;
}
/**
* Returns the type/category of this record.
*/
public String recordType() {
return recordType;
}
/**
* Returns the free-form data associated with this record.
*/
public String data() {
return data;
}
@Override
public String toString() {
return "HistoryRecord{moduleId=" + moduleId + ", tick=" + tick +
", recordType='" + recordType + "', data='" + data + "'}";
}
}
@@ -0,0 +1,51 @@
package com.livingworld.modules;
import com.livingworld.core.services.ServiceKey;
import com.livingworld.core.services.ServiceRegistry;
/**
* Dependency container passed to modules during initialisation.
*
* <p>This class provides controlled access to registered services through the
* {@link ServiceRegistry}. Modules should only request the services they are
* allowed to use via this context.</p>
*/
public final class ModuleContext {
private final ServiceRegistry services;
/**
* Creates a new ModuleContext with the given service registry.
*
* @param services the service registry (must not be null)
* @throws IllegalArgumentException if services is null
*/
public ModuleContext(ServiceRegistry services) {
if (services == null) {
throw new IllegalArgumentException("services must not be null");
}
this.services = services;
}
/**
* Returns the service associated with the given key.
*
* @param <T> the service type
* @param key the service key (must not be null)
* @return the registered service
* @throws IllegalArgumentException if key is null or services are missing
*/
public <T> T getService(ServiceKey<T> key) {
return services.get(key);
}
/**
* Returns whether a service with the given key has been registered.
*
* @param key the service key (must not be null)
* @return true if the service is registered
*/
public boolean hasService(ServiceKey<?> key) {
return services.isRegistered(key);
}
}
@@ -0,0 +1,137 @@
package com.livingworld.modules;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
/**
* Immutable metadata describing a simulation module.
*
* <p>This class validates that essential fields are present and normalises
* null dependency lists into empty lists for safe downstream usage.</p>
*/
public final class ModuleMetadata {
private final String moduleId;
private final String displayName;
private final String version;
private final String description;
private final String requiredCoreVersion;
private final List<String> dependencies;
private final List<String> optionalDependencies;
private final boolean defaultEnabled;
private final boolean serverOnly;
private final boolean experimental;
/**
* Creates new ModuleMetadata with the given values.
*
* @param moduleId unique module identifier (must not be null or blank)
* @param displayName human-readable name (must not be null or blank)
* @param version semantic version string (may be null, defaults to "0.0.0")
* @param description short description of the module (may be null, defaults to empty)
* @param requiredCoreVersion minimum core schema version required (may be null, defaults to "1")
* @param dependencies list of required module IDs (null becomes empty list)
* @param optionalDependencies list of optional module IDs (null becomes empty list)
* @param defaultEnabled whether the module is enabled by default
* @param serverOnly whether this module only runs on dedicated servers
* @param experimental whether this module is experimental and may change behaviour
*/
public ModuleMetadata(String moduleId, String displayName, String version,
String description, String requiredCoreVersion,
List<String> dependencies, List<String> optionalDependencies,
boolean defaultEnabled, boolean serverOnly, boolean experimental) {
if (moduleId == null || moduleId.isBlank()) {
throw new IllegalArgumentException("moduleId must not be null or blank");
}
if (displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException("displayName must not be null or blank");
}
this.moduleId = moduleId;
this.displayName = displayName;
this.version = version != null ? version : "0.0.0";
this.description = description != null ? description : "";
this.requiredCoreVersion = requiredCoreVersion != null ? requiredCoreVersion : "1";
this.dependencies = dependencies != null
? Collections.unmodifiableList(new ArrayList<>(dependencies))
: Collections.emptyList();
this.optionalDependencies = optionalDependencies != null
? Collections.unmodifiableList(new ArrayList<>(optionalDependencies))
: Collections.emptyList();
this.defaultEnabled = defaultEnabled;
this.serverOnly = serverOnly;
this.experimental = experimental;
}
/**
* Returns the unique module identifier.
*/
public String moduleId() {
return moduleId;
}
/**
* Returns the human-readable display name.
*/
public String displayName() {
return displayName;
}
/**
* Returns the semantic version string.
*/
public String version() {
return version;
}
/**
* Returns the description of this module.
*/
public String description() {
return description;
}
/**
* Returns the minimum core schema version required by this module.
*/
public String requiredCoreVersion() {
return requiredCoreVersion;
}
/**
* Returns an unmodifiable list of required dependency module IDs.
*/
public List<String> dependencies() {
return dependencies;
}
/**
* Returns an unmodifiable list of optional dependency module IDs.
*/
public List<String> optionalDependencies() {
return optionalDependencies;
}
/**
* Returns whether this module is enabled by default.
*/
public boolean defaultEnabled() {
return defaultEnabled;
}
/**
* Returns whether this module only runs on dedicated servers.
*/
public boolean serverOnly() {
return serverOnly;
}
/**
* Returns whether this module is experimental and may change behaviour.
*/
public boolean experimental() {
return experimental;
}
}
@@ -0,0 +1,160 @@
package com.livingworld.modules;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Central registry for simulation modules.
*
* <p>This class manages module registration, lookup, lifecycle coordination,
* and filtering. It is intentionally simple for v1 — dependency sorting
* and advanced lifecycle management are deferred to later milestones.</p>
*
* <p><b>Thread safety:</b> This class is not thread-safe. All operations must
* be performed on the server main thread.</p>
*/
public final class ModuleRegistry {
private final Map<String, SimulationModule> modules;
/**
* Creates a new empty module registry.
*/
public ModuleRegistry() {
this.modules = new LinkedHashMap<>();
}
// ------------------------------------------------------------------
// Registration
// ------------------------------------------------------------------
/**
* Registers a simulation module.
*
* <p>If a module with the same ID is already registered, an
* {@link IllegalArgumentException} is thrown to prevent silent overwrites.</p>
*
* @param module the module to register (must not be null)
* @throws IllegalArgumentException if module is null or a module with the same ID is already registered
*/
public void register(SimulationModule module) {
if (module == null) {
throw new IllegalArgumentException("module must not be null");
}
ModuleMetadata metadata = module.getMetadata();
if (metadata == null) {
throw new IllegalArgumentException("module metadata must not be null");
}
String moduleId = module.getModuleId();
if (moduleId == null || moduleId.isBlank()) {
throw new IllegalArgumentException("module ID must not be null or blank");
}
if (!moduleId.equals(metadata.moduleId())) {
throw new IllegalArgumentException(
"module ID '" + moduleId + "' does not match metadata ID '" + metadata.moduleId() + "'");
}
if (modules.containsKey(moduleId)) {
throw new IllegalArgumentException(
"A module with ID '" + moduleId + "' is already registered");
}
modules.put(moduleId, module);
}
// ------------------------------------------------------------------
// Lookup
// ------------------------------------------------------------------
/**
* Returns all registered modules in an unmodifiable list.
*
* @return an unmodifiable list of all registered modules (never null)
*/
public List<SimulationModule> getAllModules() {
return Collections.unmodifiableList(new ArrayList<>(modules.values()));
}
/**
* Returns enabled modules — those where {@code metadata.defaultEnabled()} is true.
*
* @return an unmodifiable list of enabled modules (never null)
*/
public List<SimulationModule> getEnabledModules() {
List<SimulationModule> enabled = new ArrayList<>();
for (SimulationModule module : modules.values()) {
if (module.getMetadata().defaultEnabled()) {
enabled.add(module);
}
}
return Collections.unmodifiableList(enabled);
}
/**
* Finds a module by its unique identifier.
*
* @param moduleId the module ID to look up (must not be null or blank)
* @return an optional containing the module if found, otherwise empty
* @throws IllegalArgumentException if moduleId is null or blank
*/
public Optional<SimulationModule> find(String moduleId) {
if (moduleId == null || moduleId.isBlank()) {
throw new IllegalArgumentException("moduleId must not be null or blank");
}
return Optional.ofNullable(modules.get(moduleId));
}
// ------------------------------------------------------------------
// Lifecycle coordination
// ------------------------------------------------------------------
/**
* Initialises all registered modules.
*
* <p>Each module is initialised in registration order. If one module's
* {@code initialize()} call throws an exception, the remaining modules
* are still attempted so that partial initialisation diagnostics can be
* collected.</p>
*
* <p><b>TODO (Milestone 9):</b> Implement dependency-aware topological sort
* so that modules are initialised in the correct order based on their
* {@code ModuleMetadata.dependencies()} list.</p>
*
* @param context the module context providing service access (must not be null)
*/
public void initializeAll(ModuleContext context) {
if (context == null) {
throw new IllegalArgumentException("context must not be null");
}
for (SimulationModule module : modules.values()) {
module.initialize(context);
}
}
/**
* Shuts down all registered modules.
*
* <p>Each module's {@code shutdown()} method is called in reverse registration
* order. Exceptions from one module do not prevent other modules from
* shutting down.</p>
*/
public void shutdownAll() {
// Iterate in reverse to shut down in opposite order of initialisation
List<SimulationModule> reversed = new ArrayList<>(modules.values());
Collections.reverse(reversed);
for (SimulationModule module : reversed) {
try {
module.shutdown();
} catch (Exception e) {
com.livingworld.debug.LivingWorldLogger.error(
com.livingworld.debug.DiagnosticCategory.SIMULATION,
"Module shutdown failed: " + module.getModuleId(), e);
}
}
}
}
@@ -0,0 +1,95 @@
package com.livingworld.modules;
import com.livingworld.events.LivingWorldEvent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Immutable result returned by a module after updating a region.
*
* <p>This class prevents modules from mutating global systems directly during
* update. Instead, they return results which SimulationManager applies in a
* controlled way.</p>
*/
public final class ModuleUpdateResult {
private final boolean changedRegion;
private final List<LivingWorldEvent> generatedEvents;
private final List<HistoryRecord> historyRecords;
private final int estimatedCost;
private final List<String> warnings;
/**
* Creates a new ModuleUpdateResult with the given values.
*
* @param changedRegion whether this update modified the region
* @param generatedEvents list of events generated during the update (null becomes empty list)
* @param historyRecords list of historical records to persist (null becomes empty list)
* @param estimatedCost estimated computational cost (must not be negative)
* @param warnings list of warning messages from this update (null becomes empty list)
*/
public ModuleUpdateResult(boolean changedRegion, List<LivingWorldEvent> generatedEvents,
List<HistoryRecord> historyRecords, int estimatedCost, List<String> warnings) {
if (estimatedCost < 0) {
throw new IllegalArgumentException("estimatedCost must not be negative");
}
this.changedRegion = changedRegion;
this.generatedEvents = generatedEvents != null ? Collections.unmodifiableList(new ArrayList<>(generatedEvents)) : Collections.emptyList();
this.historyRecords = historyRecords != null ? Collections.unmodifiableList(new ArrayList<>(historyRecords)) : Collections.emptyList();
this.estimatedCost = estimatedCost;
this.warnings = warnings != null ? Collections.unmodifiableList(new ArrayList<>(warnings)) : Collections.emptyList();
}
/**
* Returns a result indicating no changes were made to the region.
*/
public static ModuleUpdateResult noChange() {
return new ModuleUpdateResult(false, Collections.emptyList(), Collections.emptyList(), 0, Collections.emptyList());
}
/**
* Returns a result indicating the region was changed.
*/
public static ModuleUpdateResult changed() {
return new ModuleUpdateResult(true, Collections.emptyList(), Collections.emptyList(), 0, Collections.emptyList());
}
/**
* Returns whether this update modified the region.
*/
public boolean changedRegion() {
return changedRegion;
}
/**
* Returns an unmodifiable list of events generated during the update.
*/
public List<LivingWorldEvent> generatedEvents() {
return generatedEvents;
}
/**
* Returns an unmodifiable list of historical records to persist.
*/
public List<HistoryRecord> historyRecords() {
return historyRecords;
}
/**
* Returns the estimated computational cost of this update.
*/
public int estimatedCost() {
return estimatedCost;
}
/**
* Returns an unmodifiable list of warning messages from this update.
*/
public List<String> warnings() {
return warnings;
}
}
@@ -0,0 +1,37 @@
package com.livingworld.modules;
import com.livingworld.regions.Region;
/**
* Minimal context passed to modules during a region update call.
*
* <p>This is a placeholder class for Milestone 8. It provides access to the
* region being updated and can be extended later with additional information
* such as time service, profiler data, or event bus references.</p>
*/
public final class RegionUpdateContext {
private final Region region;
/**
* Creates a new RegionUpdateContext for the given region.
*
* @param region the region being updated (must not be null)
* @throws IllegalArgumentException if region is null
*/
public RegionUpdateContext(Region region) {
if (region == null) {
throw new IllegalArgumentException("region must not be null");
}
this.region = region;
}
/**
* Returns the region being updated.
*
* @return the region (never null)
*/
public Region getRegion() {
return region;
}
}
@@ -0,0 +1,18 @@
package com.livingworld.modules;
/**
* Minimal context passed to modules when the server starts.
*
* <p>This is a placeholder class for Milestone 8. It provides a simple container
* that can be extended later with additional server-side information such as
* world access, player lists, or configuration state.</p>
*/
public final class ServerContext {
/**
* Creates a new ServerContext instance.
*/
public ServerContext() {
// minimal placeholder for Milestone 8; extend as needed
}
}
@@ -0,0 +1,141 @@
package com.livingworld.modules;
import com.livingworld.events.LivingWorldEvent;
import com.livingworld.regions.Region;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
/**
* Interface that all simulation modules must implement.
*
* <p>A SimulationModule is a self-contained unit of simulation logic that operates
* on {@link Region} instances. Modules communicate through services, public data
* contracts, or events -- never by directly mutating other systems.</p>
*
* <p>Modules receive a {@link ModuleContext} during initialisation which provides
* access to registered core services. They must not create or depend on other
* modules directly.</p>
*/
public interface SimulationModule {
// ------------------------------------------------------------------
// Identity
// ------------------------------------------------------------------
/**
* Returns the unique module identifier.
*
* @return the module ID (must match {@code ModuleMetadata.moduleId()})
*/
String getModuleId();
/**
* Returns the metadata describing this module.
*
* @return the module metadata (never null)
*/
ModuleMetadata getMetadata();
// ------------------------------------------------------------------
// Lifecycle methods
// ------------------------------------------------------------------
/**
* Initialises this module with access to core services.
*
* <p>This method is called once during module registration. Modules should
* validate their configuration and prepare any internal state here.</p>
*
* @param context the module context providing service access (never null)
* @throws IllegalArgumentException if context is null
*/
void initialize(ModuleContext context);
/**
* Called when the server has fully started.
*
* <p>This method allows modules to perform any server-startup tasks such as
* loading saved data or registering hooks.</p>
*
* @param context the server context (never null)
*/
void onServerStarted(ServerContext context);
// ------------------------------------------------------------------
// Region operations
// ------------------------------------------------------------------
/**
* Creates default module-specific data for a new region.
*
* <p>This method is called when a new region is created via {@link com.livingworld.regions.RegionFactory}.
* The module should populate the region's {@code moduleData} with deterministic
* defaults based on world seed and region coordinate where randomness is needed.</p>
*
* @param region the newly created region (never null)
*/
void createDefaultRegionData(Region region);
/**
* Updates this module's logic for a specific region.
*
* <p>This method is called during each simulation cycle for regions that
* are selected for update. Modules should return a {@link ModuleUpdateResult}
* summarising any changes, events, or warnings rather than mutating global
* systems directly.</p>
*
* @param context the region update context (never null)
* @return the module update result (never null)
*/
ModuleUpdateResult updateRegion(RegionUpdateContext context);
// ------------------------------------------------------------------
// Event handling
// ------------------------------------------------------------------
/**
* Handles a Living World simulation event.
*
* <p>Modules should react to relevant events and return results through
* {@link #updateRegion} rather than mutating systems directly.</p>
*
* @param event the event (never null)
*/
void onLivingWorldEvent(LivingWorldEvent event);
// ------------------------------------------------------------------
// Persistence
// ------------------------------------------------------------------
/**
* Saves this module's data to a persistence writer.
*
* <p>Modules must never write files directly. All serialisation goes through
* the provided {@link PersistenceWriter} abstraction.</p>
*
* @param writer the persistence writer (never null)
*/
void saveModuleData(PersistenceWriter writer);
/**
* Loads this module's data from a persistence reader.
*
* <p>This method is called during region loading to restore saved state.</p>
*
* @param reader the persistence reader (never null)
*/
void loadModuleData(PersistenceReader reader);
// ------------------------------------------------------------------
// Shutdown
// ------------------------------------------------------------------
/**
* Shuts down this module.
*
* <p>This method is called when the mod is being disabled or the server is
* stopping. Modules should release any resources, close connections, and
* perform cleanup here.</p>
*/
void shutdown();
}
@@ -0,0 +1,2 @@
/** Modules ecosystem package — ecosystem simulation module. */
package com.livingworld.modules.ecosystem;
@@ -0,0 +1,2 @@
/** Modules package — modular system components base. */
package com.livingworld.modules;
@@ -0,0 +1,2 @@
/** Modules pollution package — pollution simulation module. */
package com.livingworld.modules.pollution;
@@ -0,0 +1,2 @@
/** Modules recovery package — natural recovery simulation module. */
package com.livingworld.modules.recovery;
@@ -0,0 +1,2 @@
/** Modules resources package — resource simulation module. */
package com.livingworld.modules.resources;
@@ -0,0 +1,2 @@
/** Modules soil package — soil simulation module. */
package com.livingworld.modules.soil;
@@ -0,0 +1,2 @@
/** Modules vegetation package — vegetation simulation module. */
package com.livingworld.modules.vegetation;
@@ -0,0 +1,2 @@
/** Modules water package — water simulation module. */
package com.livingworld.modules.water;
@@ -0,0 +1,2 @@
/** Modules world effects package — world effects simulation module. */
package com.livingworld.modules.worldeffects;
@@ -0,0 +1,2 @@
/** Networking package — network protocol and message handling. */
package com.livingworld.networking;
@@ -0,0 +1,2 @@
/** Platform NeoForge package — NeoForge-specific platform implementations. */
package com.livingworld.platform.neoforge;
@@ -0,0 +1,2 @@
/** Platform package — platform-agnostic abstractions and interfaces. */
package com.livingworld.platform;
@@ -0,0 +1,241 @@
package com.livingworld.regions;
import java.util.UUID;
/**
* Represents a region within the game world.
*
* <p>A Region encapsulates identity, spatial position, lifecycle state,
* simulation metadata, flags, metrics, and module-specific data.</p>
*/
public class Region {
private UUID id;
private RegionCoordinate coordinate;
private RegionLifecycleState lifecycleState;
private long createdAtSimulationTick;
private long lastUpdatedSimulationTick;
private boolean dirty;
private RegionFlags flags;
private RegionMetrics metrics;
private RegionModuleData moduleData;
/**
* Creates a new Region with the specified fields.
*
* @param id the unique identifier (must not be null)
* @param coordinate the spatial position (must not be null)
* @param lifecycleState the current lifecycle state (must not be null)
* @param createdAtSimulationTick the simulation tick when this region was created
* @param lastUpdatedSimulationTick the simulation tick of the last update
* @param dirty whether the region has unsaved changes
* @param flags the region flag state (must not be null)
* @param metrics the region metric data (must not be null)
* @param moduleData the module-specific data store (must not be null)
* @throws IllegalArgumentException if any required field is null
*/
public Region(UUID id, RegionCoordinate coordinate, RegionLifecycleState lifecycleState,
long createdAtSimulationTick, long lastUpdatedSimulationTick, boolean dirty,
RegionFlags flags, RegionMetrics metrics, RegionModuleData moduleData) {
if (id == null) {
throw new IllegalArgumentException("id must not be null");
}
if (coordinate == null) {
throw new IllegalArgumentException("coordinate must not be null");
}
if (lifecycleState == null) {
throw new IllegalArgumentException("lifecycleState must not be null");
}
if (flags == null) {
throw new IllegalArgumentException("flags must not be null");
}
if (metrics == null) {
throw new IllegalArgumentException("metrics must not be null");
}
if (moduleData == null) {
throw new IllegalArgumentException("moduleData must not be null");
}
this.id = id;
this.coordinate = coordinate;
this.lifecycleState = lifecycleState;
this.createdAtSimulationTick = createdAtSimulationTick;
this.lastUpdatedSimulationTick = lastUpdatedSimulationTick;
this.dirty = dirty;
this.flags = flags;
this.metrics = metrics;
this.moduleData = moduleData;
}
// ------------------------------------------------------------------
// Dirty tracking
// ------------------------------------------------------------------
/**
* Marks this region as dirty, indicating unsaved changes.
*/
public void markDirty() {
this.dirty = true;
}
/**
* Clears the dirty flag, indicating changes have been saved.
*/
public void clearDirty() {
this.dirty = false;
}
/**
* Returns whether this region has unsaved changes.
*
* @return true if the region is dirty
*/
public boolean isDirty() {
return this.dirty;
}
// ------------------------------------------------------------------
// Simulation tick management
// ------------------------------------------------------------------
/**
* Updates the last simulated tick to the given value.
*
* @param tick the current simulation tick
*/
public void updateLastSimulatedTick(long tick) {
this.lastUpdatedSimulationTick = tick;
}
// ------------------------------------------------------------------
// Lifecycle state management
// ------------------------------------------------------------------
/**
* Sets the lifecycle state of this region.
*
* @param state the new lifecycle state (must not be null)
* @throws IllegalArgumentException if state is null
*/
public void setLifecycleState(RegionLifecycleState state) {
if (state == null) {
throw new IllegalArgumentException("lifecycleState must not be null");
}
this.lifecycleState = state;
}
// ------------------------------------------------------------------
// Validation
// ------------------------------------------------------------------
/**
* Validates that all required fields are non-null.
*
* @throws IllegalStateException if any required field is null
*/
public void validate() {
if (this.id == null) {
throw new IllegalStateException("id must not be null");
}
if (this.coordinate == null) {
throw new IllegalStateException("coordinate must not be null");
}
if (this.lifecycleState == null) {
throw new IllegalStateException("lifecycleState must not be null");
}
if (this.flags == null) {
throw new IllegalStateException("flags must not be null");
}
if (this.metrics == null) {
throw new IllegalStateException("metrics must not be null");
}
if (this.moduleData == null) {
throw new IllegalStateException("moduleData must not be null");
}
}
// ------------------------------------------------------------------
// Getters and setters
// ------------------------------------------------------------------
public UUID getId() {
return id;
}
public void setId(UUID id) {
if (id == null) {
throw new IllegalArgumentException("id must not be null");
}
this.id = id;
}
public RegionCoordinate getCoordinate() {
return coordinate;
}
public void setCoordinate(RegionCoordinate coordinate) {
if (coordinate == null) {
throw new IllegalArgumentException("coordinate must not be null");
}
this.coordinate = coordinate;
}
public RegionLifecycleState getLifecycleState() {
return lifecycleState;
}
public long getCreatedAtSimulationTick() {
return createdAtSimulationTick;
}
public void setCreatedAtSimulationTick(long createdAtSimulationTick) {
this.createdAtSimulationTick = createdAtSimulationTick;
}
public long getLastUpdatedSimulationTick() {
return lastUpdatedSimulationTick;
}
public RegionFlags getFlags() {
return flags;
}
public void setFlags(RegionFlags flags) {
if (flags == null) {
throw new IllegalArgumentException("flags must not be null");
}
this.flags = flags;
}
public RegionMetrics getMetrics() {
return metrics;
}
public void setMetrics(RegionMetrics metrics) {
if (metrics == null) {
throw new IllegalArgumentException("metrics must not be null");
}
this.metrics = metrics;
}
public RegionModuleData getModuleData() {
return moduleData;
}
public void setModuleData(RegionModuleData moduleData) {
if (moduleData == null) {
throw new IllegalArgumentException("moduleData must not be null");
}
this.moduleData = moduleData;
}
// ------------------------------------------------------------------
// Standard overrides
// ------------------------------------------------------------------
@Override
public String toString() {
return "Region{id=" + id + ", coordinate=" + coordinate +
", lifecycleState=" + lifecycleState + "}";
}
}
@@ -0,0 +1,182 @@
package com.livingworld.regions;
/**
* Immutable representation of a region coordinate within a dimension.
*
* <p>A RegionCoordinate stores the region index (x, z) rather than raw chunk or block
* coordinates. The region index identifies which 8×8-chunk region file contains a given
* position.</p>
*
* <p>Negative coordinates are handled correctly using {@code Math#floorDiv}, matching
* Minecraft's own coordinate system where block -1 belongs to chunk -1, not chunk 0.</p>
*/
public record RegionCoordinate(String dimensionId, int x, int z) {
private static final int CHUNK_SIZE = 16;
/**
* Creates a region coordinate for the given dimension and region indices.
*
* @param dimensionId the dimension identifier (must not be null or blank)
* @param x the region X index
* @param z the region Z index
*/
public RegionCoordinate {
if (dimensionId == null || dimensionId.isBlank()) {
throw new IllegalArgumentException("dimensionId must not be null or blank");
}
}
// ------------------------------------------------------------------
// Static factory methods
// ------------------------------------------------------------------
/**
* Creates a RegionCoordinate from chunk coordinates.
*
* <p>The chunk coordinates are divided by {@code regionSizeChunks} using floor division
* to determine the region index.</p>
*
* @param dimensionId the dimension identifier (must not be null or blank)
* @param chunkX the chunk X coordinate
* @param chunkZ the chunk Z coordinate
* @param regionSizeChunks the number of chunks along one axis in a region (must be >= 1)
* @return a RegionCoordinate representing the region that contains the given chunk
*/
public static RegionCoordinate fromChunk(String dimensionId, int chunkX, int chunkZ, int regionSizeChunks) {
validateRegionSize(regionSizeChunks);
int regionX = Math.floorDiv(chunkX, regionSizeChunks);
int regionZ = Math.floorDiv(chunkZ, regionSizeChunks);
return new RegionCoordinate(dimensionId, regionX, regionZ);
}
/**
* Creates a RegionCoordinate from block coordinates.
*
* <p>Block coordinates are first converted to chunk coordinates using floor division
* by {@link #CHUNK_SIZE}, then the resulting chunk coordinates are passed to
* {@link #fromChunk(String, int, int, int)}.</p>
*
* @param dimensionId the dimension identifier (must not be null or blank)
* @param blockX the block X coordinate
* @param blockZ the block Z coordinate
* @param regionSizeChunks the number of chunks along one axis in a region (must be >= 1)
* @return a RegionCoordinate representing the region that contains the given block
*/
public static RegionCoordinate fromBlock(String dimensionId, int blockX, int blockZ, int regionSizeChunks) {
validateRegionSize(regionSizeChunks);
int chunkX = Math.floorDiv(blockX, CHUNK_SIZE);
int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE);
return fromChunk(dimensionId, chunkX, chunkZ, regionSizeChunks);
}
// ------------------------------------------------------------------
// Validation helpers
// ------------------------------------------------------------------
private static void validateRegionSize(int regionSizeChunks) {
if (regionSizeChunks < 1) {
throw new IllegalArgumentException("regionSizeChunks must be >= 1, got: " + regionSizeChunks);
}
}
// ------------------------------------------------------------------
// Chunk boundary methods
// ------------------------------------------------------------------
/**
* Returns the minimum chunk X coordinate covered by this region.
*
* @param regionSizeChunks the number of chunks along one axis in a region (must be >= 1)
*/
public int minChunkX(int regionSizeChunks) {
validateRegionSize(regionSizeChunks);
return x() * regionSizeChunks;
}
/**
* Returns the minimum chunk Z coordinate covered by this region.
*
* @param regionSizeChunks the number of chunks along one axis in a region (must be >= 1)
*/
public int minChunkZ(int regionSizeChunks) {
validateRegionSize(regionSizeChunks);
return z() * regionSizeChunks;
}
/**
* Returns the maximum chunk X coordinate covered by this region.
*
* @param regionSizeChunks the number of chunks along one axis in a region (must be >= 1)
*/
public int maxChunkX(int regionSizeChunks) {
validateRegionSize(regionSizeChunks);
return (x() + 1) * regionSizeChunks - 1;
}
/**
* Returns the maximum chunk Z coordinate covered by this region.
*
* @param regionSizeChunks the number of chunks along one axis in a region (must be >= 1)
*/
public int maxChunkZ(int regionSizeChunks) {
validateRegionSize(regionSizeChunks);
return (z() + 1) * regionSizeChunks - 1;
}
// ------------------------------------------------------------------
// Block center methods
// ------------------------------------------------------------------
/**
* Returns the X block coordinate of the centre of this region.
*
* <p>The centre is calculated as {@code floorDiv(minBlockX + maxBlockX, 2)} so that
* negative coordinates behave correctly.</p>
*
* @param regionSizeChunks the number of chunks along one axis in a region (must be >= 1)
*/
public int centerBlockX(int regionSizeChunks) {
validateRegionSize(regionSizeChunks);
int minBlockX = minChunkX(regionSizeChunks) * CHUNK_SIZE;
int maxBlockX = (maxChunkX(regionSizeChunks) + 1) * CHUNK_SIZE - 1;
return Math.floorDiv(minBlockX + maxBlockX, 2);
}
/**
* Returns the Z block coordinate of the centre of this region.
*
* @param regionSizeChunks the number of chunks along one axis in a region (must be >= 1)
*/
public int centerBlockZ(int regionSizeChunks) {
validateRegionSize(regionSizeChunks);
int minBlockZ = minChunkZ(regionSizeChunks) * CHUNK_SIZE;
int maxBlockZ = (maxChunkZ(regionSizeChunks) + 1) * CHUNK_SIZE - 1;
return Math.floorDiv(minBlockZ + maxBlockZ, 2);
}
// ------------------------------------------------------------------
// Stable identifier
// ------------------------------------------------------------------
/**
* Returns a stable string representation suitable for use as a HashMap key.
*
* <p>The format is {@code "dimensionId:x:z"} which is deterministic and does not
* depend on object identity.</p>
*/
public String stableId() {
return dimensionId() + ":" + x() + ":" + z();
}
// ------------------------------------------------------------------
// Standard overrides
// ------------------------------------------------------------------
// equals() and hashCode() are provided by the record compiler.
// Only toString is overridden for a more readable debug format.
@Override
public String toString() {
return "RegionCoordinate{dimensionId='" + dimensionId() + "', x=" + x() + ", z=" + z() + "}";
}
}
@@ -0,0 +1,66 @@
package com.livingworld.regions;
import java.util.UUID;
/**
* Creates new region objects with default values.
*
* <p>This factory ensures that every newly created region is valid,
* immediately dirty (so it will be saved), and initialised with sensible
* defaults before any ecology modules are connected.</p>
*/
public class RegionFactory {
/**
* Creates a new region with default values.
*
* <p>The returned region has the following properties:
* <ul>
* <li>A randomly generated UUID as its identifier</li>
* <li>The specified coordinate from the argument</li>
* <li>Lifecycle state set to {@link RegionLifecycleState#ACTIVE ACTIVE}</li>
* <li>{@code createdAtSimulationTick} and {@code lastUpdatedSimulationTick} both equal to {@code simulationTick}</li>
* <li>The dirty flag set to {@code true}</li>
* <li>Default {@link RegionFlags} (all flags false)</li>
* <li>Default {@link RegionMetrics} via {@link RegionMetrics#defaults()}</li>
* <li>An empty {@link RegionModuleData} container</li>
* </ul></p>
*
* <p>The region is validated before being returned. If validation fails,
* an {@link IllegalStateException} is thrown.</p>
*
* @param coordinate the spatial position of the new region (must not be null)
* @param simulationTick the current simulation tick
* @return a valid new Region instance
* @throws IllegalArgumentException if coordinate is null
*/
public Region createNewRegion(RegionCoordinate coordinate, long simulationTick) {
if (coordinate == null) {
throw new IllegalArgumentException("coordinate must not be null");
}
UUID id = UUID.randomUUID();
RegionLifecycleState lifecycleState = RegionLifecycleState.ACTIVE;
boolean dirty = true;
RegionFlags flags = new RegionFlags();
RegionMetrics metrics = RegionMetrics.defaults();
RegionModuleData moduleData = new RegionModuleData();
Region region = new Region(
id,
coordinate,
lifecycleState,
simulationTick,
simulationTick,
dirty,
flags,
metrics,
moduleData
);
// Validate before returning; throws IllegalStateException if invalid
region.validate();
return region;
}
}
@@ -0,0 +1,162 @@
package com.livingworld.regions;
/**
* Boolean flags that describe the current state of a region.
*
* <p>This class tracks transient and persistent conditions affecting a region,
* such as player activity, pollution levels, soil quality, active ecosystem events,
* simulation force-loading, and data corruption.</p>
*/
public class RegionFlags {
private boolean hasPlayerActivity;
private boolean hasHighPollution;
private boolean hasLowSoilQuality;
private boolean hasActiveEcosystemEvent;
private boolean forceLoadedBySimulation;
private boolean corrupted;
/**
* Default constructor. All flags are initialized to false.
*/
public RegionFlags() {
// all fields default to false
}
// ------------------------------------------------------------------
// Getters and Setters
// ------------------------------------------------------------------
/**
* Returns true if players have interacted with this region.
*/
public boolean isHasPlayerActivity() {
return hasPlayerActivity;
}
/**
* Sets whether players have interacted with this region.
*/
public void setHasPlayerActivity(boolean hasPlayerActivity) {
this.hasPlayerActivity = hasPlayerActivity;
}
/**
* Returns true if pollution levels in this region are high.
*/
public boolean isHasHighPollution() {
return hasHighPollution;
}
/**
* Sets whether pollution levels are high.
*/
public void setHasHighPollution(boolean hasHighPollution) {
this.hasHighPollution = hasHighPollution;
}
/**
* Returns true if soil quality in this region is low.
*/
public boolean isHasLowSoilQuality() {
return hasLowSoilQuality;
}
/**
* Sets whether soil quality is low.
*/
public void setHasLowSoilQuality(boolean hasLowSoilQuality) {
this.hasLowSoilQuality = hasLowSoilQuality;
}
/**
* Returns true if an active ecosystem event (fire, flood, etc.) is occurring.
*/
public boolean isHasActiveEcosystemEvent() {
return hasActiveEcosystemEvent;
}
/**
* Sets whether an active ecosystem event is occurring.
*/
public void setHasActiveEcosystemEvent(boolean hasActiveEcosystemEvent) {
this.hasActiveEcosystemEvent = hasActiveEcosystemEvent;
}
/**
* Returns true if the region is force-loaded by simulation and cannot be
* unloaded by normal garbage collection.
*/
public boolean isForceLoadedBySimulation() {
return forceLoadedBySimulation;
}
/**
* Sets whether the region is force-loaded by simulation.
*/
public void setForceLoadedBySimulation(boolean forceLoadedBySimulation) {
this.forceLoadedBySimulation = forceLoadedBySimulation;
}
/**
* Returns true if this region has corrupted or inconsistent data.
*/
public boolean isCorrupted() {
return corrupted;
}
/**
* Sets whether this region is corrupted.
*/
public void setCorrupted(boolean corrupted) {
this.corrupted = corrupted;
}
// ------------------------------------------------------------------
// Copy and Clear
// ------------------------------------------------------------------
/**
* Returns a new {@link RegionFlags} instance with identical field values.
*
* <p>The returned instance is independent; modifying it will not affect
* the original flags.</p>
*/
public RegionFlags copy() {
RegionFlags copy = new RegionFlags();
copy.hasPlayerActivity = this.hasPlayerActivity;
copy.hasHighPollution = this.hasHighPollution;
copy.hasLowSoilQuality = this.hasLowSoilQuality;
copy.hasActiveEcosystemEvent = this.hasActiveEcosystemEvent;
copy.forceLoadedBySimulation = this.forceLoadedBySimulation;
copy.corrupted = this.corrupted;
return copy;
}
/**
* Clears transient flags: {@code hasPlayerActivity} and {@code hasActiveEcosystemEvent}.
*
* <p>Persistent flags such as {@code corrupted}, {@code hasHighPollution},
* {@code hasLowSoilQuality}, and {@code forceLoadedBySimulation} are preserved.</p>
*/
public void clearTransientFlags() {
this.hasPlayerActivity = false;
this.hasActiveEcosystemEvent = false;
}
// ------------------------------------------------------------------
// Standard overrides
// ------------------------------------------------------------------
@Override
public String toString() {
return "RegionFlags{"
+ "hasPlayerActivity=" + hasPlayerActivity
+ ", hasHighPollution=" + hasHighPollution
+ ", hasLowSoilQuality=" + hasLowSoilQuality
+ ", hasActiveEcosystemEvent=" + hasActiveEcosystemEvent
+ ", forceLoadedBySimulation=" + forceLoadedBySimulation
+ ", corrupted=" + corrupted
+ "}";
}
}
@@ -0,0 +1,115 @@
package com.livingworld.regions;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* Manages region lifecycle state transitions.
*
* <p>This is a plain Java utility class that provides static methods for validating
* and executing transitions between {@link RegionLifecycleState} values. All allowed
* transitions are hardcoded in an internal lookup table.</p>
*
* <p>Allowed transitions:
* <ul>
* <li>UNLOADED → LOADING</li>
* <li>LOADING → ACTIVE</li>
* <li>LOADING → FAILED</li>
* <li>ACTIVE → DIRTY</li>
* <li>DIRTY → SAVING</li>
* <li>SAVING → ACTIVE</li>
* <li>SAVING → FAILED</li>
* <li>ACTIVE → UNLOADING</li>
* <li>UNLOADING → UNLOADED</li>
* <li>FAILED → LOADING</li>
* </ul></p>
*/
public class RegionLifecycleController {
private static final String SEPARATOR = "->";
/** Unmodifiable set of allowed transition keys in the format "FROM->TO". */
private static final Set<String> ALLOWED_TRANSITIONS;
static {
Set<String> table = new HashSet<>();
table.add("UNLOADED" + SEPARATOR + "LOADING");
table.add("LOADING" + SEPARATOR + "ACTIVE");
table.add("LOADING" + SEPARATOR + "FAILED");
table.add("ACTIVE" + SEPARATOR + "DIRTY");
table.add("DIRTY" + SEPARATOR + "SAVING");
table.add("SAVING" + SEPARATOR + "ACTIVE");
table.add("SAVING" + SEPARATOR + "FAILED");
table.add("ACTIVE" + SEPARATOR + "UNLOADING");
table.add("UNLOADING" + SEPARATOR + "UNLOADED");
table.add("FAILED" + SEPARATOR + "LOADING");
ALLOWED_TRANSITIONS = Collections.unmodifiableSet(table);
}
/**
* Private constructor to prevent instantiation.
*/
private RegionLifecycleController() {
// utility class
}
// ------------------------------------------------------------------
// Transition validation
// ------------------------------------------------------------------
/**
* Returns whether the specified transition from {@code from} to {@code to} is allowed.
*
* @param from the current lifecycle state (must not be null)
* @param to the target lifecycle state (must not be null)
* @return true if this transition is permitted
* @throws IllegalArgumentException if either parameter is null
*/
public static boolean canTransition(RegionLifecycleState from, RegionLifecycleState to) {
if (from == null) {
throw new IllegalArgumentException("from state must not be null");
}
if (to == null) {
throw new IllegalArgumentException("to state must not be null");
}
String key = from.name() + SEPARATOR + to.name();
return ALLOWED_TRANSITIONS.contains(key);
}
// ------------------------------------------------------------------
// Transition execution
// ------------------------------------------------------------------
/**
* Transitions the given region to the target lifecycle state.
*
* <p>Validates that the transition from the region's current state to the
* target is allowed. If valid, updates the region's lifecycle state and marks
* it dirty.</p>
*
* @param region the region to transition (must not be null)
* @param target the target lifecycle state (must not be null)
* @throws IllegalArgumentException if region or target is null
* @throws IllegalStateException if the transition is not allowed
*/
public static void transition(Region region, RegionLifecycleState target) {
if (region == null) {
throw new IllegalArgumentException("region must not be null");
}
if (target == null) {
throw new IllegalArgumentException("target state must not be null");
}
RegionLifecycleState current = region.getLifecycleState();
if (!canTransition(current, target)) {
throw new IllegalStateException(
"Invalid lifecycle transition: " + current.name() + " -> " + target.name());
}
region.setLifecycleState(target);
region.markDirty();
}
}
@@ -0,0 +1,52 @@
package com.livingworld.regions;
/**
* Lifecycle states for a region in the Living World simulation.
*
* <p>This enum tracks the state transitions of a region as it is loaded,
* simulated, saved, and unloaded during gameplay.</p>
*/
public enum RegionLifecycleState {
/**
* The region is not currently loaded in memory.
* No data is held; the region exists only on disk (or has never been created).
*/
UNLOADED,
/**
* The region is being read from disk and initialized.
* Data structures are being constructed but the region is not yet ready for simulation.
*/
LOADING,
/**
* The region is fully loaded and actively processing simulation ticks.
* Entities, blocks, and other systems within this region are updated each cycle.
*/
ACTIVE,
/**
* The region has been modified since it was last saved.
* These changes must be persisted to disk before the region can be safely unloaded.
*/
DIRTY,
/**
* The region's data is being written to disk.
* No modifications should occur while in this state.
*/
SAVING,
/**
* An error occurred during loading, saving, or simulation.
* The region cannot continue normal operations and requires manual intervention or reset.
*/
FAILED,
/**
* The region is being removed from memory after simulation.
* Data is being flushed to disk (if dirty) and internal structures are being released.
*/
UNLOADING;
}
@@ -0,0 +1,387 @@
package com.livingworld.regions;
/**
* Metrics that describe the health and state of an ecosystem within a region.
*
* <p>All values are clamped between 0 and 100 to represent percentages or
* normalized scores. This class provides methods for normalizing, copying,
* and creating default instances.</p>
*/
public class RegionMetrics {
private double ecosystemHealth;
private double pollutionScore;
private double soilQuality;
private double waterQuality;
private double vegetationPressure;
private double resourceDepletion;
private double recoveryPressure;
// ------------------------------------------------------------------
// Constants
// ------------------------------------------------------------------
/** Minimum allowed value for all metrics. */
public static final double MIN_VALUE = 0;
/** Maximum allowed value for all metrics. */
public static final double MAX_VALUE = 100;
// ------------------------------------------------------------------
// Default values
// ------------------------------------------------------------------
/** Default ecosystem health score. */
private static final double DEFAULT_ECOSYSTEM_HEALTH = 60;
/** Default pollution score. */
private static final double DEFAULT_POLLUTION_SCORE = 0;
/** Default soil quality score. */
private static final double DEFAULT_SOIL_QUALITY = 60;
/** Default water quality score. */
private static final double DEFAULT_WATER_QUALITY = 60;
/** Default vegetation pressure score. */
private static final double DEFAULT_VEGETATION_PRESSURE = 50;
/** Default resource depletion score. */
private static final double DEFAULT_RESOURCE_DEPLETION = 0;
/** Default recovery pressure score. */
private static final double DEFAULT_RECOVERY_PRESSURE = 50;
// ------------------------------------------------------------------
// Constructor
// ------------------------------------------------------------------
/**
* Default constructor. All fields are initialized to 0.
*/
public RegionMetrics() {
// all fields default to 0.0
}
// ------------------------------------------------------------------
// Getters and Setters
// ------------------------------------------------------------------
/**
* Returns the ecosystem health score (0-100).
*
* <p>Higher values indicate a healthier, more balanced ecosystem.</p>
*/
public double getEcosystemHealth() {
return ecosystemHealth;
}
/**
* Sets the ecosystem health score.
*
* <p>The value is clamped between 0 and 100.</p>
*/
public void setEcosystemHealth(double ecosystemHealth) {
this.ecosystemHealth = clamp(ecosystemHealth);
}
/**
* Returns the pollution score (0-100).
*
* <p>Higher values indicate more severe pollution.</p>
*/
public double getPollutionScore() {
return pollutionScore;
}
/**
* Sets the pollution score.
*
* <p>The value is clamped between 0 and 100.</p>
*/
public void setPollutionScore(double pollutionScore) {
this.pollutionScore = clamp(pollutionScore);
}
/**
* Returns the soil quality score (0-100).
*
* <p>Higher values indicate better soil health.</p>
*/
public double getSoilQuality() {
return soilQuality;
}
/**
* Sets the soil quality score.
*
* <p>The value is clamped between 0 and 100.</p>
*/
public void setSoilQuality(double soilQuality) {
this.soilQuality = clamp(soilQuality);
}
/**
* Returns the water quality score (0-100).
*
* <p>Higher values indicate cleaner, more abundant water.</p>
*/
public double getWaterQuality() {
return waterQuality;
}
/**
* Sets the water quality score.
*
* <p>The value is clamped between 0 and 100.</p>
*/
public void setWaterQuality(double waterQuality) {
this.waterQuality = clamp(waterQuality);
}
/**
* Returns the vegetation pressure score (0-100).
*
* <p>Higher values indicate higher stress on vegetation resources.</p>
*/
public double getVegetationPressure() {
return vegetationPressure;
}
/**
* Sets the vegetation pressure score.
*
* <p>The value is clamped between 0 and 100.</p>
*/
public void setVegetationPressure(double vegetationPressure) {
this.vegetationPressure = clamp(vegetationPressure);
}
/**
* Returns the resource depletion score (0-100).
*
* <p>Higher values indicate more severe resource exhaustion.</p>
*/
public double getResourceDepletion() {
return resourceDepletion;
}
/**
* Sets the resource depletion score.
*
* <p>The value is clamped between 0 and 100.</p>
*/
public void setResourceDepletion(double resourceDepletion) {
this.resourceDepletion = clamp(resourceDepletion);
}
/**
* Returns the recovery pressure score (0-100).
*
* <p>Higher values indicate greater need for ecosystem recovery efforts.</p>
*/
public double getRecoveryPressure() {
return recoveryPressure;
}
/**
* Sets the recovery pressure score.
*
* <p>The value is clamped between 0 and 100.</p>
*/
public void setRecoveryPressure(double recoveryPressure) {
this.recoveryPressure = clamp(recoveryPressure);
}
// ------------------------------------------------------------------
// Copy and Static Factory Methods
// ------------------------------------------------------------------
/**
* Returns a new {@link RegionMetrics} instance with identical field values.
*
* <p>The returned instance is independent; modifying it will not affect
* the original metrics.</p>
*/
public RegionMetrics copy() {
RegionMetrics copy = new RegionMetrics();
copy.ecosystemHealth = this.ecosystemHealth;
copy.pollutionScore = this.pollutionScore;
copy.soilQuality = this.soilQuality;
copy.waterQuality = this.waterQuality;
copy.vegetationPressure = this.vegetationPressure;
copy.resourceDepletion = this.resourceDepletion;
copy.recoveryPressure = this.recoveryPressure;
return copy;
}
/**
* Returns a new {@link RegionMetrics} instance with default values.
*
* <p>Default values:
* ecosystemHealth=60, pollutionScore=0, soilQuality=60,
* waterQuality=60, vegetationPressure=50, resourceDepletion=0,
* recoveryPressure=50</p>
*/
public static RegionMetrics defaults() {
RegionMetrics metrics = new RegionMetrics();
metrics.ecosystemHealth = DEFAULT_ECOSYSTEM_HEALTH;
metrics.pollutionScore = DEFAULT_POLLUTION_SCORE;
metrics.soilQuality = DEFAULT_SOIL_QUALITY;
metrics.waterQuality = DEFAULT_WATER_QUALITY;
metrics.vegetationPressure = DEFAULT_VEGETATION_PRESSURE;
metrics.resourceDepletion = DEFAULT_RESOURCE_DEPLETION;
metrics.recoveryPressure = DEFAULT_RECOVERY_PRESSURE;
return metrics;
}
// ------------------------------------------------------------------
// Delta Application Methods
// ------------------------------------------------------------------
/**
* Applies deltas to all metric fields and returns this instance for chaining.
*
* <p>Each field is modified by its corresponding delta value, then clamped
* between 0 and 100.</p>
*
* @param ecosystemHealthDelta the change to apply to ecosystem health
* @param pollutionScoreDelta the change to apply to pollution score
* @param soilQualityDelta the change to apply to soil quality
* @param waterQualityDelta the change to apply to water quality
* @param vegetationPressureDelta the change to apply to vegetation pressure
* @param resourceDepletionDelta the change to apply to resource depletion
* @param recoveryPressureDelta the change to apply to recovery pressure
* @return this metrics instance for method chaining
*/
public RegionMetrics applyDelta(
double ecosystemHealthDelta,
double pollutionScoreDelta,
double soilQualityDelta,
double waterQualityDelta,
double vegetationPressureDelta,
double resourceDepletionDelta,
double recoveryPressureDelta) {
this.ecosystemHealth = clamp(this.ecosystemHealth + ecosystemHealthDelta);
this.pollutionScore = clamp(this.pollutionScore + pollutionScoreDelta);
this.soilQuality = clamp(this.soilQuality + soilQualityDelta);
this.waterQuality = clamp(this.waterQuality + waterQualityDelta);
this.vegetationPressure = clamp(this.vegetationPressure + vegetationPressureDelta);
this.resourceDepletion = clamp(this.resourceDepletion + resourceDepletionDelta);
this.recoveryPressure = clamp(this.recoveryPressure + recoveryPressureDelta);
return this;
}
/**
* Applies a delta to the ecosystem health score and returns this instance.
*/
public RegionMetrics applyEcosystemHealthDelta(double delta) {
this.ecosystemHealth = clamp(this.ecosystemHealth + delta);
return this;
}
/**
* Applies a delta to the pollution score and returns this instance.
*/
public RegionMetrics applyPollutionScoreDelta(double delta) {
this.pollutionScore = clamp(this.pollutionScore + delta);
return this;
}
/**
* Applies a delta to the soil quality score and returns this instance.
*/
public RegionMetrics applySoilQualityDelta(double delta) {
this.soilQuality = clamp(this.soilQuality + delta);
return this;
}
/**
* Applies a delta to the water quality score and returns this instance.
*/
public RegionMetrics applyWaterQualityDelta(double delta) {
this.waterQuality = clamp(this.waterQuality + delta);
return this;
}
/**
* Applies a delta to the vegetation pressure score and returns this instance.
*/
public RegionMetrics applyVegetationPressureDelta(double delta) {
this.vegetationPressure = clamp(this.vegetationPressure + delta);
return this;
}
/**
* Applies a delta to the resource depletion score and returns this instance.
*/
public RegionMetrics applyResourceDepletionDelta(double delta) {
this.resourceDepletion = clamp(this.resourceDepletion + delta);
return this;
}
/**
* Applies a delta to the recovery pressure score and returns this instance.
*/
public RegionMetrics applyRecoveryPressureDelta(double delta) {
this.recoveryPressure = clamp(this.recoveryPressure + delta);
return this;
}
// ------------------------------------------------------------------
// Normalization
// ------------------------------------------------------------------
/**
* Clamps all metric values between 0 and 100.
*
* <p>This method modifies the current instance in place and returns it
* for method chaining.</p>
*/
public RegionMetrics normalize() {
this.ecosystemHealth = clamp(this.ecosystemHealth);
this.pollutionScore = clamp(this.pollutionScore);
this.soilQuality = clamp(this.soilQuality);
this.waterQuality = clamp(this.waterQuality);
this.vegetationPressure = clamp(this.vegetationPressure);
this.resourceDepletion = clamp(this.resourceDepletion);
this.recoveryPressure = clamp(this.recoveryPressure);
return this;
}
// ------------------------------------------------------------------
// Utility Methods
// ------------------------------------------------------------------
/**
* Clamps a value between 0 and 100.
*/
private static double clamp(double value) {
if (value < MIN_VALUE) {
return MIN_VALUE;
}
if (value > MAX_VALUE) {
return MAX_VALUE;
}
return value;
}
// ------------------------------------------------------------------
// Standard Overrides
// ------------------------------------------------------------------
@Override
public String toString() {
return "RegionMetrics{"
+ "ecosystemHealth=" + ecosystemHealth
+ ", pollutionScore=" + pollutionScore
+ ", soilQuality=" + soilQuality
+ ", waterQuality=" + waterQuality
+ ", vegetationPressure=" + vegetationPressure
+ ", resourceDepletion=" + resourceDepletion
+ ", recoveryPressure=" + recoveryPressure
+ "}";
}
}
@@ -0,0 +1,105 @@
package com.livingworld.regions;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Stores module-specific data by module ID.
*/
public class RegionModuleData {
private Map<String, Object> moduleData;
/**
* Creates a new empty RegionModuleData instance.
*/
public RegionModuleData() {
this.moduleData = new HashMap<>();
}
/**
* Associates the given data with the specified module ID.
*
* @param moduleId the module identifier (must not be null or blank)
* @param data the data to store (must not be null)
* @throws IllegalArgumentException if moduleId is null or blank
* @throws IllegalArgumentException if data is null
*/
public void put(String moduleId, Object data) {
if (moduleId == null || moduleId.isBlank()) {
throw new IllegalArgumentException("moduleId must not be null or blank");
}
if (data == null) {
throw new IllegalArgumentException("Data for module '" + moduleId + "' must not be null");
}
this.moduleData.put(moduleId, data);
}
/**
* Returns the data associated with the given module ID, cast to the requested type.
*
* <p>If the module ID is not present or the stored data does not match the
* requested type, this method returns {@link Optional#empty()} instead of
* throwing an exception.</p>
*
* @param moduleId the module identifier (must not be null or blank)
* @param type the expected type of the stored data
* @param <T> the target type
* @return the data cast to {@code type}, or {@link Optional#empty()} if not found or type mismatch
*/
@SuppressWarnings("unchecked")
public <T> Optional<T> get(String moduleId, Class<T> type) {
if (moduleId == null || moduleId.isBlank()) {
throw new IllegalArgumentException("moduleId must not be null or blank");
}
Object value = this.moduleData.get(moduleId);
if (value == null) {
return Optional.empty();
}
if (!type.isInstance(value)) {
return Optional.empty();
}
return Optional.of((T) value);
}
/**
* Returns whether this instance contains data for the given module ID.
*
* @param moduleId the module identifier (must not be null or blank)
* @return true if data exists for the given module ID
* @throws IllegalArgumentException if moduleId is null or blank
*/
public boolean contains(String moduleId) {
if (moduleId == null || moduleId.isBlank()) {
throw new IllegalArgumentException("moduleId must not be null or blank");
}
return this.moduleData.containsKey(moduleId);
}
/**
* Returns all module IDs currently stored.
*
* @return an unmodifiable set of module identifiers
*/
public Set<String> moduleIds() {
return Collections.unmodifiableSet(new HashSet<>(this.moduleData.keySet()));
}
/**
* Returns a shallow copy of this instance.
*
* <p>The returned {@link RegionModuleData} has its own map, but the values
* inside are shared references.</p>
*
* @return a new {@code RegionModuleData} with the same entries
*/
public RegionModuleData copyShallow() {
RegionModuleData copy = new RegionModuleData();
copy.moduleData.putAll(this.moduleData);
return copy;
}
}
@@ -0,0 +1,2 @@
/** Regions cache package — region caching utilities. */
package com.livingworld.regions.cache;
@@ -0,0 +1,2 @@
/** Regions package — region system core and management. */
package com.livingworld.regions;
@@ -0,0 +1,2 @@
/** Regions query package — region querying and filtering utilities. */
package com.livingworld.regions.query;
@@ -0,0 +1,2 @@
/** Testing package — testing utilities and test fixtures. */
package com.livingworld.testing;
@@ -0,0 +1,25 @@
modLoader="javafml"
loaderVersion="[4,)"
license="All Rights Reserved"
[[mods]]
modId="livingworld"
version="0.1.0"
displayName="Living World"
description='''
An ecosystem-focused world evolution simulation for Minecraft.
'''
[[dependencies.livingworld]]
modId="neoforge"
type="required"
versionRange="[21.1,)"
ordering="NONE"
side="BOTH"
[[dependencies.livingworld]]
modId="minecraft"
type="required"
versionRange="[1.21.1,1.22)"
ordering="NONE"
side="BOTH"
+6
View File
@@ -0,0 +1,6 @@
{
"pack": {
"description": "Living World resources",
"pack_format": 34
}
}
@@ -0,0 +1,281 @@
package com.livingworld.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link SimulationConfig}.
*/
class SimulationConfigTest {
// ------------------------------------------------------------------
// Default values
// ------------------------------------------------------------------
@Nested
@DisplayName("Default values")
class DefaultValues {
@Test
void defaultRegionSizeChunks() {
final SimulationConfig config = new SimulationConfig();
assertEquals(8, config.getRegionSizeChunks());
}
@Test
void defaultSimulationIntervalTicks() {
final SimulationConfig config = new SimulationConfig();
assertEquals(100, config.getSimulationIntervalTicks());
}
@Test
void defaultMaxRegionsPerCycle() {
final SimulationConfig config = new SimulationConfig();
assertEquals(50, config.getMaxRegionsPerCycle());
}
@Test
void defaultMaxMillisecondsPerCycle() {
final SimulationConfig config = new SimulationConfig();
assertEquals(25, config.getMaxMillisecondsPerCycle());
}
@Test
void defaultEmergencyStopMilliseconds() {
final SimulationConfig config = new SimulationConfig();
assertEquals(40, config.getEmergencyStopMilliseconds());
}
@Test
void defaultEnableDebugCommands() {
final SimulationConfig config = new SimulationConfig();
assertTrue(config.isEnableDebugCommands());
}
@Test
void defaultEnableProfiler() {
final SimulationConfig config = new SimulationConfig();
assertTrue(config.isEnableProfiler());
}
}
// ------------------------------------------------------------------
// Validation valid values should pass
// ------------------------------------------------------------------
@Nested
@DisplayName("Validation with valid values")
class ValidValues {
@Test
void defaultsAreValid() {
final SimulationConfig config = new SimulationConfig();
config.validate(); // Should not throw
}
@Test
void minimumRegionSizeChunks() {
final SimulationConfig config = new SimulationConfig();
config.setRegionSizeChunks(1);
config.validate(); // Should not throw
}
@Test
void minimumSimulationIntervalTicks() {
final SimulationConfig config = new SimulationConfig();
config.setSimulationIntervalTicks(1);
config.validate(); // Should not throw
}
@Test
void minimumMaxRegionsPerCycle() {
final SimulationConfig config = new SimulationConfig();
config.setMaxRegionsPerCycle(1);
config.validate(); // Should not throw
}
@Test
void minimumMaxMillisecondsPerCycle() {
final SimulationConfig config = new SimulationConfig();
config.setMaxMillisecondsPerCycle(1);
config.validate(); // Should not throw
}
@Test
void emergencyStopEqualsMaxMilliseconds() {
final SimulationConfig config = new SimulationConfig();
config.setEmergencyStopMilliseconds(25);
config.setMaxMillisecondsPerCycle(25);
config.validate(); // Should not throw (equal is allowed)
}
@Test
void emergencyStopGreaterThanMaxMilliseconds() {
final SimulationConfig config = new SimulationConfig();
config.setEmergencyStopMilliseconds(50);
config.setMaxMillisecondsPerCycle(25);
config.validate(); // Should not throw
}
}
// ------------------------------------------------------------------
// Validation invalid values should fail
// ------------------------------------------------------------------
@Nested
@DisplayName("Validation with invalid values")
class InvalidValues {
@Test
void zeroRegionSizeChunks() {
final SimulationConfig config = new SimulationConfig();
config.setRegionSizeChunks(0);
assertThrows(IllegalArgumentException.class, () -> config.validate());
}
@Test
void negativeRegionSizeChunks() {
final SimulationConfig config = new SimulationConfig();
config.setRegionSizeChunks(-5);
assertThrows(IllegalArgumentException.class, () -> config.validate());
}
@Test
void zeroSimulationIntervalTicks() {
final SimulationConfig config = new SimulationConfig();
config.setSimulationIntervalTicks(0);
assertThrows(IllegalArgumentException.class, () -> config.validate());
}
@Test
void negativeSimulationIntervalTicks() {
final SimulationConfig config = new SimulationConfig();
config.setSimulationIntervalTicks(-10);
assertThrows(IllegalArgumentException.class, () -> config.validate());
}
@Test
void zeroMaxRegionsPerCycle() {
final SimulationConfig config = new SimulationConfig();
config.setMaxRegionsPerCycle(0);
assertThrows(IllegalArgumentException.class, () -> config.validate());
}
@Test
void negativeMaxRegionsPerCycle() {
final SimulationConfig config = new SimulationConfig();
config.setMaxRegionsPerCycle(-1);
assertThrows(IllegalArgumentException.class, () -> config.validate());
}
@Test
void zeroMaxMillisecondsPerCycle() {
final SimulationConfig config = new SimulationConfig();
config.setMaxMillisecondsPerCycle(0);
assertThrows(IllegalArgumentException.class, () -> config.validate());
}
@Test
void negativeMaxMillisecondsPerCycle() {
final SimulationConfig config = new SimulationConfig();
config.setMaxMillisecondsPerCycle(-10);
assertThrows(IllegalArgumentException.class, () -> config.validate());
}
@Test
void emergencyStopLessThanMaxMilliseconds() {
final SimulationConfig config = new SimulationConfig();
config.setEmergencyStopMilliseconds(20);
config.setMaxMillisecondsPerCycle(25);
assertThrows(IllegalArgumentException.class, () -> config.validate());
}
@Test
void emergencyStopMessageContainsValues() {
final SimulationConfig config = new SimulationConfig();
config.setEmergencyStopMilliseconds(10);
config.setMaxMillisecondsPerCycle(30);
final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> config.validate());
assertTrue(exception.getMessage().contains("emergencyStopMilliseconds"));
assertTrue(exception.getMessage().contains("maxMillisecondsPerCycle"));
}
}
// ------------------------------------------------------------------
// Setters
// ------------------------------------------------------------------
@Nested
@DisplayName("Setters")
class Setters {
@Test
void setRegionSizeChunks() {
final SimulationConfig config = new SimulationConfig();
config.setRegionSizeChunks(16);
assertEquals(16, config.getRegionSizeChunks());
}
@Test
void setSimulationIntervalTicks() {
final SimulationConfig config = new SimulationConfig();
config.setSimulationIntervalTicks(50);
assertEquals(50, config.getSimulationIntervalTicks());
}
@Test
void setMaxRegionsPerCycle() {
final SimulationConfig config = new SimulationConfig();
config.setMaxRegionsPerCycle(100);
assertEquals(100, config.getMaxRegionsPerCycle());
}
@Test
void setMaxMillisecondsPerCycle() {
final SimulationConfig config = new SimulationConfig();
config.setMaxMillisecondsPerCycle(50);
assertEquals(50, config.getMaxMillisecondsPerCycle());
}
@Test
void setEmergencyStopMilliseconds() {
final SimulationConfig config = new SimulationConfig();
config.setEmergencyStopMilliseconds(60);
assertEquals(60, config.getEmergencyStopMilliseconds());
}
@Test
void setEnableDebugCommandsFalse() {
final SimulationConfig config = new SimulationConfig();
config.setEnableDebugCommands(false);
assertFalse(config.isEnableDebugCommands());
}
@Test
void setEnableProfilerFalse() {
final SimulationConfig config = new SimulationConfig();
config.setEnableProfiler(false);
assertFalse(config.isEnableProfiler());
}
}
// ------------------------------------------------------------------
// toString
// ------------------------------------------------------------------
@Test
@DisplayName("toString should not throw")
void toStringShouldNotThrow() {
final SimulationConfig config = new SimulationConfig();
final String result = config.toString();
assertTrue(result.contains("SimulationConfig"));
assertTrue(result.contains("regionSizeChunks=8"));
assertTrue(result.contains("simulationIntervalTicks=100"));
}
}
@@ -0,0 +1,221 @@
package com.livingworld.core.services;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link ServiceRegistry}.
*/
class ServiceRegistryTest {
// ------------------------------------------------------------------
// Registration and retrieval
// ------------------------------------------------------------------
@Nested
@DisplayName("Registration and retrieval")
class RegistrationAndRetrieval {
@Test
void registerAndGet() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("test-service", String.class);
registry.register(key, "hello");
String result = registry.get(key);
assertEquals("hello", result);
}
@Test
void findReturnsPresentService() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("test-service-2", Integer.class);
registry.register(key, 42);
var found = registry.find(key);
assertTrue(found.isPresent());
assertEquals(42, found.get());
}
@Test
void findReturnsEmptyForUnregistered() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("unregistered", Boolean.class);
var found = registry.find(key);
assertFalse(found.isPresent());
}
@Test
void isRegisteredReturnsTrueAfterRegistration() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("test-service-3", Object.class);
registry.register(key, new Object());
assertTrue(registry.isRegistered(key));
}
@Test
void isRegisteredReturnsFalseForUnregistered() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("unregistered-2", Number.class);
assertFalse(registry.isRegistered(key));
}
}
// ------------------------------------------------------------------
// Null argument validation
// ------------------------------------------------------------------
@Nested
@DisplayName("Null argument validation")
class NullArgumentValidation {
@Test
void registerWithNullKeyThrows() {
var registry = new ServiceRegistry();
assertThrows(IllegalArgumentException.class, () -> registry.register(null, "service"));
}
@Test
void registerWithNullServiceThrows() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("null-service", String.class);
assertThrows(IllegalArgumentException.class, () -> registry.register(key, null));
}
@Test
void getWithNullKeyThrows() {
var registry = new ServiceRegistry();
assertThrows(IllegalArgumentException.class, () -> registry.get(null));
}
@Test
void findWithNullKeyThrows() {
var registry = new ServiceRegistry();
assertThrows(IllegalArgumentException.class, () -> registry.find(null));
}
@Test
void isRegisteredWithNullKeyThrows() {
var registry = new ServiceRegistry();
assertThrows(IllegalArgumentException.class, () -> registry.isRegistered(null));
}
}
// ------------------------------------------------------------------
// Duplicate registration rejection
// ------------------------------------------------------------------
@Nested
@DisplayName("Duplicate registration rejection")
class DuplicateRegistrationRejection {
@Test
void duplicateKeyThrows() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("duplicate", String.class);
registry.register(key, "first");
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> registry.register(key, "second"));
assertTrue(exception.getMessage().contains("already registered"));
}
@Test
void differentKeysDoNotConflict() {
var registry = new ServiceRegistry();
var key1 = new ServiceKey<>("key-1", String.class);
var key2 = new ServiceKey<>("key-2", Integer.class);
registry.register(key1, "hello");
registry.register(key2, 42);
assertEquals("hello", registry.get(key1));
assertEquals(42, registry.get(key2));
}
}
// ------------------------------------------------------------------
// Lock mechanism
// ------------------------------------------------------------------
@Nested
@DisplayName("Lock mechanism")
class LockMechanism {
@Test
void isLockedReturnsFalseByDefault() {
var registry = new ServiceRegistry();
assertFalse(registry.isLocked());
}
@Test
void lockSetsLockedState() {
var registry = new ServiceRegistry();
registry.lock();
assertTrue(registry.isLocked());
}
@Test
void registerAfterLockThrows() {
var registry = new ServiceRegistry();
registry.lock();
var key = new ServiceKey<>("after-lock", String.class);
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> registry.register(key, "service"));
assertTrue(exception.getMessage().contains("locked"));
}
@Test
void registerBeforeLockThenRegisterAfterThrows() {
var registry = new ServiceRegistry();
var key1 = new ServiceKey<>("before-lock", String.class);
registry.register(key1, "before");
registry.lock();
var key2 = new ServiceKey<>("after-lock-2", Integer.class);
assertThrows(IllegalStateException.class, () -> registry.register(key2, 42));
}
@Test
void getAndFindWorkAfterLock() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("locked-service", String.class);
registry.register(key, "locked");
registry.lock();
assertEquals("locked", registry.get(key));
assertTrue(registry.find(key).isPresent());
}
}
// ------------------------------------------------------------------
// get() throws IllegalStateException for missing service
// ------------------------------------------------------------------
@Nested
@DisplayName("get() throws IllegalStateException for missing service")
class GetMissingThrows {
@Test
void getUnregisteredServiceThrows() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("missing", String.class);
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> registry.get(key));
assertTrue(exception.getMessage().contains("No service registered"));
}
}
// ------------------------------------------------------------------
// No Minecraft dependencies
// ------------------------------------------------------------------
@Nested
@DisplayName("Plain Java - no Minecraft imports")
class PlainJava {
@Test
void registryIsPureJava() {
var registry = new ServiceRegistry();
var key = new ServiceKey<>("plain", String.class);
registry.register(key, "test");
assertEquals("test", registry.get(key));
}
}
}
@@ -0,0 +1,181 @@
package com.livingworld.events;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link LivingWorldEventBus}.
*/
public class LivingWorldEventBusTest {
// ------------------------------------------------------------------
// Registration tests
// ------------------------------------------------------------------
@Test
void registerAddsListenerForEventType() {
LivingWorldEventBus bus = new LivingWorldEventBus();
LivingWorldEventListener listener = event -> {};
assertDoesNotThrow(() -> bus.register("weather_change", listener));
}
@Test
void registerPreservesRegistrationOrder() {
LivingWorldEventBus bus = new LivingWorldEventBus();
AtomicBoolean firstCalled = new AtomicBoolean(false);
AtomicBoolean secondCalled = new AtomicBoolean(false);
LivingWorldEventListener first = event -> firstCalled.set(true);
LivingWorldEventListener second = event -> secondCalled.set(true);
bus.register("weather_change", first);
bus.register("weather_change", second);
BaseLivingWorldEvent event = new BaseLivingWorldEvent("weather_change", 0L, "core");
publishEvent(bus, "weather_change", event);
assertTrue(firstCalled.get());
assertTrue(secondCalled.get());
}
@Test
void registerBlankEventTypeThrows() {
LivingWorldEventBus bus = new LivingWorldEventBus();
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
bus.register(" ", null);
});
assertEquals("eventType must not be null or blank", exception.getMessage());
}
@Test
void registerNullListenerThrows() {
LivingWorldEventBus bus = new LivingWorldEventBus();
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
bus.register("weather_change", null);
});
assertEquals("listener must not be null", exception.getMessage());
}
// ------------------------------------------------------------------
// Publishing tests
// ------------------------------------------------------------------
@Test
void publishDispatchesToRegisteredListeners() {
LivingWorldEventBus bus = new LivingWorldEventBus();
AtomicBoolean listenerCalled = new AtomicBoolean(false);
bus.register("weather_change", event -> listenerCalled.set(true));
BaseLivingWorldEvent event = new BaseLivingWorldEvent(
"weather_change", 100L, "core"
);
publishEvent(bus, "weather_change", event);
assertTrue(listenerCalled.get());
}
@Test
void publishUnknownEventTypeDoesNotCrash() {
LivingWorldEventBus bus = new LivingWorldEventBus();
BaseLivingWorldEvent event = new BaseLivingWorldEvent("unknown_type_123", 100L, "core");
assertDoesNotThrow(() -> publishEvent(bus, "unknown_type_123", event));
}
@Test
void publishNullEventThrows() {
LivingWorldEventBus bus = new LivingWorldEventBus();
assertThrowsWithMessage(() -> { try { publishEvent(bus, "weather_change", null); } catch (RuntimeException e) { throw e; } },
IllegalArgumentException.class, "event must not be null");
}
// ------------------------------------------------------------------
// Metrics tests
// ------------------------------------------------------------------
@Test
void getPublishedEventCountIncrementsOnPublish() {
LivingWorldEventBus bus = new LivingWorldEventBus();
BaseLivingWorldEvent event1 = new BaseLivingWorldEvent("weather_change", 100L, "core");
bus.publish(event1);
assertEquals(1, bus.getPublishedEventCount());
// Clear suppression to allow publishing events with different ticks
bus.clearSuppressionForCurrentTick();
BaseLivingWorldEvent event2 = new BaseLivingWorldEvent("soil_moisture", 200L, "core");
bus.publish(event2);
assertEquals(2, bus.getPublishedEventCount());
}
@Test
void getListenerCountReturnsCorrectNumberOfListeners() {
LivingWorldEventBus bus = new LivingWorldEventBus();
bus.register("weather_change", event -> {});
bus.register("weather_change", event -> {});
bus.register("pollution_level", event -> {});
assertEquals(2, bus.getListenerCount("weather_change"));
assertEquals(1, bus.getListenerCount("pollution_level"));
assertEquals(0, bus.getListenerCount("unknown_event"));
}
// ------------------------------------------------------------------
// Helper methods (kept in test file for simplicity)
// ------------------------------------------------------------------
private void publishEvent(LivingWorldEventBus bus, String eventType, LivingWorldEvent event) {
// Use reflection to call the package-private publish method
try {
java.lang.reflect.Method method = LivingWorldEventBus.class.getDeclaredMethod(
"publish", LivingWorldEvent.class
);
method.setAccessible(true);
if (event == null) {
method.invoke(bus, new Object[]{null});
} else {
method.invoke(bus, event);
}
} catch (java.lang.reflect.InvocationTargetException e) {
// Unwrap the target exception for proper test assertions
Throwable cause = e.getCause();
if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
} else if (cause != null) {
throw new RuntimeException(cause);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private void assertThrowsWithMessage(Runnable runnable, Class<? extends Throwable> expectedType, String expectedMessage) {
try {
runnable.run();
fail("Expected " + expectedType.getSimpleName() + " but no exception was thrown");
} catch (AssertionError e) {
throw e;
} catch (Throwable t) {
if (expectedType.isInstance(t)) {
assertEquals(expectedMessage, t.getMessage());
} else {
throw new AssertionError("Expected " + expectedType.getSimpleName() + " but got " + t.getClass().getSimpleName() + ": " + t.getMessage(), t);
}
}
}
}
@@ -0,0 +1,378 @@
package com.livingworld.regions;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link RegionCoordinate}.
*/
class RegionCoordinateTest {
// ------------------------------------------------------------------
// Validation dimensionId
// ------------------------------------------------------------------
@Nested
@DisplayName("dimensionId validation")
class DimensionIdValidation {
@Test
void nullDimensionIdThrows() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> new RegionCoordinate(null, 0, 0));
assertTrue(ex.getMessage().contains("dimensionId"));
}
@Test
void blankDimensionIdThrows() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> new RegionCoordinate("", 0, 0));
assertTrue(ex.getMessage().contains("dimensionId"));
}
@Test
void whitespaceOnlyDimensionIdThrows() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> new RegionCoordinate(" ", 0, 0));
assertTrue(ex.getMessage().contains("dimensionId"));
}
@Test
void validDimensionIdAccepted() {
RegionCoordinate rc = new RegionCoordinate("overworld", 0, 0);
assertEquals("overworld", rc.dimensionId());
}
}
// ------------------------------------------------------------------
// Validation regionSizeChunks
// ------------------------------------------------------------------
@Nested
@DisplayName("regionSizeChunks validation")
class RegionSizeValidation {
@Test
void zeroRegionSizeThrows() {
assertThrows(IllegalArgumentException.class,
() -> RegionCoordinate.fromChunk("overworld", 0, 0, 0));
}
@Test
void negativeRegionSizeThrows() {
assertThrows(IllegalArgumentException.class,
() -> RegionCoordinate.fromChunk("overworld", 0, 0, -1));
}
@Test
void oneRegionSizeAccepted() {
RegionCoordinate rc = RegionCoordinate.fromChunk("overworld", 5, 5, 1);
assertEquals(5, rc.x());
assertEquals(5, rc.z());
}
}
// ------------------------------------------------------------------
// fromBlock specified test cases
// ------------------------------------------------------------------
@Nested
@DisplayName("fromBlock coordinate mapping")
class FromBlockMapping {
/** Block 0,0 maps to region 0,0 */
@Test
void blockZeroZeroMapsToRegionZeroZero() {
RegionCoordinate rc = RegionCoordinate.fromBlock("overworld", 0, 0, 8);
assertEquals(0, rc.x());
assertEquals(0, rc.z());
}
/** Block 127,127 maps to region 0,0 with region size 8 chunks */
@Test
void block127_127MapsToRegionZeroZero() {
// chunk = floorDiv(block, 16) → floorDiv(127,16) = 7
// region = floorDiv(chunk, 8) → floorDiv(7,8) = 0
RegionCoordinate rc = RegionCoordinate.fromBlock("overworld", 127, 127, 8);
assertEquals(0, rc.x());
assertEquals(0, rc.z());
}
/** Block 128,0 maps to region 1,0 */
@Test
void block128ZeroMapsToRegionOneZero() {
// chunk = floorDiv(128,16) = 8
// region = floorDiv(8,8) = 1
RegionCoordinate rc = RegionCoordinate.fromBlock("overworld", 128, 0, 8);
assertEquals(1, rc.x());
assertEquals(0, rc.z());
}
/** Block -1,0 maps to region -1,0 */
@Test
void blockMinusOneZeroMapsToRegionMinusOneZero() {
// chunk = floorDiv(-1,16) = -1
// region = floorDiv(-1,8) = -1
RegionCoordinate rc = RegionCoordinate.fromBlock("overworld", -1, 0, 8);
assertEquals(-1, rc.x());
assertEquals(0, rc.z());
}
/** Block -128,0 maps to region -1,0 */
@Test
void blockMinus128ZeroMapsToRegionMinusOneZero() {
// chunk = floorDiv(-128,16) = -8
// region = floorDiv(-8,8) = -1
RegionCoordinate rc = RegionCoordinate.fromBlock("overworld", -128, 0, 8);
assertEquals(-1, rc.x());
assertEquals(0, rc.z());
}
/** Block -129,0 maps to region -2,0 */
@Test
void blockMinus129ZeroMapsToRegionMinusTwoZero() {
// chunk = floorDiv(-129,16) = -9
// region = floorDiv(-9,8) = -2
RegionCoordinate rc = RegionCoordinate.fromBlock("overworld", -129, 0, 8);
assertEquals(-2, rc.x());
assertEquals(0, rc.z());
}
}
// ------------------------------------------------------------------
// fromChunk basic checks
// ------------------------------------------------------------------
@Nested
@DisplayName("fromChunk coordinate mapping")
class FromChunkMapping {
@Test
void chunkZeroZeroMapsToRegionZeroZero() {
RegionCoordinate rc = RegionCoordinate.fromChunk("overworld", 0, 0, 8);
assertEquals(0, rc.x());
assertEquals(0, rc.z());
}
@Test
void chunkSevenSevenMapsToRegionZeroZero() {
RegionCoordinate rc = RegionCoordinate.fromChunk("overworld", 7, 7, 8);
assertEquals(0, rc.x());
assertEquals(0, rc.z());
}
@Test
void chunkEightEightMapsToRegionOneOne() {
RegionCoordinate rc = RegionCoordinate.fromChunk("overworld", 8, 8, 8);
assertEquals(1, rc.x());
assertEquals(1, rc.z());
}
@Test
void chunkMinusOneMinusOneMapsToRegionMinusOneMinusOne() {
RegionCoordinate rc = RegionCoordinate.fromChunk("overworld", -1, -1, 8);
assertEquals(-1, rc.x());
assertEquals(-1, rc.z());
}
}
// ------------------------------------------------------------------
// Boundary methods min/max chunk coordinates
// ------------------------------------------------------------------
@Nested
@DisplayName("minChunkX / minChunkZ / maxChunkX / maxChunkZ")
class ChunkBoundaryMethods {
@Test
void regionZeroBoundariesWithSizeEight() {
RegionCoordinate rc = new RegionCoordinate("overworld", 0, 0);
assertEquals(0, rc.minChunkX(8));
assertEquals(0, rc.minChunkZ(8));
assertEquals(7, rc.maxChunkX(8));
assertEquals(7, rc.maxChunkZ(8));
}
@Test
void regionOneBoundariesWithSizeEight() {
RegionCoordinate rc = new RegionCoordinate("overworld", 1, 1);
assertEquals(8, rc.minChunkX(8));
assertEquals(8, rc.minChunkZ(8));
assertEquals(15, rc.maxChunkX(8));
assertEquals(15, rc.maxChunkZ(8));
}
@Test
void regionMinusOneBoundariesWithSizeEight() {
RegionCoordinate rc = new RegionCoordinate("overworld", -1, -1);
assertEquals(-8, rc.minChunkX(8));
assertEquals(-8, rc.minChunkZ(8));
assertEquals(-1, rc.maxChunkX(8));
assertEquals(-1, rc.maxChunkZ(8));
}
@Test
void invalidRegionSizeThrowsInBoundaryMethods() {
RegionCoordinate rc = new RegionCoordinate("overworld", 0, 0);
assertThrows(IllegalArgumentException.class, () -> rc.minChunkX(0));
assertThrows(IllegalArgumentException.class, () -> rc.minChunkZ(-1));
assertThrows(IllegalArgumentException.class, () -> rc.maxChunkX(0));
assertThrows(IllegalArgumentException.class, () -> rc.maxChunkZ(-5));
}
}
// ------------------------------------------------------------------
// centerBlock methods
// ------------------------------------------------------------------
@Nested
@DisplayName("centerBlockX / centerBlockZ")
class CenterBlockMethods {
/** Region 0 with size 8: chunks 0-7, blocks 0-127 → centre = floorDiv(0+127,2) = 63 */
@Test
void centerOfRegionZero() {
RegionCoordinate rc = new RegionCoordinate("overworld", 0, 0);
assertEquals(63, rc.centerBlockX(8));
assertEquals(63, rc.centerBlockZ(8));
}
/** Region 1 with size 8: chunks 8-15, blocks 128-255 → centre = floorDiv(128+255,2) = 191 */
@Test
void centerOfRegionOne() {
RegionCoordinate rc = new RegionCoordinate("overworld", 1, 1);
assertEquals(191, rc.centerBlockX(8));
assertEquals(191, rc.centerBlockZ(8));
}
/** Region -1 with size 8: chunks -8 to -1, blocks -128 to -1 → centre = floorDiv(-128+-1,2) = -65 */
@Test
void centerOfRegionMinusOne() {
RegionCoordinate rc = new RegionCoordinate("overworld", -1, -1);
assertEquals(-65, rc.centerBlockX(8));
assertEquals(-65, rc.centerBlockZ(8));
}
@Test
void invalidRegionSizeThrowsInCenterMethods() {
RegionCoordinate rc = new RegionCoordinate("overworld", 0, 0);
assertThrows(IllegalArgumentException.class, () -> rc.centerBlockX(0));
assertThrows(IllegalArgumentException.class, () -> rc.centerBlockZ(-1));
}
}
// ------------------------------------------------------------------
// stableId
// ------------------------------------------------------------------
@Nested
@DisplayName("stableId")
class StableIdTests {
@Test
void stableIdIsStable() {
RegionCoordinate rc = new RegionCoordinate("overworld", 1, -2);
String id1 = rc.stableId();
String id2 = rc.stableId();
assertEquals(id1, id2);
}
@Test
void stableIdFormat() {
RegionCoordinate rc = new RegionCoordinate("the_nether", 3, 5);
assertEquals("the_nether:3:5", rc.stableId());
}
@Test
void negativeCoordinatesInStableId() {
RegionCoordinate rc = new RegionCoordinate("overworld", -1, -2);
assertEquals("overworld:-1:-2", rc.stableId());
}
}
// ------------------------------------------------------------------
// HashMap key usage
// ------------------------------------------------------------------
@Nested
@DisplayName("HashMap key compatibility")
class HashMapKeyTests {
@Test
void worksAsHashMapKey() {
Map<RegionCoordinate, String> map = new HashMap<>();
RegionCoordinate key1 = new RegionCoordinate("overworld", 0, 0);
RegionCoordinate key2 = new RegionCoordinate("overworld", 0, 0);
RegionCoordinate key3 = new RegionCoordinate("overworld", 1, 0);
map.put(key1, "value1");
assertEquals("value1", map.get(key2)); // same logical key
assertNotEquals("value1", map.get(key3)); // different key
}
@Test
void equalsAndHashCodeContract() {
RegionCoordinate rc1 = new RegionCoordinate("overworld", 0, 0);
RegionCoordinate rc2 = new RegionCoordinate("overworld", 0, 0);
assertEquals(rc1, rc2);
assertEquals(rc1.hashCode(), rc2.hashCode());
}
@Test
void notEqualToDifferentDimension() {
RegionCoordinate rc1 = new RegionCoordinate("overworld", 0, 0);
RegionCoordinate rc2 = new RegionCoordinate("nether", 0, 0);
assertFalse(rc1.equals(rc2));
}
@Test
void notEqualToDifferentType() {
RegionCoordinate rc = new RegionCoordinate("overworld", 0, 0);
assertFalse(rc.equals("not a region coordinate"));
}
@Test
void nullSafeEquals() {
RegionCoordinate rc = new RegionCoordinate("overworld", 0, 0);
assertFalse(rc.equals(null));
}
}
// ------------------------------------------------------------------
// toString
// ------------------------------------------------------------------
@Nested
@DisplayName("toString")
class ToStringTests {
@Test
void toStringContainsAllFields() {
RegionCoordinate rc = new RegionCoordinate("overworld", 1, -3);
String s = rc.toString();
assertTrue(s.contains("RegionCoordinate"));
assertTrue(s.contains("overworld"));
assertTrue(s.contains("x=1"));
assertTrue(s.contains("z=-3"));
}
@Test
void toStringShouldNotThrow() {
RegionCoordinate rc = new RegionCoordinate("the_end", 0, 0);
assertDoesNotThrow(() -> rc.toString());
}
}
}
@@ -0,0 +1,200 @@
package com.livingworld.regions;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for {@link RegionFactory}.
*/
class RegionFactoryTest {
private final RegionFactory factory = new RegionFactory();
// ------------------------------------------------------------------
// Null handling
// ------------------------------------------------------------------
@Test
void createNewRegionWithNullCoordinateThrows() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> factory.createNewRegion(null, 0L)
);
assertEquals("coordinate must not be null", exception.getMessage());
}
// ------------------------------------------------------------------
// Basic creation
// ------------------------------------------------------------------
@Test
void createNewRegionReturnsValidRegion() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
long tick = 42L;
Region region = factory.createNewRegion(coordinate, tick);
assertNotNull(region);
assertDoesNotThrow(() -> region.validate());
}
// ------------------------------------------------------------------
// UUID assignment
// ------------------------------------------------------------------
@Test
void createNewRegionAssignsRandomUUID() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
Region region1 = factory.createNewRegion(coordinate, 0L);
Region region2 = factory.createNewRegion(coordinate, 0L);
assertNotEquals(region1.getId(), region2.getId());
}
@Test
void createNewRegionUUIDIsNotNull() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
Region region = factory.createNewRegion(coordinate, 0L);
assertNotNull(region.getId());
assertInstanceOf(UUID.class, region.getId());
}
// ------------------------------------------------------------------
// Coordinate assignment
// ------------------------------------------------------------------
@Test
void createNewRegionUsesArgumentCoordinate() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 5, -3);
Region region = factory.createNewRegion(coordinate, 0L);
assertEquals(coordinate, region.getCoordinate());
}
// ------------------------------------------------------------------
// Lifecycle state
// ------------------------------------------------------------------
@Test
void createNewRegionStartsActive() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
Region region = factory.createNewRegion(coordinate, 0L);
assertEquals(RegionLifecycleState.ACTIVE, region.getLifecycleState());
}
// ------------------------------------------------------------------
// Simulation tick values
// ------------------------------------------------------------------
@Test
void createNewRegionSetsCreatedAtSimulationTick() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
long tick = 12345L;
Region region = factory.createNewRegion(coordinate, tick);
assertEquals(tick, region.getCreatedAtSimulationTick());
}
@Test
void createNewRegionSetsLastUpdatedSimulationTick() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
long tick = 12345L;
Region region = factory.createNewRegion(coordinate, tick);
assertEquals(tick, region.getLastUpdatedSimulationTick());
}
// ------------------------------------------------------------------
// Dirty flag
// ------------------------------------------------------------------
@Test
void createNewRegionIsDirty() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
Region region = factory.createNewRegion(coordinate, 0L);
assertTrue(region.isDirty());
}
// ------------------------------------------------------------------
// Default flags
// ------------------------------------------------------------------
@Test
void createNewRegionHasDefaultFlags() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
Region region = factory.createNewRegion(coordinate, 0L);
RegionFlags flags = region.getFlags();
assertNotNull(flags);
assertFalse(flags.isHasPlayerActivity());
assertFalse(flags.isHasHighPollution());
assertFalse(flags.isHasLowSoilQuality());
assertFalse(flags.isHasActiveEcosystemEvent());
assertFalse(flags.isForceLoadedBySimulation());
assertFalse(flags.isCorrupted());
}
// ------------------------------------------------------------------
// Default metrics
// ------------------------------------------------------------------
@Test
void createNewRegionHasDefaultMetrics() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
Region region = factory.createNewRegion(coordinate, 0L);
RegionMetrics metrics = region.getMetrics();
assertNotNull(metrics);
assertEquals(60.0, metrics.getEcosystemHealth());
assertEquals(0.0, metrics.getPollutionScore());
assertEquals(60.0, metrics.getSoilQuality());
assertEquals(60.0, metrics.getWaterQuality());
assertEquals(50.0, metrics.getVegetationPressure());
assertEquals(0.0, metrics.getResourceDepletion());
assertEquals(50.0, metrics.getRecoveryPressure());
}
// ------------------------------------------------------------------
// Empty module data
// ------------------------------------------------------------------
@Test
void createNewRegionHasEmptyModuleData() {
RegionCoordinate coordinate = new RegionCoordinate("overworld", 0, 0);
Region region = factory.createNewRegion(coordinate, 0L);
RegionModuleData moduleData = region.getModuleData();
assertNotNull(moduleData);
assertTrue(moduleData.moduleIds().isEmpty());
}
// ------------------------------------------------------------------
// Different dimensions
// ------------------------------------------------------------------
@Test
void createNewRegionWorksForDifferentDimensions() {
RegionCoordinate coordOverworld = new RegionCoordinate("overworld", 0, 0);
RegionCoordinate coordNether = new RegionCoordinate("the_nether", 1, 2);
RegionCoordinate coordEnd = new RegionCoordinate("end", -3, -4);
Region regionOverworld = factory.createNewRegion(coordOverworld, 0L);
Region regionNether = factory.createNewRegion(coordNether, 0L);
Region regionEnd = factory.createNewRegion(coordEnd, 0L);
assertEquals(coordOverworld, regionOverworld.getCoordinate());
assertEquals(coordNether, regionNether.getCoordinate());
assertEquals(coordEnd, regionEnd.getCoordinate());
assertNotEquals(regionOverworld.getId(), regionNether.getId());
assertNotEquals(regionNether.getId(), regionEnd.getId());
}
}
@@ -0,0 +1,217 @@
package com.livingworld.regions;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link RegionFlags}.
*/
class RegionFlagsTest {
// ------------------------------------------------------------------
// Default values
// ------------------------------------------------------------------
@Nested
@DisplayName("Default values")
class DefaultValues {
@Test
void allFlagsDefaultToFalse() {
RegionFlags flags = new RegionFlags();
assertFalse(flags.isHasPlayerActivity());
assertFalse(flags.isHasHighPollution());
assertFalse(flags.isHasLowSoilQuality());
assertFalse(flags.isHasActiveEcosystemEvent());
assertFalse(flags.isForceLoadedBySimulation());
assertFalse(flags.isCorrupted());
}
@Test
void instanceIsNotNull() {
assertNotNull(new RegionFlags());
}
}
// ------------------------------------------------------------------
// Getters and Setters
// ------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GetterSetterTests {
@Test
void hasPlayerActivity() {
RegionFlags flags = new RegionFlags();
flags.setHasPlayerActivity(true);
assertTrue(flags.isHasPlayerActivity());
flags.setHasPlayerActivity(false);
assertFalse(flags.isHasPlayerActivity());
}
@Test
void hasHighPollution() {
RegionFlags flags = new RegionFlags();
flags.setHasHighPollution(true);
assertTrue(flags.isHasHighPollution());
flags.setHasHighPollution(false);
assertFalse(flags.isHasHighPollution());
}
@Test
void hasLowSoilQuality() {
RegionFlags flags = new RegionFlags();
flags.setHasLowSoilQuality(true);
assertTrue(flags.isHasLowSoilQuality());
flags.setHasLowSoilQuality(false);
assertFalse(flags.isHasLowSoilQuality());
}
@Test
void hasActiveEcosystemEvent() {
RegionFlags flags = new RegionFlags();
flags.setHasActiveEcosystemEvent(true);
assertTrue(flags.isHasActiveEcosystemEvent());
flags.setHasActiveEcosystemEvent(false);
assertFalse(flags.isHasActiveEcosystemEvent());
}
@Test
void forceLoadedBySimulation() {
RegionFlags flags = new RegionFlags();
flags.setForceLoadedBySimulation(true);
assertTrue(flags.isForceLoadedBySimulation());
flags.setForceLoadedBySimulation(false);
assertFalse(flags.isForceLoadedBySimulation());
}
@Test
void corrupted() {
RegionFlags flags = new RegionFlags();
flags.setCorrupted(true);
assertTrue(flags.isCorrupted());
flags.setCorrupted(false);
assertFalse(flags.isCorrupted());
}
}
// ------------------------------------------------------------------
// copy()
// ------------------------------------------------------------------
@Nested
@DisplayName("copy()")
class CopyTests {
@Test
void copyReturnsIndependentInstance() {
RegionFlags original = new RegionFlags();
original.setHasPlayerActivity(true);
original.setCorrupted(true);
RegionFlags copy = original.copy();
assertNotNull(copy);
assertNotSame(original, copy);
}
@Test
void copyPreservesAllFlagValues() {
RegionFlags original = new RegionFlags();
original.setHasPlayerActivity(true);
original.setHasHighPollution(true);
original.setHasLowSoilQuality(true);
original.setHasActiveEcosystemEvent(true);
original.setForceLoadedBySimulation(true);
original.setCorrupted(true);
RegionFlags copy = original.copy();
assertTrue(copy.isHasPlayerActivity());
assertTrue(copy.isHasHighPollution());
assertTrue(copy.isHasLowSoilQuality());
assertTrue(copy.isHasActiveEcosystemEvent());
assertTrue(copy.isForceLoadedBySimulation());
assertTrue(copy.isCorrupted());
}
@Test
void modifyingCopyDoesNotAffectOriginal() {
RegionFlags original = new RegionFlags();
original.setHasPlayerActivity(true);
original.setCorrupted(false);
RegionFlags copy = original.copy();
copy.setHasPlayerActivity(false);
copy.setCorrupted(true);
assertTrue(original.isHasPlayerActivity());
assertFalse(original.isCorrupted());
}
}
// ------------------------------------------------------------------
// clearTransientFlags()
// ------------------------------------------------------------------
@Nested
@DisplayName("clearTransientFlags()")
class ClearTransientFlagTests {
@Test
void clearsTransientFlagsOnly() {
RegionFlags flags = new RegionFlags();
flags.setHasPlayerActivity(true);
flags.setHasActiveEcosystemEvent(true);
flags.setHasHighPollution(true);
flags.setHasLowSoilQuality(true);
flags.setForceLoadedBySimulation(true);
flags.setCorrupted(true);
flags.clearTransientFlags();
// Transient flags cleared
assertFalse(flags.isHasPlayerActivity());
assertFalse(flags.isHasActiveEcosystemEvent());
// Persistent flags preserved
assertTrue(flags.isHasHighPollution());
assertTrue(flags.isHasLowSoilQuality());
assertTrue(flags.isForceLoadedBySimulation());
assertTrue(flags.isCorrupted());
}
@Test
void safeToCallWhenAllFlagsAlreadyFalse() {
RegionFlags flags = new RegionFlags();
flags.clearTransientFlags(); // should not throw
assertFalse(flags.isHasPlayerActivity());
assertFalse(flags.isHasActiveEcosystemEvent());
}
}
// ------------------------------------------------------------------
// toString
// ------------------------------------------------------------------
@Nested
@DisplayName("toString")
class ToStringTests {
@Test
void toStringContainsFlagNames() {
RegionFlags flags = new RegionFlags();
flags.setHasPlayerActivity(true);
String s = flags.toString();
assertTrue(s.contains("hasPlayerActivity=true"));
assertTrue(s.contains("corrupted=false"));
}
}
}
@@ -0,0 +1,308 @@
package com.livingworld.regions;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link RegionLifecycleController}.
*/
class RegionLifecycleControllerTest {
// ------------------------------------------------------------------
// Allowed transitions
// ------------------------------------------------------------------
@Nested
@DisplayName("Allowed transitions")
class AllowedTransitions {
@Test
@DisplayName("UNLOADED -> LOADING is allowed")
void unloadedToLoading() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.UNLOADED, RegionLifecycleState.LOADING));
}
@Test
@DisplayName("LOADING -> ACTIVE is allowed")
void loadingToActive() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.LOADING, RegionLifecycleState.ACTIVE));
}
@Test
@DisplayName("LOADING -> FAILED is allowed")
void loadingToFailed() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.LOADING, RegionLifecycleState.FAILED));
}
@Test
@DisplayName("ACTIVE -> DIRTY is allowed")
void activeToDirty() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.ACTIVE, RegionLifecycleState.DIRTY));
}
@Test
@DisplayName("DIRTY -> SAVING is allowed")
void dirtyToSaving() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.DIRTY, RegionLifecycleState.SAVING));
}
@Test
@DisplayName("SAVING -> ACTIVE is allowed")
void savingToActive() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.SAVING, RegionLifecycleState.ACTIVE));
}
@Test
@DisplayName("SAVING -> FAILED is allowed")
void savingToFailed() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.SAVING, RegionLifecycleState.FAILED));
}
@Test
@DisplayName("ACTIVE -> UNLOADING is allowed")
void activeToUnloading() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.ACTIVE, RegionLifecycleState.UNLOADING));
}
@Test
@DisplayName("UNLOADING -> UNLOADED is allowed")
void unloadingToUnloaded() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.UNLOADING, RegionLifecycleState.UNLOADED));
}
@Test
@DisplayName("FAILED -> LOADING is allowed")
void failedToLoading() {
assertTrue(RegionLifecycleController.canTransition(
RegionLifecycleState.FAILED, RegionLifecycleState.LOADING));
}
}
// ------------------------------------------------------------------
// Disallowed transitions
// ------------------------------------------------------------------
@Nested
@DisplayName("Disallowed transitions")
class DisallowedTransitions {
private static final String[] STATES = {
"UNLOADED", "LOADING", "ACTIVE", "DIRTY", "SAVING", "FAILED", "UNLOADING"
};
@Test
@DisplayName("Same state is not allowed (no self-transitions)")
void sameStateNotAllowed() {
for (String state : STATES) {
assertFalse(RegionLifecycleController.canTransition(
RegionLifecycleState.valueOf(state), RegionLifecycleState.valueOf(state)));
}
}
@Test
@DisplayName("UNLOADED -> ACTIVE is not allowed")
void unloadedToActive() {
assertFalse(RegionLifecycleController.canTransition(
RegionLifecycleState.UNLOADED, RegionLifecycleState.ACTIVE));
}
@Test
@DisplayName("ACTIVE -> LOADING is not allowed")
void activeToLoading() {
assertFalse(RegionLifecycleController.canTransition(
RegionLifecycleState.ACTIVE, RegionLifecycleState.LOADING));
}
@Test
@DisplayName("FAILED -> UNLOADED is not allowed")
void failedToUnloaded() {
assertFalse(RegionLifecycleController.canTransition(
RegionLifecycleState.FAILED, RegionLifecycleState.UNLOADED));
}
@Test
@DisplayName("ACTIVE -> SAVING is not allowed")
void activeToSaving() {
assertFalse(RegionLifecycleController.canTransition(
RegionLifecycleState.ACTIVE, RegionLifecycleState.SAVING));
}
@Test
@DisplayName("All invalid transitions throw IllegalStateException on transition()")
void invalidTransitionsThrowException() {
Region region1 = new Region(
java.util.UUID.randomUUID(),
new RegionCoordinate("minecraft:overworld", 0, 0),
RegionLifecycleState.ACTIVE, 0, 0, false,
new RegionFlags(), new RegionMetrics(), new RegionModuleData());
// ACTIVE -> LOADING (invalid)
assertThrows(IllegalStateException.class, () ->
RegionLifecycleController.transition(region1, RegionLifecycleState.LOADING));
// ACTIVE -> SAVING (invalid)
Region region2 = new Region(
java.util.UUID.randomUUID(),
new RegionCoordinate("minecraft:overworld", 0, 0),
RegionLifecycleState.ACTIVE, 0, 0, false,
new RegionFlags(), new RegionMetrics(), new RegionModuleData());
assertThrows(IllegalStateException.class, () ->
RegionLifecycleController.transition(region2, RegionLifecycleState.SAVING));
}
}
// ------------------------------------------------------------------
// Null handling
// ------------------------------------------------------------------
@Nested
@DisplayName("Null handling")
class NullHandling {
@Test
@DisplayName("canTransition throws IllegalArgumentException for null from")
void canTransitionNullFrom() {
assertThrows(IllegalArgumentException.class, () ->
RegionLifecycleController.canTransition(null, RegionLifecycleState.ACTIVE));
}
@Test
@DisplayName("canTransition throws IllegalArgumentException for null to")
void canTransitionNullTo() {
assertThrows(IllegalArgumentException.class, () ->
RegionLifecycleController.canTransition(RegionLifecycleState.UNLOADED, null));
}
@Test
@DisplayName("transition throws IllegalArgumentException for null region")
void transitionNullRegion() {
assertThrows(IllegalArgumentException.class, () ->
RegionLifecycleController.transition(null, RegionLifecycleState.LOADING));
}
@Test
@DisplayName("transition throws IllegalArgumentException for null target")
void transitionNullTarget() {
Region region = new Region(
java.util.UUID.randomUUID(),
new RegionCoordinate("minecraft:overworld", 0, 0),
RegionLifecycleState.UNLOADED, 0, 0, false,
new RegionFlags(), new RegionMetrics(), new RegionModuleData());
assertThrows(IllegalArgumentException.class, () ->
RegionLifecycleController.transition(region, null));
}
}
// ------------------------------------------------------------------
// transition() side effects
// ------------------------------------------------------------------
@Nested
@DisplayName("transition() side effects")
class TransitionSideEffects {
@Test
@DisplayName("transition() updates lifecycle state and marks dirty")
void transitionUpdatesStateAndMarksDirty() {
Region region = new Region(
java.util.UUID.randomUUID(),
new RegionCoordinate("minecraft:overworld", 0, 0),
RegionLifecycleState.UNLOADED, 0, 0, false,
new RegionFlags(), new RegionMetrics(), new RegionModuleData());
assertFalse(region.isDirty());
assertEquals(RegionLifecycleState.UNLOADED, region.getLifecycleState());
RegionLifecycleController.transition(region, RegionLifecycleState.LOADING);
assertEquals(RegionLifecycleState.LOADING, region.getLifecycleState());
assertTrue(region.isDirty());
}
@Test
@DisplayName("transition() updates state through full load-save-unload cycle")
void fullCycleTransition() {
Region region = new Region(
java.util.UUID.randomUUID(),
new RegionCoordinate("minecraft:overworld", 0, 0),
RegionLifecycleState.UNLOADED, 0, 0, false,
new RegionFlags(), new RegionMetrics(), new RegionModuleData());
// UNLOADED -> LOADING
RegionLifecycleController.transition(region, RegionLifecycleState.LOADING);
assertEquals(RegionLifecycleState.LOADING, region.getLifecycleState());
// LOADING -> ACTIVE
RegionLifecycleController.transition(region, RegionLifecycleState.ACTIVE);
assertEquals(RegionLifecycleState.ACTIVE, region.getLifecycleState());
// ACTIVE -> DIRTY
RegionLifecycleController.transition(region, RegionLifecycleState.DIRTY);
assertEquals(RegionLifecycleState.DIRTY, region.getLifecycleState());
// DIRTY -> SAVING
RegionLifecycleController.transition(region, RegionLifecycleState.SAVING);
assertEquals(RegionLifecycleState.SAVING, region.getLifecycleState());
// SAVING -> ACTIVE (successful save)
RegionLifecycleController.transition(region, RegionLifecycleState.ACTIVE);
assertEquals(RegionLifecycleState.ACTIVE, region.getLifecycleState());
// ACTIVE -> UNLOADING
RegionLifecycleController.transition(region, RegionLifecycleState.UNLOADING);
assertEquals(RegionLifecycleState.UNLOADING, region.getLifecycleState());
// UNLOADING -> UNLOADED
RegionLifecycleController.transition(region, RegionLifecycleState.UNLOADED);
assertEquals(RegionLifecycleState.UNLOADED, region.getLifecycleState());
}
@Test
@DisplayName("transition() allows FAILED -> LOADING recovery")
void failedToLoadingRecovery() {
Region region = new Region(
java.util.UUID.randomUUID(),
new RegionCoordinate("minecraft:overworld", 0, 0),
RegionLifecycleState.FAILED, 0, 0, false,
new RegionFlags(), new RegionMetrics(), new RegionModuleData());
RegionLifecycleController.transition(region, RegionLifecycleState.LOADING);
assertEquals(RegionLifecycleState.LOADING, region.getLifecycleState());
}
@Test
@DisplayName("transition() allows SAVING -> FAILED error path")
void savingToFailedErrorPath() {
Region region = new Region(
java.util.UUID.randomUUID(),
new RegionCoordinate("minecraft:overworld", 0, 0),
RegionLifecycleState.ACTIVE, 0, 0, false,
new RegionFlags(), new RegionMetrics(), new RegionModuleData());
// ACTIVE -> DIRTY
RegionLifecycleController.transition(region, RegionLifecycleState.DIRTY);
assertEquals(RegionLifecycleState.DIRTY, region.getLifecycleState());
// DIRTY -> SAVING
RegionLifecycleController.transition(region, RegionLifecycleState.SAVING);
assertEquals(RegionLifecycleState.SAVING, region.getLifecycleState());
// SAVING -> FAILED (save error)
RegionLifecycleController.transition(region, RegionLifecycleState.FAILED);
assertEquals(RegionLifecycleState.FAILED, region.getLifecycleState());
}
}
}
@@ -0,0 +1,435 @@
package com.livingworld.regions;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Comprehensive tests for {@link RegionMetrics}.
*/
class RegionMetricsTest {
// ------------------------------------------------------------------
// Default Values Tests
// ------------------------------------------------------------------
@Nested
@DisplayName("Default Values")
class DefaultValues {
@Test
@DisplayName("defaults() returns correct ecosystem health")
void defaultsEcosystemHealth() {
RegionMetrics metrics = RegionMetrics.defaults();
assertEquals(60, metrics.getEcosystemHealth(), 0.001);
}
@Test
@DisplayName("defaults() returns correct pollution score")
void defaultsPollutionScore() {
RegionMetrics metrics = RegionMetrics.defaults();
assertEquals(0, metrics.getPollutionScore(), 0.001);
}
@Test
@DisplayName("defaults() returns correct soil quality")
void defaultsSoilQuality() {
RegionMetrics metrics = RegionMetrics.defaults();
assertEquals(60, metrics.getSoilQuality(), 0.001);
}
@Test
@DisplayName("defaults() returns correct water quality")
void defaultsWaterQuality() {
RegionMetrics metrics = RegionMetrics.defaults();
assertEquals(60, metrics.getWaterQuality(), 0.001);
}
@Test
@DisplayName("defaults() returns correct vegetation pressure")
void defaultsVegetationPressure() {
RegionMetrics metrics = RegionMetrics.defaults();
assertEquals(50, metrics.getVegetationPressure(), 0.001);
}
@Test
@DisplayName("defaults() returns correct resource depletion")
void defaultsResourceDepletion() {
RegionMetrics metrics = RegionMetrics.defaults();
assertEquals(0, metrics.getResourceDepletion(), 0.001);
}
@Test
@DisplayName("defaults() returns correct recovery pressure")
void defaultsRecoveryPressure() {
RegionMetrics metrics = RegionMetrics.defaults();
assertEquals(50, metrics.getRecoveryPressure(), 0.001);
}
@Test
@DisplayName("default constructor initializes all fields to 0")
void defaultConstructorAllZeros() {
RegionMetrics metrics = new RegionMetrics();
assertEquals(0, metrics.getEcosystemHealth(), 0.001);
assertEquals(0, metrics.getPollutionScore(), 0.001);
assertEquals(0, metrics.getSoilQuality(), 0.001);
assertEquals(0, metrics.getWaterQuality(), 0.001);
assertEquals(0, metrics.getVegetationPressure(), 0.001);
assertEquals(0, metrics.getResourceDepletion(), 0.001);
assertEquals(0, metrics.getRecoveryPressure(), 0.001);
}
}
// ------------------------------------------------------------------
// Getter/Setter Tests
// ------------------------------------------------------------------
@Nested
@DisplayName("Getter and Setter Clamping")
class GetterSetterClamping {
@Test
@DisplayName("setter clamps values above 100 to 100")
void setterClampsAboveMax() {
RegionMetrics metrics = new RegionMetrics();
metrics.setEcosystemHealth(150);
assertEquals(100, metrics.getEcosystemHealth(), 0.001);
}
@Test
@DisplayName("setter clamps values below 0 to 0")
void setterClampsBelowMin() {
RegionMetrics metrics = new RegionMetrics();
metrics.setPollutionScore(-50);
assertEquals(0, metrics.getPollutionScore(), 0.001);
}
@Test
@DisplayName("setter accepts exact boundary values")
void setterAcceptsBoundaries() {
RegionMetrics metrics = new RegionMetrics();
metrics.setSoilQuality(0);
assertEquals(0, metrics.getSoilQuality(), 0.001);
metrics.setSoilQuality(100);
assertEquals(100, metrics.getSoilQuality(), 0.001);
}
@Test
@DisplayName("setter accepts normal values")
void setterAcceptsNormalValues() {
RegionMetrics metrics = new RegionMetrics();
metrics.setWaterQuality(42.5);
assertEquals(42.5, metrics.getWaterQuality(), 0.001);
}
@Test
@DisplayName("all setters clamp correctly")
void allSettersClamp() {
RegionMetrics metrics = new RegionMetrics();
metrics.setVegetationPressure(-10);
assertEquals(0, metrics.getVegetationPressure(), 0.001);
metrics.setResourceDepletion(200);
assertEquals(100, metrics.getResourceDepletion(), 0.001);
metrics.setRecoveryPressure(-5);
assertEquals(0, metrics.getRecoveryPressure(), 0.001);
}
}
// ------------------------------------------------------------------
// Normalize Tests
// ------------------------------------------------------------------
@Nested
@DisplayName("Normalize")
class NormalizeTests {
@Test
@DisplayName("normalize() clamps values above 100")
void normalizeClampsAboveMax() {
RegionMetrics metrics = new RegionMetrics();
metrics.setEcosystemHealth(150);
metrics.setPollutionScore(-20);
metrics.normalize();
assertEquals(100, metrics.getEcosystemHealth(), 0.001);
assertEquals(0, metrics.getPollutionScore(), 0.001);
}
@Test
@DisplayName("normalize() clamps values below 0")
void normalizeClampsBelowMin() {
RegionMetrics metrics = new RegionMetrics();
metrics.setEcosystemHealth(-50);
metrics.setPollutionScore(200);
metrics.normalize();
assertEquals(0, metrics.getEcosystemHealth(), 0.001);
assertEquals(100, metrics.getPollutionScore(), 0.001);
}
@Test
@DisplayName("normalize() leaves valid values unchanged")
void normalizeLeavesValidUnchanged() {
RegionMetrics metrics = new RegionMetrics();
metrics.setEcosystemHealth(50);
metrics.setPollutionScore(30);
metrics.normalize();
assertEquals(50, metrics.getEcosystemHealth(), 0.001);
assertEquals(30, metrics.getPollutionScore(), 0.001);
}
@Test
@DisplayName("normalize() returns this for chaining")
void normalizeReturnsThis() {
RegionMetrics metrics = new RegionMetrics();
RegionMetrics result = metrics.normalize();
assertTrue(result == metrics, "normalize() should return the same instance");
}
}
// ------------------------------------------------------------------
// Copy Tests
// ------------------------------------------------------------------
@Nested
@DisplayName("Copy")
class CopyTests {
@Test
@DisplayName("copy() returns independent instance")
void copyReturnsIndependentInstance() {
RegionMetrics original = new RegionMetrics();
original.setEcosystemHealth(80);
original.setPollutionScore(30);
RegionMetrics copy = original.copy();
assertEquals(80, copy.getEcosystemHealth(), 0.001);
assertEquals(30, copy.getPollutionScore(), 0.001);
// Modify the copy
copy.setEcosystemHealth(90);
// Original should be unchanged
assertEquals(80, original.getEcosystemHealth(), 0.001);
}
@Test
@DisplayName("copy() returns a different object")
void copyReturnsDifferentObject() {
RegionMetrics metrics = new RegionMetrics();
metrics.setSoilQuality(75);
RegionMetrics copy = metrics.copy();
assertNotSame(metrics, copy);
}
@Test
@DisplayName("copy() copies all fields")
void copyAllFields() {
RegionMetrics original = new RegionMetrics();
original.setEcosystemHealth(10);
original.setPollutionScore(20);
original.setSoilQuality(30);
original.setWaterQuality(40);
original.setVegetationPressure(50);
original.setResourceDepletion(60);
original.setRecoveryPressure(70);
RegionMetrics copy = original.copy();
assertEquals(original.getEcosystemHealth(), copy.getEcosystemHealth(), 0.001);
assertEquals(original.getPollutionScore(), copy.getPollutionScore(), 0.001);
assertEquals(original.getSoilQuality(), copy.getSoilQuality(), 0.001);
assertEquals(original.getWaterQuality(), copy.getWaterQuality(), 0.001);
assertEquals(original.getVegetationPressure(), copy.getVegetationPressure(), 0.001);
assertEquals(original.getResourceDepletion(), copy.getResourceDepletion(), 0.001);
assertEquals(original.getRecoveryPressure(), copy.getRecoveryPressure(), 0.001);
}
}
// ------------------------------------------------------------------
// ApplyDelta Tests
// ------------------------------------------------------------------
@Nested
@DisplayName("Apply Delta")
class ApplyDeltaTests {
@Test
@DisplayName("applyDelta() modifies all fields correctly")
void applyDeltaAllFields() {
RegionMetrics metrics = new RegionMetrics();
metrics.setEcosystemHealth(50);
metrics.setPollutionScore(20);
metrics.setSoilQuality(80);
metrics.applyDelta(10, -5, 15, 0, 0, 0, 0);
assertEquals(60, metrics.getEcosystemHealth(), 0.001);
assertEquals(15, metrics.getPollutionScore(), 0.001);
assertEquals(95, metrics.getSoilQuality(), 0.001);
}
@Test
@DisplayName("applyDelta() clamps results above max")
void applyDeltaClampsAboveMax() {
RegionMetrics metrics = new RegionMetrics();
metrics.setEcosystemHealth(95);
metrics.applyDelta(20, 0, 0, 0, 0, 0, 0);
assertEquals(100, metrics.getEcosystemHealth(), 0.001);
}
@Test
@DisplayName("applyDelta() clamps results below min")
void applyDeltaClampsBelowMin() {
RegionMetrics metrics = new RegionMetrics();
metrics.setPollutionScore(5);
metrics.applyDelta(-10, 0, 0, 0, 0, 0, 0);
assertEquals(0, metrics.getEcosystemHealth(), 0.001);
}
@Test
@DisplayName("applyDelta() returns this for chaining")
void applyDeltaReturnsThis() {
RegionMetrics metrics = new RegionMetrics();
RegionMetrics result = metrics.applyDelta(0, 0, 0, 0, 0, 0, 0);
assertTrue(result == metrics);
}
@Test
@DisplayName("applyEcosystemHealthDelta() modifies only ecosystem health")
void applySingleDelta() {
RegionMetrics metrics = new RegionMetrics();
metrics.setEcosystemHealth(50);
metrics.setPollutionScore(30);
metrics.applyEcosystemHealthDelta(10);
assertEquals(60, metrics.getEcosystemHealth(), 0.001);
assertEquals(30, metrics.getPollutionScore(), 0.001);
}
@Test
@DisplayName("applyPollutionScoreDelta() clamps correctly")
void applySingleDeltaClamps() {
RegionMetrics metrics = new RegionMetrics();
metrics.setPollutionScore(95);
metrics.applyPollutionScoreDelta(20);
assertEquals(100, metrics.getPollutionScore(), 0.001);
}
@Test
@DisplayName("applySoilQualityDelta() clamps below min")
void applySingleDeltaClampsBelow() {
RegionMetrics metrics = new RegionMetrics();
metrics.setSoilQuality(5);
metrics.applySoilQualityDelta(-10);
assertEquals(0, metrics.getSoilQuality(), 0.001);
}
@Test
@DisplayName("applyWaterQualityDelta() works correctly")
void applyWaterQualityDelta() {
RegionMetrics metrics = new RegionMetrics();
metrics.setWaterQuality(50);
metrics.applyWaterQualityDelta(25);
assertEquals(75, metrics.getWaterQuality(), 0.001);
}
@Test
@DisplayName("applyVegetationPressureDelta() works correctly")
void applyVegetationPressureDelta() {
RegionMetrics metrics = new RegionMetrics();
metrics.setVegetationPressure(30);
metrics.applyVegetationPressureDelta(-10);
assertEquals(20, metrics.getVegetationPressure(), 0.001);
}
@Test
@DisplayName("applyResourceDepletionDelta() works correctly")
void applyResourceDepletionDelta() {
RegionMetrics metrics = new RegionMetrics();
metrics.setResourceDepletion(40);
metrics.applyResourceDepletionDelta(60);
assertEquals(100, metrics.getResourceDepletion(), 0.001);
}
@Test
@DisplayName("applyRecoveryPressureDelta() works correctly")
void applyRecoveryPressureDelta() {
RegionMetrics metrics = new RegionMetrics();
metrics.setRecoveryPressure(25);
metrics.applyRecoveryPressureDelta(-15);
assertEquals(10, metrics.getRecoveryPressure(), 0.001);
}
@Test
@DisplayName("individual apply methods return this for chaining")
void individualApplyMethodsReturnThis() {
RegionMetrics metrics = new RegionMetrics();
assertTrue(metrics.applyEcosystemHealthDelta(5) == metrics);
assertTrue(metrics.applyPollutionScoreDelta(5) == metrics);
assertTrue(metrics.applySoilQualityDelta(5) == metrics);
assertTrue(metrics.applyWaterQualityDelta(5) == metrics);
assertTrue(metrics.applyVegetationPressureDelta(5) == metrics);
assertTrue(metrics.applyResourceDepletionDelta(5) == metrics);
assertTrue(metrics.applyRecoveryPressureDelta(5) == metrics);
}
}
// ------------------------------------------------------------------
// Constants Tests
// ------------------------------------------------------------------
@Nested
@DisplayName("Constants")
class ConstantsTests {
@Test
@DisplayName("MIN_VALUE is 0")
void minValue() {
assertEquals(0, RegionMetrics.MIN_VALUE);
}
@Test
@DisplayName("MAX_VALUE is 100")
void maxValue() {
assertEquals(100, RegionMetrics.MAX_VALUE);
}
}
// ------------------------------------------------------------------
// toString Tests
// ------------------------------------------------------------------
@Nested
@DisplayName("toString")
class ToStringTests {
@Test
@DisplayName("toString() includes all field names and values")
void toStringIncludesAllFields() {
RegionMetrics metrics = new RegionMetrics();
metrics.setEcosystemHealth(60);
metrics.setPollutionScore(10);
String str = metrics.toString();
assertTrue(str.contains("ecosystemHealth"));
assertTrue(str.contains("pollutionScore"));
assertTrue(str.contains("soilQuality"));
assertTrue(str.contains("waterQuality"));
assertTrue(str.contains("vegetationPressure"));
assertTrue(str.contains("resourceDepletion"));
assertTrue(str.contains("recoveryPressure"));
}
@Test
@DisplayName("toString() starts with class name")
void toStringStartsWithClassName() {
RegionMetrics metrics = new RegionMetrics();
assertTrue(metrics.toString().startsWith("RegionMetrics{"));
}
}
}