Initial commit
This commit is contained in:
@@ -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"
|
||||
@@ -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{"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user