From 21371ee6e691124746f29297b6caea7e5cc57509 Mon Sep 17 00:00:00 2001 From: Christian Schindler Date: Sat, 15 Aug 2026 08:50:35 +0200 Subject: [PATCH] feat(engine): implement deterministic creature systems --- engine/build.gradle.kts | 33 ++++ .../engine/model/BaseStats.java | 39 ++++ .../engine/model/BattleEvent.java | 27 +++ .../engine/model/BattleParticipant.java | 44 +++++ .../engine/model/BattleState.java | 64 +++++++ .../engine/model/CaptureContext.java | 40 +++++ .../projectcapture/engine/model/Checks.java | 75 ++++++++ .../engine/model/CreatureEgg.java | 33 ++++ .../engine/model/CreatureInstance.java | 57 ++++++ .../engine/model/DuelRules.java | 21 +++ .../engine/model/DuelSession.java | 72 ++++++++ .../engine/model/ElementType.java | 19 ++ .../engine/model/EvolutionRule.java | 21 +++ .../projectcapture/engine/model/Genome.java | 62 +++++++ .../engine/model/HabitatState.java | 70 ++++++++ .../engine/model/LineageContribution.java | 8 + .../engine/model/MoveDefinition.java | 36 ++++ .../projectcapture/engine/model/Party.java | 38 ++++ .../engine/model/PlayerInventory.java | 30 ++++ .../engine/model/SpawnRule.java | 35 ++++ .../engine/model/SpeciesDefinition.java | 67 +++++++ .../engine/model/TradeOffer.java | 26 +++ .../engine/model/TradeTransaction.java | 59 ++++++ .../engine/service/BattleService.java | 169 ++++++++++++++++++ .../engine/service/BreedingService.java | 142 +++++++++++++++ .../engine/service/CaptureService.java | 50 ++++++ .../engine/service/DuelService.java | 120 +++++++++++++ .../engine/service/EvolutionService.java | 59 ++++++ .../engine/service/ExperienceService.java | 76 ++++++++ .../engine/service/GeneticsService.java | 86 +++++++++ .../engine/service/GenomeAnalysisService.java | 26 +++ .../engine/service/PartyService.java | 58 ++++++ .../engine/service/SpawnService.java | 69 +++++++ .../service/StaleRevisionException.java | 9 + .../engine/service/TradeService.java | 167 +++++++++++++++++ .../engine/BattleServiceTest.java | 96 ++++++++++ .../engine/CaptureAndSpawnServiceTest.java | 72 ++++++++ .../engine/DuelServiceTest.java | 76 ++++++++ .../engine/GeneticsAndBreedingTest.java | 123 +++++++++++++ .../engine/ModelInvariantTest.java | 105 +++++++++++ .../engine/ProgressionEvolutionPartyTest.java | 99 ++++++++++ .../projectcapture/engine/TestFixtures.java | 78 ++++++++ .../engine/TradeServiceTest.java | 101 +++++++++++ 43 files changed, 2757 insertions(+) create mode 100644 engine/build.gradle.kts create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/BaseStats.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/BattleEvent.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/BattleParticipant.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/BattleState.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/CaptureContext.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/Checks.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/CreatureEgg.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/CreatureInstance.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/DuelRules.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/DuelSession.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/ElementType.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/EvolutionRule.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/Genome.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/HabitatState.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/LineageContribution.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/MoveDefinition.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/Party.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/PlayerInventory.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/SpawnRule.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/SpeciesDefinition.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/TradeOffer.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/model/TradeTransaction.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/BattleService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/BreedingService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/CaptureService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/DuelService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/EvolutionService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/ExperienceService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/GeneticsService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/GenomeAnalysisService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/PartyService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/SpawnService.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/StaleRevisionException.java create mode 100644 engine/src/main/java/dev/projectcapture/engine/service/TradeService.java create mode 100644 engine/src/test/java/dev/projectcapture/engine/BattleServiceTest.java create mode 100644 engine/src/test/java/dev/projectcapture/engine/CaptureAndSpawnServiceTest.java create mode 100644 engine/src/test/java/dev/projectcapture/engine/DuelServiceTest.java create mode 100644 engine/src/test/java/dev/projectcapture/engine/GeneticsAndBreedingTest.java create mode 100644 engine/src/test/java/dev/projectcapture/engine/ModelInvariantTest.java create mode 100644 engine/src/test/java/dev/projectcapture/engine/ProgressionEvolutionPartyTest.java create mode 100644 engine/src/test/java/dev/projectcapture/engine/TestFixtures.java create mode 100644 engine/src/test/java/dev/projectcapture/engine/TradeServiceTest.java diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts new file mode 100644 index 0000000..3a2b075 --- /dev/null +++ b/engine/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + `java-library` +} + +group = "dev.projectcapture" +version = "0.1.0-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } + withSourcesJar() +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation(platform("org.junit:junit-bom:5.11.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.release = 25 + options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/BaseStats.java b/engine/src/main/java/dev/projectcapture/engine/model/BaseStats.java new file mode 100644 index 0000000..e6cb1fe --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/BaseStats.java @@ -0,0 +1,39 @@ +package dev.projectcapture.engine.model; + +public record BaseStats( + int health, + int attack, + int defence, + int specialAttack, + int specialDefence, + int speed +) { + public BaseStats { + Checks.range(health, 1, 255, "health"); + Checks.range(attack, 1, 255, "attack"); + Checks.range(defence, 1, 255, "defence"); + Checks.range(specialAttack, 1, 255, "specialAttack"); + Checks.range(specialDefence, 1, 255, "specialDefence"); + Checks.range(speed, 1, 255, "speed"); + } + + public int healthAtLevel(int level) { + Checks.range(level, 1, 100, "level"); + return ((2 * health * level) / 100) + level + 10; + } + + public int attackAtLevel(int level) { + Checks.range(level, 1, 100, "level"); + return ((2 * attack * level) / 100) + 5; + } + + public int defenceAtLevel(int level) { + Checks.range(level, 1, 100, "level"); + return ((2 * defence * level) / 100) + 5; + } + + public int speedAtLevel(int level) { + Checks.range(level, 1, 100, "level"); + return ((2 * speed * level) / 100) + 5; + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/BattleEvent.java b/engine/src/main/java/dev/projectcapture/engine/model/BattleEvent.java new file mode 100644 index 0000000..7026655 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/BattleEvent.java @@ -0,0 +1,27 @@ +package dev.projectcapture.engine.model; + +import java.util.UUID; + +public record BattleEvent( + UUID actorCreatureId, + UUID targetCreatureId, + String moveId, + boolean automaticBasicAttack, + boolean hit, + int damage, + int targetHealthRemaining +) { + public BattleEvent { + actorCreatureId = Checks.notNull(actorCreatureId, "actorCreatureId"); + targetCreatureId = Checks.notNull(targetCreatureId, "targetCreatureId"); + if (actorCreatureId.equals(targetCreatureId)) { + throw new IllegalArgumentException("battle actor and target must differ"); + } + moveId = Checks.namespacedId(moveId, "moveId"); + Checks.range(damage, 0, 100_000, "damage"); + Checks.range(targetHealthRemaining, 0, 100_000, "targetHealthRemaining"); + if (!hit && damage != 0) { + throw new IllegalArgumentException("missed attacks cannot deal damage"); + } + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/BattleParticipant.java b/engine/src/main/java/dev/projectcapture/engine/model/BattleParticipant.java new file mode 100644 index 0000000..c468575 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/BattleParticipant.java @@ -0,0 +1,44 @@ +package dev.projectcapture.engine.model; + +import java.util.HashSet; +import java.util.List; +import java.util.UUID; + +public record BattleParticipant( + UUID creatureId, + UUID ownerId, + String speciesId, + int level, + int currentHealth, + int maximumHealth, + int attack, + int defence, + int speed, + String basicMoveId, + List commandMoveIds +) { + public BattleParticipant { + creatureId = Checks.notNull(creatureId, "creatureId"); + ownerId = Checks.notNull(ownerId, "ownerId"); + speciesId = Checks.namespacedId(speciesId, "speciesId"); + Checks.range(level, 1, 100, "level"); + Checks.range(maximumHealth, 1, 100_000, "maximumHealth"); + Checks.range(currentHealth, 0, maximumHealth, "currentHealth"); + Checks.range(attack, 1, 100_000, "attack"); + Checks.range(defence, 1, 100_000, "defence"); + Checks.range(speed, 1, 100_000, "speed"); + basicMoveId = Checks.namespacedId(basicMoveId, "basicMoveId"); + Checks.noNulls(commandMoveIds, "commandMoveIds"); + commandMoveIds = commandMoveIds.stream() + .map(move -> Checks.namespacedId(move, "commandMoveId")) + .toList(); + if (commandMoveIds.size() != 3 || new HashSet<>(commandMoveIds).size() != 3 + || commandMoveIds.contains(basicMoveId)) { + throw new IllegalArgumentException("battle participant requires exactly three unique command moves"); + } + } + + public boolean isFainted() { + return currentHealth == 0; + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/BattleState.java b/engine/src/main/java/dev/projectcapture/engine/model/BattleState.java new file mode 100644 index 0000000..f94460e --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/BattleState.java @@ -0,0 +1,64 @@ +package dev.projectcapture.engine.model; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public record BattleState( + int schemaVersion, + UUID id, + BattleParticipant participantOne, + BattleParticipant participantTwo, + long turn, + Status status, + Optional winnerCreatureId, + List lastTurnEvents +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + + public enum Status { + ACTIVE, + COMPLETED, + CANCELLED + } + + public BattleState { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported battle schema version: " + schemaVersion); + } + id = Checks.notNull(id, "id"); + participantOne = Checks.notNull(participantOne, "participantOne"); + participantTwo = Checks.notNull(participantTwo, "participantTwo"); + if (participantOne.creatureId().equals(participantTwo.creatureId())) { + throw new IllegalArgumentException("battle participants must be different creatures"); + } + Checks.nonNegative(turn, "turn"); + status = Checks.notNull(status, "status"); + winnerCreatureId = Checks.optional(winnerCreatureId, "winnerCreatureId"); + var creatureIds = Set.of(participantOne.creatureId(), participantTwo.creatureId()); + if (winnerCreatureId.isPresent() && !creatureIds.contains(winnerCreatureId.orElseThrow())) { + throw new IllegalArgumentException("winner must participate in the battle"); + } + if (status == Status.COMPLETED && winnerCreatureId.isEmpty()) { + throw new IllegalArgumentException("completed battle requires a winner"); + } + if (status != Status.COMPLETED && winnerCreatureId.isPresent()) { + throw new IllegalArgumentException("only completed battle may have a winner"); + } + if (status == Status.ACTIVE && (participantOne.isFainted() || participantTwo.isFainted())) { + throw new IllegalArgumentException("active battle cannot contain a fainted participant"); + } + if (status == Status.COMPLETED) { + boolean exactlyOneFainted = participantOne.isFainted() ^ participantTwo.isFainted(); + UUID survivingCreatureId = participantOne.isFainted() + ? participantTwo.creatureId() + : participantOne.creatureId(); + if (!exactlyOneFainted || !winnerCreatureId.orElseThrow().equals(survivingCreatureId)) { + throw new IllegalArgumentException("completed battle winner must be the sole surviving creature"); + } + } + Checks.noNulls(lastTurnEvents, "lastTurnEvents"); + lastTurnEvents = List.copyOf(lastTurnEvents); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/CaptureContext.java b/engine/src/main/java/dev/projectcapture/engine/model/CaptureContext.java new file mode 100644 index 0000000..9e87228 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/CaptureContext.java @@ -0,0 +1,40 @@ +package dev.projectcapture.engine.model; + +public record CaptureContext( + int captureRate, + SpeciesDefinition.Rarity rarity, + int level, + int currentHealth, + int maximumHealth, + double deviceMultiplier, + StatusCondition statusCondition +) { + public enum StatusCondition { + NONE(1.0), + POISONED(1.15), + BURNED(1.15), + PARALYSED(1.35), + ASLEEP(1.75), + FROZEN(1.75); + + private final double captureMultiplier; + + StatusCondition(double captureMultiplier) { + this.captureMultiplier = captureMultiplier; + } + + public double captureMultiplier() { + return captureMultiplier; + } + } + + public CaptureContext { + Checks.range(captureRate, 1, 255, "captureRate"); + rarity = Checks.notNull(rarity, "rarity"); + Checks.range(level, 1, 100, "level"); + Checks.range(maximumHealth, 1, 100_000, "maximumHealth"); + Checks.range(currentHealth, 1, maximumHealth, "currentHealth"); + Checks.range(deviceMultiplier, 0.01, 100.0, "deviceMultiplier"); + statusCondition = Checks.notNull(statusCondition, "statusCondition"); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/Checks.java b/engine/src/main/java/dev/projectcapture/engine/model/Checks.java new file mode 100644 index 0000000..357f4d3 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/Checks.java @@ -0,0 +1,75 @@ +package dev.projectcapture.engine.model; + +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +final class Checks { + private static final Pattern NAMESPACED_ID = + Pattern.compile("[a-z0-9][a-z0-9_.-]*:[a-z0-9][a-z0-9_./-]*"); + + private Checks() { + } + + static T notNull(T value, String name) { + return Objects.requireNonNull(value, name + " must not be null"); + } + + static String text(String value, String name) { + notNull(value, name); + var result = value.strip(); + if (result.isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return result; + } + + static String namespacedId(String value, String name) { + var result = text(value, name); + if (!NAMESPACED_ID.matcher(result).matches()) { + throw new IllegalArgumentException(name + " must be a lower-case namespaced id: " + result); + } + return result; + } + + static int range(int value, int min, int max, String name) { + if (value < min || value > max) { + throw new IllegalArgumentException(name + " must be between " + min + " and " + max); + } + return value; + } + + static long nonNegative(long value, String name) { + if (value < 0) { + throw new IllegalArgumentException(name + " must not be negative"); + } + return value; + } + + static double range(double value, double min, double max, String name) { + if (!Double.isFinite(value) || value < min || value > max) { + throw new IllegalArgumentException(name + " must be between " + min + " and " + max); + } + return value; + } + + static void noNulls(Collection values, String name) { + notNull(values, name); + if (values.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException(name + " must not contain null values"); + } + } + + static void noNulls(Map values, String name) { + notNull(values, name); + if (values.entrySet().stream().anyMatch(entry -> entry.getKey() == null || entry.getValue() == null)) { + throw new IllegalArgumentException(name + " must not contain null keys or values"); + } + } + + static Optional optional(Optional value, String name) { + return notNull(value, name); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/CreatureEgg.java b/engine/src/main/java/dev/projectcapture/engine/model/CreatureEgg.java new file mode 100644 index 0000000..3093889 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/CreatureEgg.java @@ -0,0 +1,33 @@ +package dev.projectcapture.engine.model; + +import java.time.Instant; +import java.util.UUID; + +public record CreatureEgg( + int schemaVersion, + UUID id, + UUID ownerId, + String speciesId, + UUID parentOneId, + UUID parentTwoId, + Genome genome, + Instant createdAt +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + + public CreatureEgg { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported egg schema version: " + schemaVersion); + } + id = Checks.notNull(id, "id"); + ownerId = Checks.notNull(ownerId, "ownerId"); + speciesId = Checks.namespacedId(speciesId, "speciesId"); + parentOneId = Checks.notNull(parentOneId, "parentOneId"); + parentTwoId = Checks.notNull(parentTwoId, "parentTwoId"); + if (parentOneId.equals(parentTwoId)) { + throw new IllegalArgumentException("egg parents must be different creatures"); + } + genome = Checks.notNull(genome, "genome"); + createdAt = Checks.notNull(createdAt, "createdAt"); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/CreatureInstance.java b/engine/src/main/java/dev/projectcapture/engine/model/CreatureInstance.java new file mode 100644 index 0000000..bbc1bae --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/CreatureInstance.java @@ -0,0 +1,57 @@ +package dev.projectcapture.engine.model; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public record CreatureInstance( + int schemaVersion, + UUID id, + String speciesId, + Optional nickname, + Optional ownerId, + int level, + long experience, + int currentHealth, + Genome genome, + String basicMoveId, + List commandMoveIds, + Instant createdAt, + long revision +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + + public CreatureInstance { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported creature schema version: " + schemaVersion); + } + id = Checks.notNull(id, "id"); + speciesId = Checks.namespacedId(speciesId, "speciesId"); + nickname = Checks.optional(nickname, "nickname").map(name -> Checks.text(name, "nickname")); + if (nickname.map(String::length).orElse(0) > 32) { + throw new IllegalArgumentException("nickname must contain at most 32 characters"); + } + ownerId = Checks.optional(ownerId, "ownerId"); + Checks.range(level, 1, 100, "level"); + Checks.nonNegative(experience, "experience"); + Checks.range(currentHealth, 0, 100_000, "currentHealth"); + genome = Checks.notNull(genome, "genome"); + basicMoveId = Checks.namespacedId(basicMoveId, "basicMoveId"); + Checks.noNulls(commandMoveIds, "commandMoveIds"); + commandMoveIds = commandMoveIds.stream() + .map(move -> Checks.namespacedId(move, "commandMoveId")) + .toList(); + if (commandMoveIds.size() != 3 || new HashSet<>(commandMoveIds).size() != 3 + || commandMoveIds.contains(basicMoveId)) { + throw new IllegalArgumentException("commandMoveIds must contain exactly three unique non-basic moves"); + } + createdAt = Checks.notNull(createdAt, "createdAt"); + Checks.nonNegative(revision, "revision"); + } + + public boolean isFainted() { + return currentHealth == 0; + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/DuelRules.java b/engine/src/main/java/dev/projectcapture/engine/model/DuelRules.java new file mode 100644 index 0000000..9bf8438 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/DuelRules.java @@ -0,0 +1,21 @@ +package dev.projectcapture.engine.model; + +public record DuelRules( + boolean playerWeaponsDisabled, + boolean naturalLevels, + double zoneRadius +) { + public DuelRules { + if (!playerWeaponsDisabled) { + throw new IllegalArgumentException("player weapons must be disabled in creature duels"); + } + if (!naturalLevels) { + throw new IllegalArgumentException("creature duels must use natural levels"); + } + Checks.range(zoneRadius, 4.0, 256.0, "zoneRadius"); + } + + public static DuelRules defaults() { + return new DuelRules(true, true, 32.0); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/DuelSession.java b/engine/src/main/java/dev/projectcapture/engine/model/DuelSession.java new file mode 100644 index 0000000..4cf0b6b --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/DuelSession.java @@ -0,0 +1,72 @@ +package dev.projectcapture.engine.model; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public record DuelSession( + int schemaVersion, + UUID id, + UUID challengerId, + UUID opponentId, + DuelRules rules, + Set acceptedBy, + Status status, + Optional winnerId, + Optional completionReason, + long revision +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + + public enum Status { + REQUESTED, + ACTIVE, + COMPLETED, + CANCELLED + } + + public DuelSession { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported duel schema version: " + schemaVersion); + } + id = Checks.notNull(id, "id"); + challengerId = Checks.notNull(challengerId, "challengerId"); + opponentId = Checks.notNull(opponentId, "opponentId"); + if (challengerId.equals(opponentId)) { + throw new IllegalArgumentException("duel players must be different"); + } + rules = Checks.notNull(rules, "rules"); + Checks.noNulls(acceptedBy, "acceptedBy"); + acceptedBy = Set.copyOf(acceptedBy); + var participants = Set.of(challengerId, opponentId); + if (!participants.containsAll(acceptedBy)) { + throw new IllegalArgumentException("only duel participants may accept"); + } + status = Checks.notNull(status, "status"); + winnerId = Checks.optional(winnerId, "winnerId"); + completionReason = Checks.optional(completionReason, "completionReason") + .map(reason -> Checks.text(reason, "completionReason")); + if (winnerId.isPresent() && !participants.contains(winnerId.orElseThrow())) { + throw new IllegalArgumentException("winner must be a duel participant"); + } + if (status == Status.ACTIVE && !acceptedBy.equals(participants)) { + throw new IllegalArgumentException("active duel requires both acceptances"); + } + if (status == Status.REQUESTED && acceptedBy.equals(participants)) { + throw new IllegalArgumentException("fully accepted duel must be active"); + } + if (status == Status.COMPLETED && (winnerId.isEmpty() || completionReason.isEmpty())) { + throw new IllegalArgumentException("completed duel requires winner and reason"); + } + if (status != Status.COMPLETED && winnerId.isPresent()) { + throw new IllegalArgumentException("only completed duel may have a winner"); + } + if (status == Status.CANCELLED && completionReason.isEmpty()) { + throw new IllegalArgumentException("cancelled duel requires a reason"); + } + if ((status == Status.REQUESTED || status == Status.ACTIVE) && completionReason.isPresent()) { + throw new IllegalArgumentException("non-terminal duel cannot have a completion reason"); + } + Checks.nonNegative(revision, "revision"); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/ElementType.java b/engine/src/main/java/dev/projectcapture/engine/model/ElementType.java new file mode 100644 index 0000000..fd44c86 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/ElementType.java @@ -0,0 +1,19 @@ +package dev.projectcapture.engine.model; + +public enum ElementType { + NORMAL, + GRASS, + POISON, + FIRE, + FLYING, + WATER, + ELECTRIC, + PSYCHIC, + GROUND, + ROCK, + ICE, + BUG, + FIGHTING, + GHOST, + DRAGON +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/EvolutionRule.java b/engine/src/main/java/dev/projectcapture/engine/model/EvolutionRule.java new file mode 100644 index 0000000..3d896e3 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/EvolutionRule.java @@ -0,0 +1,21 @@ +package dev.projectcapture.engine.model; + +import java.util.Optional; + +public record EvolutionRule( + String targetSpeciesId, + int minimumLevel, + Optional requiredItemId +) { + public EvolutionRule { + targetSpeciesId = Checks.namespacedId(targetSpeciesId, "targetSpeciesId"); + Checks.range(minimumLevel, 1, 100, "minimumLevel"); + requiredItemId = Checks.optional(requiredItemId, "requiredItemId") + .map(id -> Checks.namespacedId(id, "requiredItemId")); + } + + public boolean isEligible(int level, java.util.Set availableItemIds) { + Checks.notNull(availableItemIds, "availableItemIds"); + return level >= minimumLevel && requiredItemId.map(availableItemIds::contains).orElse(true); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/Genome.java b/engine/src/main/java/dev/projectcapture/engine/model/Genome.java new file mode 100644 index 0000000..f9a088a --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/Genome.java @@ -0,0 +1,62 @@ +package dev.projectcapture.engine.model; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public record Genome( + int schemaVersion, + long seed, + Map traits, + List lineages, + Set revealedTraits +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + public static final int TOTAL_BASIS_POINTS = 10_000; + public static final int MAX_LINEAGES = 4; + + public Genome { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported genome schema version: " + schemaVersion); + } + Checks.noNulls(traits, "traits"); + var copiedTraits = new LinkedHashMap(); + traits.forEach((key, value) -> copiedTraits.put( + Checks.text(key, "trait name"), Checks.range(value, 0, 31, "trait value"))); + traits = Map.copyOf(copiedTraits); + if (traits.isEmpty()) { + throw new IllegalArgumentException("traits must not be empty"); + } + + Checks.noNulls(lineages, "lineages"); + lineages = List.copyOf(lineages); + if (lineages.isEmpty() || lineages.size() > MAX_LINEAGES) { + throw new IllegalArgumentException("lineages must contain between one and four entries"); + } + if (new HashSet<>(lineages.stream().map(LineageContribution::speciesId).toList()).size() + != lineages.size()) { + throw new IllegalArgumentException("lineage species must be unique"); + } + int total = lineages.stream().mapToInt(LineageContribution::basisPoints).sum(); + if (total != TOTAL_BASIS_POINTS) { + throw new IllegalArgumentException("lineage contribution must total 10000 basis points"); + } + + Checks.noNulls(revealedTraits, "revealedTraits"); + revealedTraits = Set.copyOf(revealedTraits); + if (!traits.keySet().containsAll(revealedTraits)) { + throw new IllegalArgumentException("revealedTraits must be present in traits"); + } + } + + public static Genome pure(long seed, String speciesId, Map traits) { + return new Genome(CURRENT_SCHEMA_VERSION, seed, traits, + List.of(new LineageContribution(speciesId, TOTAL_BASIS_POINTS)), Set.of()); + } + + public boolean fullyAnalysed() { + return revealedTraits.size() == traits.size(); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/HabitatState.java b/engine/src/main/java/dev/projectcapture/engine/model/HabitatState.java new file mode 100644 index 0000000..ad1c5e5 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/HabitatState.java @@ -0,0 +1,70 @@ +package dev.projectcapture.engine.model; + +import java.util.Optional; +import java.util.UUID; + +public record HabitatState( + int schemaVersion, + UUID id, + UUID ownerId, + Status status, + Optional parentOneId, + Optional parentTwoId, + long accumulatedActiveMillis, + long requiredActiveMillis, + long revision +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + + public enum Status { + EMPTY, + BREEDING, + EGG_READY, + COLLECTED, + CANCELLED + } + + public HabitatState { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported habitat schema version: " + schemaVersion); + } + id = Checks.notNull(id, "id"); + ownerId = Checks.notNull(ownerId, "ownerId"); + status = Checks.notNull(status, "status"); + parentOneId = Checks.optional(parentOneId, "parentOneId"); + parentTwoId = Checks.optional(parentTwoId, "parentTwoId"); + Checks.nonNegative(accumulatedActiveMillis, "accumulatedActiveMillis"); + Checks.nonNegative(requiredActiveMillis, "requiredActiveMillis"); + Checks.nonNegative(revision, "revision"); + + boolean hasBothParents = parentOneId.isPresent() && parentTwoId.isPresent(); + if (parentOneId.equals(parentTwoId) && parentOneId.isPresent()) { + throw new IllegalArgumentException("parents must be different creatures"); + } + if (status == Status.EMPTY && (parentOneId.isPresent() || parentTwoId.isPresent() + || accumulatedActiveMillis != 0 || requiredActiveMillis != 0)) { + throw new IllegalArgumentException("empty habitat cannot contain breeding progress"); + } + if (status != Status.EMPTY && !hasBothParents) { + throw new IllegalArgumentException("non-empty habitat must contain two parents"); + } + if (status != Status.EMPTY && requiredActiveMillis == 0) { + throw new IllegalArgumentException("active habitat must have a positive required duration"); + } + if (accumulatedActiveMillis > requiredActiveMillis) { + throw new IllegalArgumentException("breeding progress cannot exceed required duration"); + } + if (status == Status.BREEDING && accumulatedActiveMillis >= requiredActiveMillis) { + throw new IllegalArgumentException("completed progress must use EGG_READY status"); + } + if ((status == Status.EGG_READY || status == Status.COLLECTED) + && accumulatedActiveMillis != requiredActiveMillis) { + throw new IllegalArgumentException("ready or collected habitat must have complete progress"); + } + } + + public static HabitatState empty(UUID id, UUID ownerId) { + return new HabitatState(CURRENT_SCHEMA_VERSION, id, ownerId, Status.EMPTY, + Optional.empty(), Optional.empty(), 0, 0, 0); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/LineageContribution.java b/engine/src/main/java/dev/projectcapture/engine/model/LineageContribution.java new file mode 100644 index 0000000..414e150 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/LineageContribution.java @@ -0,0 +1,8 @@ +package dev.projectcapture.engine.model; + +public record LineageContribution(String speciesId, int basisPoints) { + public LineageContribution { + speciesId = Checks.namespacedId(speciesId, "speciesId"); + Checks.range(basisPoints, 1, Genome.TOTAL_BASIS_POINTS, "basisPoints"); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/MoveDefinition.java b/engine/src/main/java/dev/projectcapture/engine/model/MoveDefinition.java new file mode 100644 index 0000000..ff3df04 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/MoveDefinition.java @@ -0,0 +1,36 @@ +package dev.projectcapture.engine.model; + +public record MoveDefinition( + int schemaVersion, + String id, + String displayName, + ElementType type, + Category category, + int power, + double accuracy, + int priority +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + + public enum Category { + PHYSICAL, + SPECIAL, + STATUS + } + + public MoveDefinition { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported move schema version: " + schemaVersion); + } + id = Checks.namespacedId(id, "id"); + displayName = Checks.text(displayName, "displayName"); + type = Checks.notNull(type, "type"); + category = Checks.notNull(category, "category"); + Checks.range(power, 0, 250, "power"); + Checks.range(accuracy, 0.0, 1.0, "accuracy"); + Checks.range(priority, -7, 7, "priority"); + if (category != Category.STATUS && power == 0) { + throw new IllegalArgumentException("Damaging moves must have positive power"); + } + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/Party.java b/engine/src/main/java/dev/projectcapture/engine/model/Party.java new file mode 100644 index 0000000..d45f435 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/Party.java @@ -0,0 +1,38 @@ +package dev.projectcapture.engine.model; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public record Party( + int schemaVersion, + UUID ownerId, + List creatureIds, + Optional activeCompanionId, + long revision +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + public static final int MAX_SIZE = 6; + + public Party { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported party schema version: " + schemaVersion); + } + ownerId = Checks.notNull(ownerId, "ownerId"); + Checks.noNulls(creatureIds, "creatureIds"); + creatureIds = List.copyOf(creatureIds); + if (creatureIds.size() > MAX_SIZE || new HashSet<>(creatureIds).size() != creatureIds.size()) { + throw new IllegalArgumentException("party must contain at most six unique creatures"); + } + activeCompanionId = Checks.optional(activeCompanionId, "activeCompanionId"); + if (activeCompanionId.isPresent() && !creatureIds.contains(activeCompanionId.orElseThrow())) { + throw new IllegalArgumentException("active companion must be part of the party"); + } + Checks.nonNegative(revision, "revision"); + } + + public static Party empty(UUID ownerId) { + return new Party(CURRENT_SCHEMA_VERSION, ownerId, List.of(), Optional.empty(), 0); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/PlayerInventory.java b/engine/src/main/java/dev/projectcapture/engine/model/PlayerInventory.java new file mode 100644 index 0000000..4c0b2f4 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/PlayerInventory.java @@ -0,0 +1,30 @@ +package dev.projectcapture.engine.model; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +public record PlayerInventory( + int schemaVersion, + UUID ownerId, + Set creatureIds, + Map items, + long revision +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + + public PlayerInventory { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported inventory schema version: " + schemaVersion); + } + ownerId = Checks.notNull(ownerId, "ownerId"); + Checks.noNulls(creatureIds, "creatureIds"); + creatureIds = Set.copyOf(creatureIds); + Checks.noNulls(items, "items"); + items = items.entrySet().stream().collect(Collectors.toUnmodifiableMap( + entry -> Checks.namespacedId(entry.getKey(), "itemId"), + entry -> Checks.range(entry.getValue(), 1, 1_000_000, "item quantity"))); + Checks.nonNegative(revision, "revision"); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/SpawnRule.java b/engine/src/main/java/dev/projectcapture/engine/model/SpawnRule.java new file mode 100644 index 0000000..906f417 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/SpawnRule.java @@ -0,0 +1,35 @@ +package dev.projectcapture.engine.model; + +import java.util.Set; + +public record SpawnRule( + Set biomeTags, + Set times, + int weight, + int minimumLevel, + int maximumLevel +) { + public enum TimeOfDay { + DAWN, + DAY, + DUSK, + NIGHT + } + + public SpawnRule { + Checks.noNulls(biomeTags, "biomeTags"); + biomeTags = biomeTags.stream().map(tag -> Checks.namespacedId(tag, "biomeTag")) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (biomeTags.isEmpty()) { + throw new IllegalArgumentException("biomeTags must not be empty"); + } + Checks.noNulls(times, "times"); + times = Set.copyOf(times); + if (times.isEmpty()) { + throw new IllegalArgumentException("times must not be empty"); + } + Checks.range(weight, 1, 1_000_000, "weight"); + Checks.range(minimumLevel, 1, 100, "minimumLevel"); + Checks.range(maximumLevel, minimumLevel, 100, "maximumLevel"); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/SpeciesDefinition.java b/engine/src/main/java/dev/projectcapture/engine/model/SpeciesDefinition.java new file mode 100644 index 0000000..b4b510a --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/SpeciesDefinition.java @@ -0,0 +1,67 @@ +package dev.projectcapture.engine.model; + +import java.util.HashSet; +import java.util.List; + +public record SpeciesDefinition( + int schemaVersion, + String id, + String displayName, + List types, + Rarity rarity, + BaseStats baseStats, + int captureRate, + String basicMoveId, + List commandMoveIds, + List evolutions, + List spawnRules +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + + public enum Rarity { + COMMON(1.0), + UNCOMMON(0.85), + RARE(0.65), + EPIC(0.45), + LEGENDARY(0.20); + + private final double captureMultiplier; + + Rarity(double captureMultiplier) { + this.captureMultiplier = captureMultiplier; + } + + public double captureMultiplier() { + return captureMultiplier; + } + } + + public SpeciesDefinition { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported species schema version: " + schemaVersion); + } + id = Checks.namespacedId(id, "id"); + displayName = Checks.text(displayName, "displayName"); + Checks.noNulls(types, "types"); + types = List.copyOf(types); + if (types.isEmpty() || types.size() > 2 || new HashSet<>(types).size() != types.size()) { + throw new IllegalArgumentException("types must contain one or two unique values"); + } + rarity = Checks.notNull(rarity, "rarity"); + baseStats = Checks.notNull(baseStats, "baseStats"); + Checks.range(captureRate, 1, 255, "captureRate"); + basicMoveId = Checks.namespacedId(basicMoveId, "basicMoveId"); + Checks.noNulls(commandMoveIds, "commandMoveIds"); + commandMoveIds = commandMoveIds.stream() + .map(move -> Checks.namespacedId(move, "commandMoveId")) + .toList(); + if (commandMoveIds.size() != 3 || new HashSet<>(commandMoveIds).size() != 3 + || commandMoveIds.contains(basicMoveId)) { + throw new IllegalArgumentException("commandMoveIds must contain exactly three unique non-basic moves"); + } + Checks.noNulls(evolutions, "evolutions"); + evolutions = List.copyOf(evolutions); + Checks.noNulls(spawnRules, "spawnRules"); + spawnRules = List.copyOf(spawnRules); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/TradeOffer.java b/engine/src/main/java/dev/projectcapture/engine/model/TradeOffer.java new file mode 100644 index 0000000..4ef6dd0 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/TradeOffer.java @@ -0,0 +1,26 @@ +package dev.projectcapture.engine.model; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +public record TradeOffer( + UUID ownerId, + Set creatureIds, + Map items +) { + public TradeOffer { + ownerId = Checks.notNull(ownerId, "ownerId"); + Checks.noNulls(creatureIds, "creatureIds"); + creatureIds = Set.copyOf(creatureIds); + Checks.noNulls(items, "items"); + items = items.entrySet().stream().collect(Collectors.toUnmodifiableMap( + entry -> Checks.namespacedId(entry.getKey(), "itemId"), + entry -> Checks.range(entry.getValue(), 1, 1_000_000, "item quantity"))); + } + + public boolean isEmpty() { + return creatureIds.isEmpty() && items.isEmpty(); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/model/TradeTransaction.java b/engine/src/main/java/dev/projectcapture/engine/model/TradeTransaction.java new file mode 100644 index 0000000..5c514c4 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/model/TradeTransaction.java @@ -0,0 +1,59 @@ +package dev.projectcapture.engine.model; + +import java.util.Set; +import java.util.UUID; + +public record TradeTransaction( + int schemaVersion, + UUID id, + UUID playerOneId, + UUID playerTwoId, + TradeOffer playerOneOffer, + TradeOffer playerTwoOffer, + Set acceptedBy, + Status status, + long revision +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + + public enum Status { + OPEN, + LOCKED, + COMMITTED, + CANCELLED + } + + public TradeTransaction { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported trade schema version: " + schemaVersion); + } + id = Checks.notNull(id, "id"); + playerOneId = Checks.notNull(playerOneId, "playerOneId"); + playerTwoId = Checks.notNull(playerTwoId, "playerTwoId"); + if (playerOneId.equals(playerTwoId)) { + throw new IllegalArgumentException("trade players must be different"); + } + playerOneOffer = Checks.notNull(playerOneOffer, "playerOneOffer"); + playerTwoOffer = Checks.notNull(playerTwoOffer, "playerTwoOffer"); + if (!playerOneOffer.ownerId().equals(playerOneId) || !playerTwoOffer.ownerId().equals(playerTwoId)) { + throw new IllegalArgumentException("offer owners must match trade players"); + } + if (playerOneOffer.isEmpty() && playerTwoOffer.isEmpty()) { + throw new IllegalArgumentException("at least one trade offer must not be empty"); + } + Checks.noNulls(acceptedBy, "acceptedBy"); + acceptedBy = Set.copyOf(acceptedBy); + var participants = Set.of(playerOneId, playerTwoId); + if (!participants.containsAll(acceptedBy)) { + throw new IllegalArgumentException("only trade participants may accept"); + } + status = Checks.notNull(status, "status"); + if ((status == Status.LOCKED || status == Status.COMMITTED) && !acceptedBy.equals(participants)) { + throw new IllegalArgumentException("locked or committed trade requires both acceptances"); + } + if (status == Status.OPEN && acceptedBy.size() == 2) { + throw new IllegalArgumentException("fully accepted trade must be locked"); + } + Checks.nonNegative(revision, "revision"); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/BattleService.java b/engine/src/main/java/dev/projectcapture/engine/service/BattleService.java new file mode 100644 index 0000000..0bebe34 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/BattleService.java @@ -0,0 +1,169 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.BattleEvent; +import dev.projectcapture.engine.model.BattleParticipant; +import dev.projectcapture.engine.model.BattleState; +import dev.projectcapture.engine.model.CreatureInstance; +import dev.projectcapture.engine.model.MoveDefinition; +import dev.projectcapture.engine.model.SpeciesDefinition; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.SplittableRandom; +import java.util.UUID; + +public final class BattleService { + public BattleState create(UUID battleId, BattleParticipant first, BattleParticipant second) { + if (first == null || second == null) { + throw new NullPointerException("battle participants must not be null"); + } + if (first.isFainted() || second.isFainted()) { + throw new IllegalArgumentException("fainted creatures cannot start a battle"); + } + return new BattleState(BattleState.CURRENT_SCHEMA_VERSION, battleId, first, second, + 0, BattleState.Status.ACTIVE, Optional.empty(), List.of()); + } + + public BattleParticipant participant(CreatureInstance creature, SpeciesDefinition species) { + if (creature == null || species == null) { + throw new NullPointerException("creature and species must not be null"); + } + if (!creature.speciesId().equals(species.id())) { + throw new IllegalArgumentException("species does not match creature"); + } + UUID ownerId = creature.ownerId().orElseThrow(() -> + new IllegalArgumentException("battle creature must have an owner")); + int maximumHealth = species.baseStats().healthAtLevel(creature.level()); + if (creature.currentHealth() > maximumHealth) { + throw new IllegalArgumentException("creature health exceeds species maximum"); + } + return new BattleParticipant(creature.id(), ownerId, creature.speciesId(), creature.level(), + creature.currentHealth(), maximumHealth, species.baseStats().attackAtLevel(creature.level()), + species.baseStats().defenceAtLevel(creature.level()), species.baseStats().speedAtLevel(creature.level()), + creature.basicMoveId(), creature.commandMoveIds()); + } + + public BattleState nextTurn( + BattleState battle, + Map commandedMoves, + Map moveCatalog, + long seed + ) { + if (battle == null || commandedMoves == null || moveCatalog == null) { + throw new NullPointerException("battle turn inputs must not be null"); + } + if (battle.status() != BattleState.Status.ACTIVE) { + throw new IllegalStateException("battle is not active"); + } + Set participants = Set.of( + battle.participantOne().creatureId(), battle.participantTwo().creatureId()); + if (!participants.containsAll(commandedMoves.keySet()) || commandedMoves.values().stream().anyMatch(java.util.Objects::isNull)) { + throw new IllegalArgumentException("commands must reference battle participants and non-null moves"); + } + + var actions = new ArrayList(); + actions.add(actionFor(battle.participantOne(), commandedMoves, moveCatalog)); + actions.add(actionFor(battle.participantTwo(), commandedMoves, moveCatalog)); + actions.sort((left, right) -> { + int comparison = Integer.compare(right.move().priority(), left.move().priority()); + if (comparison == 0) { + comparison = Integer.compare(right.actor().speed(), left.actor().speed()); + } + if (comparison == 0) { + comparison = left.actor().creatureId().compareTo(right.actor().creatureId()); + } + return comparison; + }); + + var current = new HashMap(); + current.put(battle.participantOne().creatureId(), battle.participantOne()); + current.put(battle.participantTwo().creatureId(), battle.participantTwo()); + var random = new SplittableRandom(seed ^ battle.id().getMostSignificantBits() + ^ battle.id().getLeastSignificantBits() ^ battle.turn()); + var events = new ArrayList(); + for (var action : actions) { + var actor = current.get(action.actor().creatureId()); + if (actor.isFainted()) { + continue; + } + UUID targetId = actor.creatureId().equals(battle.participantOne().creatureId()) + ? battle.participantTwo().creatureId() + : battle.participantOne().creatureId(); + var target = current.get(targetId); + if (target.isFainted()) { + continue; + } + boolean hit = random.nextDouble() < action.move().accuracy(); + int damage = hit ? Math.min(target.currentHealth(), damage(actor, target, action.move(), random)) : 0; + int remainingHealth = Math.max(0, target.currentHealth() - damage); + var updatedTarget = withHealth(target, remainingHealth); + current.put(targetId, updatedTarget); + events.add(new BattleEvent(actor.creatureId(), targetId, action.move().id(), action.automatic(), + hit, damage, remainingHealth)); + } + + var first = current.get(battle.participantOne().creatureId()); + var second = current.get(battle.participantTwo().creatureId()); + Optional winner = Optional.empty(); + var status = BattleState.Status.ACTIVE; + if (first.isFainted() || second.isFainted()) { + status = BattleState.Status.COMPLETED; + winner = Optional.of(first.isFainted() ? second.creatureId() : first.creatureId()); + } + return new BattleState(battle.schemaVersion(), battle.id(), first, second, battle.turn() + 1, + status, winner, events); + } + + public BattleState cancel(BattleState battle) { + if (battle.status() != BattleState.Status.ACTIVE) { + throw new IllegalStateException("only active battles can be cancelled"); + } + return new BattleState(battle.schemaVersion(), battle.id(), battle.participantOne(), + battle.participantTwo(), battle.turn(), BattleState.Status.CANCELLED, + Optional.empty(), List.of()); + } + + private static Action actionFor( + BattleParticipant participant, + Map commands, + Map moveCatalog + ) { + boolean automatic = !commands.containsKey(participant.creatureId()); + String moveId = automatic ? participant.basicMoveId() : commands.get(participant.creatureId()); + if (!automatic && !participant.commandMoveIds().contains(moveId)) { + throw new IllegalArgumentException("move is not one of the creature's three commands: " + moveId); + } + MoveDefinition move = Optional.ofNullable(moveCatalog.get(moveId)) + .orElseThrow(() -> new IllegalArgumentException("missing move definition: " + moveId)); + return new Action(participant, move, automatic); + } + + private static int damage( + BattleParticipant attacker, + BattleParticipant defender, + MoveDefinition move, + SplittableRandom random + ) { + if (move.category() == MoveDefinition.Category.STATUS) { + return 0; + } + double base = (((2.0 * attacker.level() / 5.0 + 2.0) * move.power() + * attacker.attack() / defender.defence()) / 50.0) + 2.0; + double variance = 0.85 + random.nextDouble() * 0.15; + return Math.max(1, (int) Math.floor(base * variance)); + } + + private static BattleParticipant withHealth(BattleParticipant participant, int health) { + return new BattleParticipant(participant.creatureId(), participant.ownerId(), participant.speciesId(), + participant.level(), health, participant.maximumHealth(), participant.attack(), participant.defence(), + participant.speed(), participant.basicMoveId(), participant.commandMoveIds()); + } + + private record Action(BattleParticipant actor, MoveDefinition move, boolean automatic) { + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/BreedingService.java b/engine/src/main/java/dev/projectcapture/engine/service/BreedingService.java new file mode 100644 index 0000000..ef2cb17 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/BreedingService.java @@ -0,0 +1,142 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.CreatureEgg; +import dev.projectcapture.engine.model.CreatureInstance; +import dev.projectcapture.engine.model.HabitatState; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.SplittableRandom; +import java.util.UUID; + +public final class BreedingService { + private final GeneticsService geneticsService; + + public BreedingService(GeneticsService geneticsService) { + this.geneticsService = java.util.Objects.requireNonNull(geneticsService, "geneticsService must not be null"); + } + + public HabitatState start( + HabitatState habitat, + CreatureInstance parentOne, + CreatureInstance parentTwo, + Duration requiredActiveTime, + long expectedRevision + ) { + requireRevision(habitat, expectedRevision); + if (habitat.status() != HabitatState.Status.EMPTY) { + throw new IllegalStateException("habitat is not empty"); + } + if (parentOne == null || parentTwo == null || requiredActiveTime == null) { + throw new NullPointerException("breeding inputs must not be null"); + } + if (parentOne.id().equals(parentTwo.id())) { + throw new IllegalArgumentException("breeding parents must be different creatures"); + } + if (parentOne.ownerId().isEmpty() || parentTwo.ownerId().isEmpty() + || !parentOne.ownerId().orElseThrow().equals(habitat.ownerId()) + || !parentTwo.ownerId().orElseThrow().equals(habitat.ownerId())) { + throw new IllegalArgumentException("habitat owner must own both parents"); + } + long requiredMillis = positiveMillis(requiredActiveTime); + return new HabitatState(habitat.schemaVersion(), habitat.id(), habitat.ownerId(), + HabitatState.Status.BREEDING, Optional.of(parentOne.id()), Optional.of(parentTwo.id()), + 0, requiredMillis, habitat.revision() + 1); + } + + public HabitatState advance( + HabitatState habitat, + Duration elapsed, + boolean serverActive, + long expectedRevision + ) { + requireRevision(habitat, expectedRevision); + if (elapsed == null || elapsed.isNegative()) { + throw new IllegalArgumentException("elapsed duration must not be null or negative"); + } + if (habitat.status() != HabitatState.Status.BREEDING) { + throw new IllegalStateException("habitat is not breeding"); + } + long elapsedMillis = elapsed.toMillis(); + if (!serverActive || elapsedMillis == 0) { + return habitat; + } + long remaining = habitat.requiredActiveMillis() - habitat.accumulatedActiveMillis(); + long progressed = Math.min(remaining, elapsedMillis); + long accumulated = habitat.accumulatedActiveMillis() + progressed; + var status = accumulated == habitat.requiredActiveMillis() + ? HabitatState.Status.EGG_READY + : HabitatState.Status.BREEDING; + return new HabitatState(habitat.schemaVersion(), habitat.id(), habitat.ownerId(), status, + habitat.parentOneId(), habitat.parentTwoId(), accumulated, + habitat.requiredActiveMillis(), habitat.revision() + 1); + } + + public EggCollection collectEgg( + HabitatState habitat, + CreatureInstance parentOne, + CreatureInstance parentTwo, + String childSpeciesId, + long seed, + Instant createdAt, + long expectedRevision + ) { + requireRevision(habitat, expectedRevision); + if (habitat.status() != HabitatState.Status.EGG_READY) { + throw new IllegalStateException("egg is not ready"); + } + if (parentOne == null || parentTwo == null || childSpeciesId == null || createdAt == null) { + throw new NullPointerException("egg inputs must not be null"); + } + if (!habitat.parentOneId().orElseThrow().equals(parentOne.id()) + || !habitat.parentTwoId().orElseThrow().equals(parentTwo.id())) { + throw new IllegalArgumentException("parents do not match habitat"); + } + var random = new SplittableRandom(seed); + var eggId = new UUID(random.nextLong(), random.nextLong()); + var genome = geneticsService.breed(parentOne.genome(), parentTwo.genome(), seed); + var egg = new CreatureEgg(CreatureEgg.CURRENT_SCHEMA_VERSION, eggId, habitat.ownerId(), childSpeciesId, + parentOne.id(), parentTwo.id(), genome, createdAt); + var collected = new HabitatState(habitat.schemaVersion(), habitat.id(), habitat.ownerId(), + HabitatState.Status.COLLECTED, habitat.parentOneId(), habitat.parentTwoId(), + habitat.accumulatedActiveMillis(), habitat.requiredActiveMillis(), habitat.revision() + 1); + return new EggCollection(collected, egg); + } + + public HabitatState cancel(HabitatState habitat, long expectedRevision) { + requireRevision(habitat, expectedRevision); + if (habitat.status() != HabitatState.Status.BREEDING + && habitat.status() != HabitatState.Status.EGG_READY) { + throw new IllegalStateException("only active breeding can be cancelled"); + } + return new HabitatState(habitat.schemaVersion(), habitat.id(), habitat.ownerId(), + HabitatState.Status.CANCELLED, habitat.parentOneId(), habitat.parentTwoId(), + habitat.accumulatedActiveMillis(), habitat.requiredActiveMillis(), habitat.revision() + 1); + } + + private static long positiveMillis(Duration duration) { + if (duration.isNegative() || duration.isZero()) { + throw new IllegalArgumentException("required active time must be positive"); + } + long millis = duration.toMillis(); + if (millis <= 0) { + throw new IllegalArgumentException("required active time must be at least one millisecond"); + } + return millis; + } + + private static void requireRevision(HabitatState habitat, long expected) { + if (habitat.revision() != expected) { + throw new StaleRevisionException(expected, habitat.revision()); + } + } + + public record EggCollection(HabitatState habitat, CreatureEgg egg) { + public EggCollection { + if (habitat == null || egg == null) { + throw new NullPointerException("egg collection values must not be null"); + } + } + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/CaptureService.java b/engine/src/main/java/dev/projectcapture/engine/service/CaptureService.java new file mode 100644 index 0000000..ad6e65a --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/CaptureService.java @@ -0,0 +1,50 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.CaptureContext; + +import java.util.SplittableRandom; + +public final class CaptureService { + private static final double MIN_PROBABILITY = 0.005; + private static final double MAX_PROBABILITY = 0.995; + + public CaptureResult attempt(CaptureContext context, long seed) { + if (context == null) { + throw new NullPointerException("context must not be null"); + } + double probability = probability(context); + double roll = new SplittableRandom(seed).nextDouble(); + return new CaptureResult(probability, roll, roll < probability); + } + + public double probability(CaptureContext context) { + if (context == null) { + throw new NullPointerException("context must not be null"); + } + double healthFactor = (3.0 * context.maximumHealth() - 2.0 * context.currentHealth()) + / (3.0 * context.maximumHealth()); + double baseRate = context.captureRate() / 255.0; + double levelFactor = 0.55 + (100 - context.level()) * 0.0045; + double raw = baseRate + * context.rarity().captureMultiplier() + * healthFactor + * levelFactor + * context.deviceMultiplier() + * context.statusCondition().captureMultiplier(); + return Math.clamp(raw, MIN_PROBABILITY, MAX_PROBABILITY); + } + + public record CaptureResult(double probability, double roll, boolean captured) { + public CaptureResult { + if (!Double.isFinite(probability) || probability < 0.0 || probability > 1.0) { + throw new IllegalArgumentException("probability must be between zero and one"); + } + if (!Double.isFinite(roll) || roll < 0.0 || roll >= 1.0) { + throw new IllegalArgumentException("roll must be in [0, 1)"); + } + if (captured != (roll < probability)) { + throw new IllegalArgumentException("captured must match roll and probability"); + } + } + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/DuelService.java b/engine/src/main/java/dev/projectcapture/engine/service/DuelService.java new file mode 100644 index 0000000..debfb00 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/DuelService.java @@ -0,0 +1,120 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.DuelRules; +import dev.projectcapture.engine.model.DuelSession; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public final class DuelService { + public DuelSession request(UUID sessionId, UUID challengerId, UUID opponentId, DuelRules rules) { + return new DuelSession(DuelSession.CURRENT_SCHEMA_VERSION, sessionId, challengerId, opponentId, + rules, Set.of(challengerId), DuelSession.Status.REQUESTED, + Optional.empty(), Optional.empty(), 0); + } + + public DuelSession accept(DuelSession session, UUID actorId, long expectedRevision) { + requireRevision(session, expectedRevision); + requireParticipant(session, actorId); + if (session.status() != DuelSession.Status.REQUESTED) { + throw new IllegalStateException("only requested duels can be accepted"); + } + if (session.acceptedBy().contains(actorId)) { + return session; + } + var accepted = new HashSet<>(session.acceptedBy()); + accepted.add(actorId); + var status = accepted.size() == 2 ? DuelSession.Status.ACTIVE : DuelSession.Status.REQUESTED; + return copy(session, accepted, status, Optional.empty(), Optional.empty(), session.revision() + 1); + } + + public DuelSession cancelRequest( + DuelSession session, + UUID actorId, + String reason, + long expectedRevision + ) { + requireRevision(session, expectedRevision); + requireParticipant(session, actorId); + if (session.status() != DuelSession.Status.REQUESTED) { + throw new IllegalStateException("only requested duels can be cancelled"); + } + return copy(session, session.acceptedBy(), DuelSession.Status.CANCELLED, + Optional.empty(), Optional.of(requireReason(reason)), session.revision() + 1); + } + + public DuelSession forfeit(DuelSession session, UUID forfeitingPlayerId, long expectedRevision) { + requireRevision(session, expectedRevision); + requireParticipant(session, forfeitingPlayerId); + if (session.status() != DuelSession.Status.ACTIVE) { + throw new IllegalStateException("only active duels can be forfeited"); + } + UUID winner = forfeitingPlayerId.equals(session.challengerId()) + ? session.opponentId() + : session.challengerId(); + return copy(session, session.acceptedBy(), DuelSession.Status.COMPLETED, + Optional.of(winner), Optional.of("forfeit"), session.revision() + 1); + } + + public DuelSession leaveZone(DuelSession session, UUID leavingPlayerId, long expectedRevision) { + requireRevision(session, expectedRevision); + requireParticipant(session, leavingPlayerId); + if (session.status() != DuelSession.Status.ACTIVE) { + throw new IllegalStateException("only active duel players can leave the zone"); + } + UUID winner = leavingPlayerId.equals(session.challengerId()) + ? session.opponentId() + : session.challengerId(); + return copy(session, session.acceptedBy(), DuelSession.Status.COMPLETED, + Optional.of(winner), Optional.of("left-zone"), session.revision() + 1); + } + + public DuelSession complete( + DuelSession session, + UUID winnerId, + String reason, + long expectedRevision + ) { + requireRevision(session, expectedRevision); + requireParticipant(session, winnerId); + if (session.status() != DuelSession.Status.ACTIVE) { + throw new IllegalStateException("only active duels can be completed"); + } + return copy(session, session.acceptedBy(), DuelSession.Status.COMPLETED, + Optional.of(winnerId), Optional.of(requireReason(reason)), session.revision() + 1); + } + + private static DuelSession copy( + DuelSession session, + Set acceptedBy, + DuelSession.Status status, + Optional winner, + Optional reason, + long revision + ) { + return new DuelSession(session.schemaVersion(), session.id(), session.challengerId(), + session.opponentId(), session.rules(), acceptedBy, status, winner, reason, revision); + } + + private static String requireReason(String reason) { + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason must not be blank"); + } + return reason.strip(); + } + + private static void requireParticipant(DuelSession session, UUID actorId) { + if (actorId == null || (!actorId.equals(session.challengerId()) + && !actorId.equals(session.opponentId()))) { + throw new IllegalArgumentException("actor is not a duel participant"); + } + } + + private static void requireRevision(DuelSession session, long expected) { + if (session.revision() != expected) { + throw new StaleRevisionException(expected, session.revision()); + } + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/EvolutionService.java b/engine/src/main/java/dev/projectcapture/engine/service/EvolutionService.java new file mode 100644 index 0000000..eab6863 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/EvolutionService.java @@ -0,0 +1,59 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.CreatureInstance; +import dev.projectcapture.engine.model.EvolutionRule; +import dev.projectcapture.engine.model.SpeciesDefinition; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public final class EvolutionService { + public EvolutionResult evolve( + CreatureInstance creature, + SpeciesDefinition currentSpecies, + Map speciesCatalog, + Set availableItemIds, + long expectedRevision + ) { + requireRevision(creature.revision(), expectedRevision); + if (!creature.speciesId().equals(currentSpecies.id())) { + throw new IllegalArgumentException("current species does not match creature"); + } + if (speciesCatalog == null || availableItemIds == null) { + throw new NullPointerException("catalog and item ids must not be null"); + } + + EvolutionRule rule = currentSpecies.evolutions().stream() + .filter(candidate -> candidate.isEligible(creature.level(), availableItemIds)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("creature is not eligible for evolution")); + SpeciesDefinition target = Optional.ofNullable(speciesCatalog.get(rule.targetSpeciesId())) + .orElseThrow(() -> new IllegalArgumentException("missing target species: " + rule.targetSpeciesId())); + + int oldMaximum = currentSpecies.baseStats().healthAtLevel(creature.level()); + int newMaximum = target.baseStats().healthAtLevel(creature.level()); + int newHealth = creature.currentHealth() == 0 + ? 0 + : Math.clamp((int) Math.ceil(creature.currentHealth() * (newMaximum / (double) oldMaximum)), 1, newMaximum); + var evolved = new CreatureInstance( + creature.schemaVersion(), creature.id(), target.id(), creature.nickname(), creature.ownerId(), + creature.level(), creature.experience(), newHealth, creature.genome(), target.basicMoveId(), + target.commandMoveIds(), creature.createdAt(), creature.revision() + 1); + return new EvolutionResult(evolved, rule.requiredItemId()); + } + + private static void requireRevision(long actual, long expected) { + if (actual != expected) { + throw new StaleRevisionException(expected, actual); + } + } + + public record EvolutionResult(CreatureInstance creature, Optional consumedItemId) { + public EvolutionResult { + if (creature == null || consumedItemId == null) { + throw new NullPointerException("evolution result values must not be null"); + } + } + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/ExperienceService.java b/engine/src/main/java/dev/projectcapture/engine/service/ExperienceService.java new file mode 100644 index 0000000..7724f83 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/ExperienceService.java @@ -0,0 +1,76 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.CreatureInstance; +import dev.projectcapture.engine.model.SpeciesDefinition; + +public final class ExperienceService { + public static final int MAX_LEVEL = 100; + + public long thresholdForLevel(int level) { + if (level < 1 || level > MAX_LEVEL) { + throw new IllegalArgumentException("level must be between 1 and 100"); + } + long levelOffset = level - 1L; + return levelOffset * levelOffset * levelOffset * 100L; + } + + public int levelForExperience(long experience) { + if (experience < 0) { + throw new IllegalArgumentException("experience must not be negative"); + } + int low = 1; + int high = MAX_LEVEL; + while (low < high) { + int middle = (low + high + 1) >>> 1; + if (thresholdForLevel(middle) <= experience) { + low = middle; + } else { + high = middle - 1; + } + } + return low; + } + + public CreatureInstance grantExperience( + CreatureInstance creature, + SpeciesDefinition species, + long amount, + long expectedRevision + ) { + requireRevision(creature.revision(), expectedRevision); + if (!creature.speciesId().equals(species.id())) { + throw new IllegalArgumentException("species does not match creature"); + } + if (amount < 0) { + throw new IllegalArgumentException("experience amount must not be negative"); + } + if (levelForExperience(creature.experience()) != creature.level()) { + throw new IllegalStateException("creature level and experience are inconsistent"); + } + if (amount == 0 || creature.level() == MAX_LEVEL) { + return creature; + } + + long maximumExperience = thresholdForLevel(MAX_LEVEL); + long newExperience = creature.experience() >= maximumExperience - amount + ? maximumExperience + : creature.experience() + amount; + int newLevel = levelForExperience(newExperience); + int health = creature.currentHealth(); + if (health > 0 && newLevel > creature.level()) { + int oldMaximum = species.baseStats().healthAtLevel(creature.level()); + int newMaximum = species.baseStats().healthAtLevel(newLevel); + health = Math.min(newMaximum, health + (newMaximum - oldMaximum)); + } + return new CreatureInstance( + creature.schemaVersion(), creature.id(), creature.speciesId(), creature.nickname(), creature.ownerId(), + newLevel, newExperience, health, creature.genome(), creature.basicMoveId(), + creature.commandMoveIds(), creature.createdAt(), creature.revision() + 1); + } + + private static void requireRevision(long actual, long expected) { + if (actual != expected) { + throw new StaleRevisionException(expected, actual); + } + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/GeneticsService.java b/engine/src/main/java/dev/projectcapture/engine/service/GeneticsService.java new file mode 100644 index 0000000..266d8ef --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/GeneticsService.java @@ -0,0 +1,86 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.Genome; +import dev.projectcapture.engine.model.LineageContribution; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.SplittableRandom; +import java.util.TreeSet; + +public final class GeneticsService { + private static final double MUTATION_CHANCE = 0.08; + + public Genome breed(Genome parentOne, Genome parentTwo, long seed) { + if (parentOne == null || parentTwo == null) { + throw new NullPointerException("parent genomes must not be null"); + } + var random = new SplittableRandom(seed); + var traitNames = new TreeSet(); + traitNames.addAll(parentOne.traits().keySet()); + traitNames.addAll(parentTwo.traits().keySet()); + var traits = new HashMap(); + for (var traitName : traitNames) { + Integer first = parentOne.traits().get(traitName); + Integer second = parentTwo.traits().get(traitName); + int inherited; + if (first == null) { + inherited = second; + } else if (second == null) { + inherited = first; + } else { + inherited = random.nextBoolean() ? first : second; + } + if (random.nextDouble() < MUTATION_CHANCE) { + inherited = Math.clamp(inherited + (random.nextBoolean() ? 1 : -1), 0, 31); + } + traits.put(traitName, inherited); + } + return new Genome(Genome.CURRENT_SCHEMA_VERSION, seed, traits, + mergeLineages(parentOne, parentTwo), java.util.Set.of()); + } + + private static List mergeLineages(Genome first, Genome second) { + var rawWeights = new HashMap(); + first.lineages().forEach(lineage -> rawWeights.merge( + lineage.speciesId(), (long) lineage.basisPoints(), Long::sum)); + second.lineages().forEach(lineage -> rawWeights.merge( + lineage.speciesId(), (long) lineage.basisPoints(), Long::sum)); + + var selected = rawWeights.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed().thenComparing(Map.Entry::getKey)) + .limit(Genome.MAX_LINEAGES) + .toList(); + long selectedTotal = selected.stream().mapToLong(Map.Entry::getValue).sum(); + int distributable = Genome.TOTAL_BASIS_POINTS - selected.size(); + int[] allocations = new int[selected.size()]; + long[] remainders = new long[selected.size()]; + int allocated = 0; + for (int index = 0; index < selected.size(); index++) { + long numerator = selected.get(index).getValue() * distributable; + allocations[index] = 1 + (int) (numerator / selectedTotal); + remainders[index] = numerator % selectedTotal; + allocated += allocations[index]; + } + var remainderOrder = new ArrayList(); + for (int index = 0; index < selected.size(); index++) { + remainderOrder.add(index); + } + remainderOrder.sort(Comparator.comparingLong(index -> remainders[index]).reversed() + .thenComparing(index -> selected.get(index).getKey())); + for (int remainder = Genome.TOTAL_BASIS_POINTS - allocated, index = 0; remainder > 0; remainder--, index++) { + allocations[remainderOrder.get(index % remainderOrder.size())]++; + } + + var lineages = new ArrayList(); + for (int index = 0; index < selected.size(); index++) { + lineages.add(new LineageContribution(selected.get(index).getKey(), allocations[index])); + } + lineages.sort(Comparator.comparingInt(LineageContribution::basisPoints).reversed() + .thenComparing(LineageContribution::speciesId)); + return List.copyOf(lineages); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/GenomeAnalysisService.java b/engine/src/main/java/dev/projectcapture/engine/service/GenomeAnalysisService.java new file mode 100644 index 0000000..0391cab --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/GenomeAnalysisService.java @@ -0,0 +1,26 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.Genome; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.SplittableRandom; + +public final class GenomeAnalysisService { + public Genome revealNext(Genome genome) { + if (genome == null) { + throw new NullPointerException("genome must not be null"); + } + if (genome.fullyAnalysed()) { + return genome; + } + var hidden = new ArrayList<>(genome.traits().keySet()); + hidden.removeAll(genome.revealedTraits()); + hidden.sort(String::compareTo); + var random = new SplittableRandom(genome.seed() ^ (0x9E3779B97F4A7C15L * genome.revealedTraits().size())); + String revealed = hidden.get(random.nextInt(hidden.size())); + var revealedTraits = new HashSet<>(genome.revealedTraits()); + revealedTraits.add(revealed); + return new Genome(genome.schemaVersion(), genome.seed(), genome.traits(), genome.lineages(), revealedTraits); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/PartyService.java b/engine/src/main/java/dev/projectcapture/engine/service/PartyService.java new file mode 100644 index 0000000..068a8e3 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/PartyService.java @@ -0,0 +1,58 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.Party; + +import java.util.ArrayList; +import java.util.Optional; +import java.util.UUID; + +public final class PartyService { + public Party add(Party party, UUID creatureId, long expectedRevision) { + requireRevision(party, expectedRevision); + if (creatureId == null) { + throw new NullPointerException("creatureId must not be null"); + } + if (party.creatureIds().contains(creatureId)) { + return party; + } + if (party.creatureIds().size() == Party.MAX_SIZE) { + throw new IllegalStateException("party is full"); + } + var creatures = new ArrayList<>(party.creatureIds()); + creatures.add(creatureId); + return new Party(party.schemaVersion(), party.ownerId(), creatures, + party.activeCompanionId(), party.revision() + 1); + } + + public Party remove(Party party, UUID creatureId, long expectedRevision) { + requireRevision(party, expectedRevision); + if (!party.creatureIds().contains(creatureId)) { + return party; + } + var creatures = new ArrayList<>(party.creatureIds()); + creatures.remove(creatureId); + var companion = party.activeCompanionId().filter(id -> !id.equals(creatureId)); + return new Party(party.schemaVersion(), party.ownerId(), creatures, companion, party.revision() + 1); + } + + public Party setActiveCompanion(Party party, Optional creatureId, long expectedRevision) { + requireRevision(party, expectedRevision); + if (creatureId == null) { + throw new NullPointerException("creatureId must not be null"); + } + if (creatureId.isPresent() && !party.creatureIds().contains(creatureId.orElseThrow())) { + throw new IllegalArgumentException("active companion must be in the party"); + } + if (party.activeCompanionId().equals(creatureId)) { + return party; + } + return new Party(party.schemaVersion(), party.ownerId(), party.creatureIds(), creatureId, + party.revision() + 1); + } + + private static void requireRevision(Party party, long expected) { + if (party.revision() != expected) { + throw new StaleRevisionException(expected, party.revision()); + } + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/SpawnService.java b/engine/src/main/java/dev/projectcapture/engine/service/SpawnService.java new file mode 100644 index 0000000..7fc3e12 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/SpawnService.java @@ -0,0 +1,69 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.SpawnRule; +import dev.projectcapture.engine.model.SpeciesDefinition; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.SplittableRandom; + +public final class SpawnService { + public Optional select( + List species, + String biomeTag, + SpawnRule.TimeOfDay time, + long seed + ) { + if (species == null || biomeTag == null || time == null) { + throw new NullPointerException("spawn inputs must not be null"); + } + var candidates = new ArrayList(); + species.stream().sorted(Comparator.comparing(SpeciesDefinition::id)).forEach(definition -> + definition.spawnRules().stream() + .filter(rule -> rule.biomeTags().contains(biomeTag) && rule.times().contains(time)) + .forEach(rule -> candidates.add(new Candidate(definition, rule, + Math.multiplyExact((long) rule.weight(), rarityWeight(definition.rarity())))))); + if (candidates.isEmpty()) { + return Optional.empty(); + } + long totalWeight = candidates.stream().mapToLong(Candidate::weight).reduce(0, Math::addExact); + var random = new SplittableRandom(seed); + long draw = random.nextLong(totalWeight); + Candidate selected = candidates.getLast(); + for (var candidate : candidates) { + if (draw < candidate.weight()) { + selected = candidate; + break; + } + draw -= candidate.weight(); + } + int level = random.nextInt(selected.rule().minimumLevel(), selected.rule().maximumLevel() + 1); + return Optional.of(new SpawnSelection(selected.species().id(), level)); + } + + private static long rarityWeight(SpeciesDefinition.Rarity rarity) { + return switch (rarity) { + case COMMON -> 100; + case UNCOMMON -> 60; + case RARE -> 25; + case EPIC -> 8; + case LEGENDARY -> 1; + }; + } + + private record Candidate(SpeciesDefinition species, SpawnRule rule, long weight) { + } + + public record SpawnSelection(String speciesId, int level) { + public SpawnSelection { + if (speciesId == null || speciesId.isBlank()) { + throw new IllegalArgumentException("speciesId must not be blank"); + } + if (level < 1 || level > 100) { + throw new IllegalArgumentException("level must be between 1 and 100"); + } + } + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/StaleRevisionException.java b/engine/src/main/java/dev/projectcapture/engine/service/StaleRevisionException.java new file mode 100644 index 0000000..7c6943e --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/StaleRevisionException.java @@ -0,0 +1,9 @@ +package dev.projectcapture.engine.service; + +public final class StaleRevisionException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + public StaleRevisionException(long expected, long actual) { + super("Stale revision: expected " + expected + " but was " + actual); + } +} diff --git a/engine/src/main/java/dev/projectcapture/engine/service/TradeService.java b/engine/src/main/java/dev/projectcapture/engine/service/TradeService.java new file mode 100644 index 0000000..08b8a79 --- /dev/null +++ b/engine/src/main/java/dev/projectcapture/engine/service/TradeService.java @@ -0,0 +1,167 @@ +package dev.projectcapture.engine.service; + +import dev.projectcapture.engine.model.PlayerInventory; +import dev.projectcapture.engine.model.TradeOffer; +import dev.projectcapture.engine.model.TradeTransaction; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class TradeService { + public TradeTransaction create(UUID transactionId, TradeOffer playerOneOffer, TradeOffer playerTwoOffer) { + return new TradeTransaction(TradeTransaction.CURRENT_SCHEMA_VERSION, transactionId, + playerOneOffer.ownerId(), playerTwoOffer.ownerId(), playerOneOffer, playerTwoOffer, + Set.of(), TradeTransaction.Status.OPEN, 0); + } + + public TradeTransaction accept(TradeTransaction transaction, UUID actorId, long expectedRevision) { + requireRevision(transaction.revision(), expectedRevision); + requireParticipant(transaction, actorId); + if (transaction.status() != TradeTransaction.Status.OPEN) { + throw new IllegalStateException("only open trades can be accepted"); + } + if (transaction.acceptedBy().contains(actorId)) { + return transaction; + } + var accepted = new HashSet<>(transaction.acceptedBy()); + accepted.add(actorId); + var status = accepted.size() == 2 ? TradeTransaction.Status.LOCKED : TradeTransaction.Status.OPEN; + return copy(transaction, accepted, status, transaction.revision() + 1); + } + + public TradeTransaction cancel(TradeTransaction transaction, UUID actorId, long expectedRevision) { + requireRevision(transaction.revision(), expectedRevision); + requireParticipant(transaction, actorId); + if (transaction.status() == TradeTransaction.Status.COMMITTED + || transaction.status() == TradeTransaction.Status.CANCELLED) { + throw new IllegalStateException("terminal trade cannot be cancelled"); + } + return copy(transaction, transaction.acceptedBy(), TradeTransaction.Status.CANCELLED, + transaction.revision() + 1); + } + + public TradeCommitResult commit( + TradeTransaction transaction, + PlayerInventory playerOneInventory, + PlayerInventory playerTwoInventory, + long expectedTradeRevision, + long expectedPlayerOneRevision, + long expectedPlayerTwoRevision + ) { + requireRevision(transaction.revision(), expectedTradeRevision); + requireRevision(playerOneInventory.revision(), expectedPlayerOneRevision); + requireRevision(playerTwoInventory.revision(), expectedPlayerTwoRevision); + if (transaction.status() != TradeTransaction.Status.LOCKED) { + throw new IllegalStateException("trade must be locked before commit"); + } + if (!playerOneInventory.ownerId().equals(transaction.playerOneId()) + || !playerTwoInventory.ownerId().equals(transaction.playerTwoId())) { + throw new IllegalArgumentException("inventory owners do not match transaction"); + } + var inventoryOverlap = new HashSet<>(playerOneInventory.creatureIds()); + inventoryOverlap.retainAll(playerTwoInventory.creatureIds()); + if (!inventoryOverlap.isEmpty()) { + throw new IllegalStateException("a creature cannot exist in both inventories"); + } + validateOffer(transaction.playerOneOffer(), playerOneInventory); + validateOffer(transaction.playerTwoOffer(), playerTwoInventory); + + // All checks and calculations happen on detached copies. If any calculation fails, + // no new aggregate is returned and both source snapshots remain unchanged. + var oneCreatures = exchangeCreatures(playerOneInventory.creatureIds(), transaction.playerOneOffer(), + transaction.playerTwoOffer()); + var twoCreatures = exchangeCreatures(playerTwoInventory.creatureIds(), transaction.playerTwoOffer(), + transaction.playerOneOffer()); + var oneItems = exchangeItems(playerOneInventory.items(), transaction.playerOneOffer(), + transaction.playerTwoOffer()); + var twoItems = exchangeItems(playerTwoInventory.items(), transaction.playerTwoOffer(), + transaction.playerOneOffer()); + + var updatedOne = new PlayerInventory(playerOneInventory.schemaVersion(), playerOneInventory.ownerId(), + oneCreatures, oneItems, playerOneInventory.revision() + 1); + var updatedTwo = new PlayerInventory(playerTwoInventory.schemaVersion(), playerTwoInventory.ownerId(), + twoCreatures, twoItems, playerTwoInventory.revision() + 1); + var committed = copy(transaction, transaction.acceptedBy(), TradeTransaction.Status.COMMITTED, + transaction.revision() + 1); + return new TradeCommitResult(committed, updatedOne, updatedTwo); + } + + private static void validateOffer(TradeOffer offer, PlayerInventory inventory) { + if (!inventory.creatureIds().containsAll(offer.creatureIds())) { + throw new IllegalStateException("offered creature is no longer owned"); + } + offer.items().forEach((itemId, quantity) -> { + if (inventory.items().getOrDefault(itemId, 0) < quantity) { + throw new IllegalStateException("offered item quantity is no longer available: " + itemId); + } + }); + } + + private static Set exchangeCreatures(Set source, TradeOffer outgoing, TradeOffer incoming) { + var result = new HashSet<>(source); + result.removeAll(outgoing.creatureIds()); + for (var creatureId : incoming.creatureIds()) { + if (!result.add(creatureId)) { + throw new IllegalStateException("receiving inventory already contains creature: " + creatureId); + } + } + return Set.copyOf(result); + } + + private static Map exchangeItems( + Map source, + TradeOffer outgoing, + TradeOffer incoming + ) { + var result = new HashMap<>(source); + outgoing.items().forEach((itemId, quantity) -> { + int remaining = result.getOrDefault(itemId, 0) - quantity; + if (remaining == 0) { + result.remove(itemId); + } else { + result.put(itemId, remaining); + } + }); + incoming.items().forEach((itemId, quantity) -> result.merge(itemId, quantity, Math::addExact)); + return Map.copyOf(result); + } + + private static TradeTransaction copy( + TradeTransaction transaction, + Set acceptedBy, + TradeTransaction.Status status, + long revision + ) { + return new TradeTransaction(transaction.schemaVersion(), transaction.id(), transaction.playerOneId(), + transaction.playerTwoId(), transaction.playerOneOffer(), transaction.playerTwoOffer(), + acceptedBy, status, revision); + } + + private static void requireParticipant(TradeTransaction transaction, UUID actorId) { + if (actorId == null || (!actorId.equals(transaction.playerOneId()) + && !actorId.equals(transaction.playerTwoId()))) { + throw new IllegalArgumentException("actor is not a trade participant"); + } + } + + private static void requireRevision(long actual, long expected) { + if (actual != expected) { + throw new StaleRevisionException(expected, actual); + } + } + + public record TradeCommitResult( + TradeTransaction transaction, + PlayerInventory playerOneInventory, + PlayerInventory playerTwoInventory + ) { + public TradeCommitResult { + if (transaction == null || playerOneInventory == null || playerTwoInventory == null) { + throw new NullPointerException("trade commit values must not be null"); + } + } + } +} diff --git a/engine/src/test/java/dev/projectcapture/engine/BattleServiceTest.java b/engine/src/test/java/dev/projectcapture/engine/BattleServiceTest.java new file mode 100644 index 0000000..494d608 --- /dev/null +++ b/engine/src/test/java/dev/projectcapture/engine/BattleServiceTest.java @@ -0,0 +1,96 @@ +package dev.projectcapture.engine; + +import dev.projectcapture.engine.model.BattleParticipant; +import dev.projectcapture.engine.model.BattleState; +import dev.projectcapture.engine.model.MoveDefinition; +import dev.projectcapture.engine.service.BattleService; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +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; + +class BattleServiceTest { + private static final UUID FIRST_CREATURE = new UUID(20, 1); + private static final UUID SECOND_CREATURE = new UUID(20, 2); + private static final UUID BATTLE_ID = new UUID(30, 1); + + private final BattleService service = new BattleService(); + + @Test + void missingCommandProducesAutomaticBasicAttackAndExplicitCommandUsesAbility() { + var battle = service.create(BATTLE_ID, participant(FIRST_CREATURE, TestFixtures.OWNER_ONE, 60, 50, 50), + participant(SECOND_CREATURE, TestFixtures.OWNER_TWO, 50, 50, 40)); + var result = service.nextTurn(battle, Map.of(FIRST_CREATURE, TestFixtures.COMMANDS.getFirst()), + TestFixtures.moves(), 123); + + assertEquals(2, result.lastTurnEvents().size()); + var firstEvent = result.lastTurnEvents().stream() + .filter(event -> event.actorCreatureId().equals(FIRST_CREATURE)).findFirst().orElseThrow(); + var secondEvent = result.lastTurnEvents().stream() + .filter(event -> event.actorCreatureId().equals(SECOND_CREATURE)).findFirst().orElseThrow(); + assertFalse(firstEvent.automaticBasicAttack()); + assertEquals(TestFixtures.COMMANDS.getFirst(), firstEvent.moveId()); + assertTrue(secondEvent.automaticBasicAttack()); + assertEquals(TestFixtures.BASIC, secondEvent.moveId()); + } + + @Test + void battleTurnIsFullyDeterministicForStateAndSeed() { + var battle = service.create(BATTLE_ID, participant(FIRST_CREATURE, TestFixtures.OWNER_ONE, 100, 50, 50), + participant(SECOND_CREATURE, TestFixtures.OWNER_TWO, 100, 50, 50)); + assertEquals( + service.nextTurn(battle, Map.of(), TestFixtures.moves(), 999), + service.nextTurn(battle, Map.of(), TestFixtures.moves(), 999)); + } + + @Test + void fasterKnockoutStopsFaintedCreatureFromActing() { + var fast = participant(FIRST_CREATURE, TestFixtures.OWNER_ONE, 20, 100_000, 1_000); + var fragile = new BattleParticipant(SECOND_CREATURE, TestFixtures.OWNER_TWO, "projectcapture:test", + 50, 10, 10, 1, 1, 1, TestFixtures.BASIC, TestFixtures.COMMANDS); + var result = service.nextTurn(service.create(BATTLE_ID, fast, fragile), Map.of(), TestFixtures.moves(), 1); + assertEquals(BattleState.Status.COMPLETED, result.status()); + assertEquals(FIRST_CREATURE, result.winnerCreatureId().orElseThrow()); + assertEquals(1, result.lastTurnEvents().size()); + assertEquals(0, result.participantTwo().currentHealth()); + } + + @Test + void rejectsFourthOrUnknownCommand() { + var battle = service.create(BATTLE_ID, participant(FIRST_CREATURE, TestFixtures.OWNER_ONE, 50, 50, 50), + participant(SECOND_CREATURE, TestFixtures.OWNER_TWO, 50, 50, 50)); + assertThrows(IllegalArgumentException.class, () -> service.nextTurn(battle, + Map.of(FIRST_CREATURE, "projectcapture:not_equipped"), TestFixtures.moves(), 1)); + assertThrows(IllegalArgumentException.class, () -> service.nextTurn(battle, + Map.of(UUID.randomUUID(), TestFixtures.COMMANDS.getFirst()), TestFixtures.moves(), 1)); + } + + @Test + void missedAttackDealsNoDamageAndCancellationIsTerminal() { + var moves = new HashMap<>(TestFixtures.moves()); + moves.put(TestFixtures.BASIC, TestFixtures.move(TestFixtures.BASIC, 40, 0.0, 0)); + var battle = service.create(BATTLE_ID, participant(FIRST_CREATURE, TestFixtures.OWNER_ONE, 50, 50, 50), + participant(SECOND_CREATURE, TestFixtures.OWNER_TWO, 50, 50, 50)); + var result = service.nextTurn(battle, Map.of(), moves, 1); + assertTrue(result.lastTurnEvents().stream().noneMatch(event -> event.hit() || event.damage() != 0)); + assertEquals(50, result.participantOne().currentHealth()); + assertEquals(50, result.participantTwo().currentHealth()); + + var cancelled = service.cancel(result); + assertEquals(BattleState.Status.CANCELLED, cancelled.status()); + assertThrows(IllegalStateException.class, + () -> service.nextTurn(cancelled, Map.of(), moves, 2)); + } + + private static BattleParticipant participant(UUID creatureId, UUID ownerId, int health, int attack, int speed) { + return new BattleParticipant(creatureId, ownerId, "projectcapture:test", 50, + health, health, attack, 50, speed, TestFixtures.BASIC, TestFixtures.COMMANDS); + } +} diff --git a/engine/src/test/java/dev/projectcapture/engine/CaptureAndSpawnServiceTest.java b/engine/src/test/java/dev/projectcapture/engine/CaptureAndSpawnServiceTest.java new file mode 100644 index 0000000..491dc99 --- /dev/null +++ b/engine/src/test/java/dev/projectcapture/engine/CaptureAndSpawnServiceTest.java @@ -0,0 +1,72 @@ +package dev.projectcapture.engine; + +import dev.projectcapture.engine.model.CaptureContext; +import dev.projectcapture.engine.model.SpawnRule; +import dev.projectcapture.engine.model.SpeciesDefinition; +import dev.projectcapture.engine.service.CaptureService; +import dev.projectcapture.engine.service.SpawnService; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CaptureAndSpawnServiceTest { + private final CaptureService captureService = new CaptureService(); + + @Test + void captureIsDeterministicForTheSameSeed() { + var context = context(70, 100, CaptureContext.StatusCondition.PARALYSED); + var first = captureService.attempt(context, 42); + var second = captureService.attempt(context, 42); + assertEquals(first, second); + } + + @Test + void lowerHealthAndStrongStatusIncreaseCaptureProbability() { + double healthy = captureService.probability(context(100, 100, CaptureContext.StatusCondition.NONE)); + double weakened = captureService.probability(context(10, 100, CaptureContext.StatusCondition.ASLEEP)); + assertTrue(weakened > healthy); + assertTrue(healthy >= 0.005); + assertTrue(weakened <= 0.995); + } + + @Test + void spawnSelectionFiltersBiomeAndTimeAndIsDeterministic() { + var forestRule = new SpawnRule(Set.of("hytale:forest"), Set.of(SpawnRule.TimeOfDay.DAY), + 100, 3, 7); + var caveRule = new SpawnRule(Set.of("hytale:cave"), Set.of(SpawnRule.TimeOfDay.NIGHT), + 100, 20, 20); + var forestSpecies = TestFixtures.species("projectcapture:forestling", List.of(), List.of(forestRule)); + var caveSpecies = TestFixtures.species("projectcapture:caveling", List.of(), List.of(caveRule)); + var service = new SpawnService(); + + var first = service.select(List.of(caveSpecies, forestSpecies), "hytale:forest", + SpawnRule.TimeOfDay.DAY, 999).orElseThrow(); + var second = service.select(List.of(forestSpecies, caveSpecies), "hytale:forest", + SpawnRule.TimeOfDay.DAY, 999).orElseThrow(); + assertEquals(first, second); + assertEquals("projectcapture:forestling", first.speciesId()); + assertTrue(first.level() >= 3 && first.level() <= 7); + } + + @Test + void spawnSelectionReturnsEmptyWhenNothingMatches() { + var rule = new SpawnRule(Set.of("hytale:forest"), Set.of(SpawnRule.TimeOfDay.DAY), 1, 1, 1); + var species = TestFixtures.species("projectcapture:forestling", List.of(), List.of(rule)); + assertFalse(new SpawnService().select(List.of(species), "hytale:desert", + SpawnRule.TimeOfDay.DAY, 1).isPresent()); + } + + private static CaptureContext context( + int currentHealth, + int maximumHealth, + CaptureContext.StatusCondition status + ) { + return new CaptureContext(120, SpeciesDefinition.Rarity.UNCOMMON, 25, currentHealth, maximumHealth, + 1.0, status); + } +} diff --git a/engine/src/test/java/dev/projectcapture/engine/DuelServiceTest.java b/engine/src/test/java/dev/projectcapture/engine/DuelServiceTest.java new file mode 100644 index 0000000..e7a60b2 --- /dev/null +++ b/engine/src/test/java/dev/projectcapture/engine/DuelServiceTest.java @@ -0,0 +1,76 @@ +package dev.projectcapture.engine; + +import dev.projectcapture.engine.model.DuelRules; +import dev.projectcapture.engine.model.DuelSession; +import dev.projectcapture.engine.service.DuelService; +import dev.projectcapture.engine.service.StaleRevisionException; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DuelServiceTest { + private final DuelService service = new DuelService(); + + @Test + void acceptedDuelUsesNaturalLevelsAndDisablesPlayerWeapons() { + var requested = request(); + assertTrue(requested.rules().naturalLevels()); + assertTrue(requested.rules().playerWeaponsDisabled()); + assertEquals(DuelSession.Status.REQUESTED, requested.status()); + + var active = service.accept(requested, TestFixtures.OWNER_TWO, requested.revision()); + assertEquals(DuelSession.Status.ACTIVE, active.status()); + assertEquals(2, active.acceptedBy().size()); + } + + @Test + void forfeitAndLeavingZoneAwardVictoryToOtherPlayer() { + var active = active(); + var forfeited = service.forfeit(active, TestFixtures.OWNER_ONE, active.revision()); + assertEquals(DuelSession.Status.COMPLETED, forfeited.status()); + assertEquals(TestFixtures.OWNER_TWO, forfeited.winnerId().orElseThrow()); + assertEquals("forfeit", forfeited.completionReason().orElseThrow()); + + var another = active(); + var leftZone = service.leaveZone(another, TestFixtures.OWNER_TWO, another.revision()); + assertEquals(TestFixtures.OWNER_ONE, leftZone.winnerId().orElseThrow()); + assertEquals("left-zone", leftZone.completionReason().orElseThrow()); + } + + @Test + void requestCanBeCancelledButTerminalOrStaleActionsFail() { + var requested = request(); + var cancelled = service.cancelRequest(requested, TestFixtures.OWNER_TWO, "declined", 0); + assertEquals(DuelSession.Status.CANCELLED, cancelled.status()); + assertEquals("declined", cancelled.completionReason().orElseThrow()); + assertThrows(IllegalStateException.class, + () -> service.accept(cancelled, TestFixtures.OWNER_TWO, cancelled.revision())); + + var active = active(); + assertThrows(StaleRevisionException.class, + () -> service.complete(active, TestFixtures.OWNER_ONE, "knockout", 0)); + assertThrows(IllegalArgumentException.class, + () -> service.forfeit(active, UUID.randomUUID(), active.revision())); + } + + @Test + void duelRulesRejectWeaponDamageLevelScalingAndUnsafeZoneSizes() { + assertThrows(IllegalArgumentException.class, () -> new DuelRules(false, true, 32)); + assertThrows(IllegalArgumentException.class, () -> new DuelRules(true, false, 32)); + assertThrows(IllegalArgumentException.class, () -> new DuelRules(true, true, 1)); + } + + private DuelSession request() { + return service.request(new UUID(60, 1), TestFixtures.OWNER_ONE, TestFixtures.OWNER_TWO, + DuelRules.defaults()); + } + + private DuelSession active() { + var requested = request(); + return service.accept(requested, TestFixtures.OWNER_TWO, requested.revision()); + } +} diff --git a/engine/src/test/java/dev/projectcapture/engine/GeneticsAndBreedingTest.java b/engine/src/test/java/dev/projectcapture/engine/GeneticsAndBreedingTest.java new file mode 100644 index 0000000..a6a93c4 --- /dev/null +++ b/engine/src/test/java/dev/projectcapture/engine/GeneticsAndBreedingTest.java @@ -0,0 +1,123 @@ +package dev.projectcapture.engine; + +import dev.projectcapture.engine.model.Genome; +import dev.projectcapture.engine.model.HabitatState; +import dev.projectcapture.engine.model.LineageContribution; +import dev.projectcapture.engine.service.BreedingService; +import dev.projectcapture.engine.service.GeneticsService; +import dev.projectcapture.engine.service.GenomeAnalysisService; +import dev.projectcapture.engine.service.StaleRevisionException; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GeneticsAndBreedingTest { + @Test + void geneticsIsDeterministicAndLimitsHybridsToFourLineages() { + var first = genome(1, List.of( + new LineageContribution("projectcapture:a", 4_000), + new LineageContribution("projectcapture:b", 3_000), + new LineageContribution("projectcapture:c", 2_000), + new LineageContribution("projectcapture:d", 1_000))); + var second = genome(2, List.of( + new LineageContribution("projectcapture:e", 4_000), + new LineageContribution("projectcapture:f", 3_000), + new LineageContribution("projectcapture:g", 2_000), + new LineageContribution("projectcapture:h", 1_000))); + var service = new GeneticsService(); + var childOne = service.breed(first, second, 12345); + var childTwo = service.breed(first, second, 12345); + + assertEquals(childOne, childTwo); + assertTrue(childOne.lineages().size() <= Genome.MAX_LINEAGES); + assertEquals(Genome.TOTAL_BASIS_POINTS, + childOne.lineages().stream().mapToInt(LineageContribution::basisPoints).sum()); + assertNotEquals(service.breed(first, second, 12346).seed(), childOne.seed()); + } + + @Test + void geneAnalysisRevealsExactlyOneNewTraitPerStep() { + var analysis = new GenomeAnalysisService(); + Genome genome = TestFixtures.genome(77, "projectcapture:a"); + for (int expected = 1; expected <= genome.traits().size(); expected++) { + genome = analysis.revealNext(genome); + assertEquals(expected, genome.revealedTraits().size()); + } + assertTrue(genome.fullyAnalysed()); + assertSame(genome, analysis.revealNext(genome)); + } + + @Test + void breedingProgressesOnlyDuringActiveServerTimeAndClampsAtReady() { + var species = TestFixtures.species("projectcapture:a"); + var first = TestFixtures.creature(new UUID(5, 1), species, TestFixtures.OWNER_ONE, 10, 1); + var second = TestFixtures.creature(new UUID(5, 2), species, TestFixtures.OWNER_ONE, 10, 2); + var service = new BreedingService(new GeneticsService()); + var empty = HabitatState.empty(new UUID(8, 1), TestFixtures.OWNER_ONE); + var breeding = service.start(empty, first, second, Duration.ofSeconds(10), 0); + + var offline = service.advance(breeding, Duration.ofHours(8), false, breeding.revision()); + assertSame(breeding, offline); + var halfway = service.advance(breeding, Duration.ofSeconds(4), true, breeding.revision()); + assertEquals(4_000, halfway.accumulatedActiveMillis()); + assertEquals(HabitatState.Status.BREEDING, halfway.status()); + var ready = service.advance(halfway, Duration.ofSeconds(30), true, halfway.revision()); + assertEquals(10_000, ready.accumulatedActiveMillis()); + assertEquals(HabitatState.Status.EGG_READY, ready.status()); + } + + @Test + void collectingEggIsDeterministicAndTerminal() { + var species = TestFixtures.species("projectcapture:a"); + var first = TestFixtures.creature(new UUID(6, 1), species, TestFixtures.OWNER_ONE, 10, 1); + var second = TestFixtures.creature(new UUID(6, 2), species, TestFixtures.OWNER_ONE, 10, 2); + var service = new BreedingService(new GeneticsService()); + var breeding = service.start(HabitatState.empty(new UUID(9, 1), TestFixtures.OWNER_ONE), + first, second, Duration.ofMillis(1), 0); + var ready = service.advance(breeding, Duration.ofMillis(1), true, breeding.revision()); + var firstResult = service.collectEgg(ready, first, second, "projectcapture:a", 88, + Instant.parse("2026-01-01T00:00:00Z"), ready.revision()); + var secondResult = service.collectEgg(ready, first, second, "projectcapture:a", 88, + Instant.parse("2026-01-01T00:00:00Z"), ready.revision()); + + assertEquals(firstResult, secondResult); + assertEquals(HabitatState.Status.COLLECTED, firstResult.habitat().status()); + assertEquals(first.id(), firstResult.egg().parentOneId()); + assertThrows(IllegalStateException.class, () -> service.collectEgg(firstResult.habitat(), first, second, + "projectcapture:a", 88, Instant.EPOCH, firstResult.habitat().revision())); + } + + @Test + void breedingRejectsWrongOwnerAndStaleUpdatesAndCanBeCancelled() { + var species = TestFixtures.species("projectcapture:a"); + var owned = TestFixtures.creature(new UUID(7, 1), species, TestFixtures.OWNER_ONE, 10, 1); + var foreign = TestFixtures.creature(new UUID(7, 2), species, TestFixtures.OWNER_TWO, 10, 2); + var service = new BreedingService(new GeneticsService()); + var empty = HabitatState.empty(new UUID(10, 1), TestFixtures.OWNER_ONE); + assertThrows(IllegalArgumentException.class, + () -> service.start(empty, owned, foreign, Duration.ofSeconds(1), 0)); + + var second = TestFixtures.creature(new UUID(7, 3), species, TestFixtures.OWNER_ONE, 10, 3); + var breeding = service.start(empty, owned, second, Duration.ofSeconds(1), 0); + assertThrows(StaleRevisionException.class, + () -> service.advance(breeding, Duration.ofMillis(1), true, 0)); + assertEquals(HabitatState.Status.CANCELLED, + service.cancel(breeding, breeding.revision()).status()); + } + + private static Genome genome(long seed, List lineages) { + return new Genome(Genome.CURRENT_SCHEMA_VERSION, seed, + Map.of("vitality", 1, "strength", 31, "agility", 15), lineages, Set.of()); + } +} diff --git a/engine/src/test/java/dev/projectcapture/engine/ModelInvariantTest.java b/engine/src/test/java/dev/projectcapture/engine/ModelInvariantTest.java new file mode 100644 index 0000000..e323677 --- /dev/null +++ b/engine/src/test/java/dev/projectcapture/engine/ModelInvariantTest.java @@ -0,0 +1,105 @@ +package dev.projectcapture.engine; + +import dev.projectcapture.engine.model.BaseStats; +import dev.projectcapture.engine.model.CreatureInstance; +import dev.projectcapture.engine.model.Genome; +import dev.projectcapture.engine.model.HabitatState; +import dev.projectcapture.engine.model.LineageContribution; +import dev.projectcapture.engine.model.Party; +import dev.projectcapture.engine.model.SpeciesDefinition; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ModelInvariantTest { + @Test + void rejectsInvalidNamespacedIdsAndStats() { + assertThrows(IllegalArgumentException.class, () -> TestFixtures.species("Bulbasaur")); + assertThrows(IllegalArgumentException.class, () -> new BaseStats(0, 1, 1, 1, 1, 1)); + } + + @Test + void speciesRequiresExactlyThreeUniqueCommandMoves() { + assertThrows(IllegalArgumentException.class, () -> new SpeciesDefinition( + SpeciesDefinition.CURRENT_SCHEMA_VERSION, "projectcapture:test", "Test", + List.of(dev.projectcapture.engine.model.ElementType.NORMAL), SpeciesDefinition.Rarity.COMMON, + TestFixtures.stats(), 100, TestFixtures.BASIC, + List.of("projectcapture:a", "projectcapture:a", "projectcapture:b"), List.of(), List.of())); + } + + @Test + void genomeRequiresOneToFourUniqueLineagesTotallingOneHundredPercent() { + assertThrows(IllegalArgumentException.class, () -> new Genome(Genome.CURRENT_SCHEMA_VERSION, 1, + Map.of("trait", 1), List.of( + new LineageContribution("projectcapture:a", 2_500), + new LineageContribution("projectcapture:b", 2_500), + new LineageContribution("projectcapture:c", 2_000), + new LineageContribution("projectcapture:d", 2_000), + new LineageContribution("projectcapture:e", 1_000)), Set.of())); + assertThrows(IllegalArgumentException.class, () -> new Genome(Genome.CURRENT_SCHEMA_VERSION, 1, + Map.of("trait", 1), List.of(new LineageContribution("projectcapture:a", 9_999)), Set.of())); + assertThrows(IllegalArgumentException.class, () -> new Genome(Genome.CURRENT_SCHEMA_VERSION, 1, + Map.of("trait", 1), List.of(new LineageContribution("projectcapture:a", 10_000)), + Set.of("unknown"))); + } + + @Test + void aggregateCollectionsAreDefensivelyCopied() { + var traits = new HashMap(); + traits.put("trait", 1); + var genome = Genome.pure(4, "projectcapture:test", traits); + traits.put("later", 2); + assertEquals(Map.of("trait", 1), genome.traits()); + + var ids = new ArrayList(); + ids.add(TestFixtures.OWNER_ONE); + var party = new Party(Party.CURRENT_SCHEMA_VERSION, TestFixtures.OWNER_ONE, ids, + Optional.of(TestFixtures.OWNER_ONE), 0); + ids.clear(); + assertEquals(List.of(TestFixtures.OWNER_ONE), party.creatureIds()); + assertThrows(UnsupportedOperationException.class, () -> party.creatureIds().clear()); + } + + @Test + void partyAndCreatureEnforceGameplayLimits() { + var seven = java.util.stream.IntStream.range(0, 7) + .mapToObj(index -> new UUID(0, index + 10L)).toList(); + assertThrows(IllegalArgumentException.class, () -> new Party( + Party.CURRENT_SCHEMA_VERSION, TestFixtures.OWNER_ONE, seven, Optional.empty(), 0)); + assertThrows(IllegalArgumentException.class, () -> new Party( + Party.CURRENT_SCHEMA_VERSION, TestFixtures.OWNER_ONE, List.of(), + Optional.of(UUID.randomUUID()), 0)); + + var species = TestFixtures.species("projectcapture:test"); + assertThrows(IllegalArgumentException.class, () -> new CreatureInstance( + CreatureInstance.CURRENT_SCHEMA_VERSION, UUID.randomUUID(), species.id(), Optional.of("x".repeat(33)), + Optional.empty(), 1, 0, 1, TestFixtures.genome(1, species.id()), + species.basicMoveId(), species.commandMoveIds(), Instant.EPOCH, 0)); + } + + @Test + void habitatRejectsImpossibleProgress() { + assertThrows(IllegalArgumentException.class, () -> new HabitatState( + HabitatState.CURRENT_SCHEMA_VERSION, UUID.randomUUID(), TestFixtures.OWNER_ONE, + HabitatState.Status.BREEDING, Optional.of(UUID.randomUUID()), Optional.of(UUID.randomUUID()), + 1_000, 1_000, 0)); + assertThrows(IllegalArgumentException.class, () -> new HabitatState( + HabitatState.CURRENT_SCHEMA_VERSION, UUID.randomUUID(), TestFixtures.OWNER_ONE, + HabitatState.Status.EGG_READY, Optional.of(UUID.randomUUID()), Optional.of(UUID.randomUUID()), + 999, 1_000, 0)); + assertThrows(IllegalArgumentException.class, () -> new HabitatState( + HabitatState.CURRENT_SCHEMA_VERSION, UUID.randomUUID(), TestFixtures.OWNER_ONE, + HabitatState.Status.EMPTY, Optional.of(UUID.randomUUID()), Optional.empty(), + 0, 0, 0)); + } +} diff --git a/engine/src/test/java/dev/projectcapture/engine/ProgressionEvolutionPartyTest.java b/engine/src/test/java/dev/projectcapture/engine/ProgressionEvolutionPartyTest.java new file mode 100644 index 0000000..8af709c --- /dev/null +++ b/engine/src/test/java/dev/projectcapture/engine/ProgressionEvolutionPartyTest.java @@ -0,0 +1,99 @@ +package dev.projectcapture.engine; + +import dev.projectcapture.engine.model.ElementType; +import dev.projectcapture.engine.model.EvolutionRule; +import dev.projectcapture.engine.model.Party; +import dev.projectcapture.engine.model.SpeciesDefinition; +import dev.projectcapture.engine.service.EvolutionService; +import dev.projectcapture.engine.service.ExperienceService; +import dev.projectcapture.engine.service.PartyService; +import dev.projectcapture.engine.service.StaleRevisionException; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProgressionEvolutionPartyTest { + @Test + void experienceCurveIsMonotonicAndCapsAtLevelOneHundred() { + var service = new ExperienceService(); + assertEquals(0, service.thresholdForLevel(1)); + assertEquals(100, service.thresholdForLevel(2)); + assertEquals(100, service.levelForExperience(Long.MAX_VALUE)); + for (int level = 2; level <= 100; level++) { + assertTrue(service.thresholdForLevel(level) > service.thresholdForLevel(level - 1)); + assertEquals(level, service.levelForExperience(service.thresholdForLevel(level))); + } + } + + @Test + void grantingExperienceLevelsCreatureAndPreservesFaintedState() { + var species = TestFixtures.species("projectcapture:sprout"); + var creature = TestFixtures.creature(UUID.randomUUID(), species, TestFixtures.OWNER_ONE, 1, 4); + var service = new ExperienceService(); + var levelled = service.grantExperience(creature, species, service.thresholdForLevel(5), 0); + assertEquals(5, levelled.level()); + assertEquals(1, levelled.revision()); + assertTrue(levelled.currentHealth() > creature.currentHealth()); + assertThrows(StaleRevisionException.class, + () -> service.grantExperience(levelled, species, 1, 0)); + + var fainted = new dev.projectcapture.engine.model.CreatureInstance( + creature.schemaVersion(), creature.id(), creature.speciesId(), creature.nickname(), creature.ownerId(), + creature.level(), creature.experience(), 0, creature.genome(), creature.basicMoveId(), + creature.commandMoveIds(), creature.createdAt(), creature.revision()); + assertEquals(0, service.grantExperience(fainted, species, service.thresholdForLevel(3), 0).currentHealth()); + } + + @Test + void evolutionAppliesFirstEligibleRuleAndTargetMoveSet() { + var rule = new EvolutionRule("projectcapture:bloom", 16, Optional.of("projectcapture:leaf_stone")); + var current = TestFixtures.species("projectcapture:sprout", List.of(rule), List.of()); + var target = new SpeciesDefinition(SpeciesDefinition.CURRENT_SCHEMA_VERSION, "projectcapture:bloom", "Bloom", + List.of(ElementType.GRASS), SpeciesDefinition.Rarity.UNCOMMON, + new dev.projectcapture.engine.model.BaseStats(80, 80, 80, 80, 80, 80), 90, + "projectcapture:slam", List.of("projectcapture:a", "projectcapture:b", "projectcapture:c"), + List.of(), List.of()); + var creature = TestFixtures.creature(UUID.randomUUID(), current, TestFixtures.OWNER_ONE, 16, 2); + + assertThrows(IllegalStateException.class, () -> new EvolutionService().evolve( + creature, current, Map.of(target.id(), target), Set.of(), 0)); + var result = new EvolutionService().evolve(creature, current, Map.of(target.id(), target), + Set.of("projectcapture:leaf_stone"), 0); + assertEquals(target.id(), result.creature().speciesId()); + assertEquals(target.basicMoveId(), result.creature().basicMoveId()); + assertEquals(creature.id(), result.creature().id()); + assertSame(creature.genome(), result.creature().genome()); + assertEquals(Optional.of("projectcapture:leaf_stone"), result.consumedItemId()); + } + + @Test + void partySupportsSixMembersAndOneVisibleCompanion() { + var service = new PartyService(); + Party party = Party.empty(TestFixtures.OWNER_ONE); + var ids = new ArrayList(); + for (int index = 0; index < Party.MAX_SIZE; index++) { + var id = new UUID(10, index); + ids.add(id); + party = service.add(party, id, party.revision()); + } + Party fullParty = party; + assertThrows(IllegalStateException.class, + () -> service.add(fullParty, UUID.randomUUID(), fullParty.revision())); + + party = service.setActiveCompanion(party, Optional.of(ids.getFirst()), party.revision()); + assertEquals(Optional.of(ids.getFirst()), party.activeCompanionId()); + party = service.remove(party, ids.getFirst(), party.revision()); + assertTrue(party.activeCompanionId().isEmpty()); + assertEquals(5, party.creatureIds().size()); + } +} diff --git a/engine/src/test/java/dev/projectcapture/engine/TestFixtures.java b/engine/src/test/java/dev/projectcapture/engine/TestFixtures.java new file mode 100644 index 0000000..c47e08f --- /dev/null +++ b/engine/src/test/java/dev/projectcapture/engine/TestFixtures.java @@ -0,0 +1,78 @@ +package dev.projectcapture.engine; + +import dev.projectcapture.engine.model.BaseStats; +import dev.projectcapture.engine.model.CreatureInstance; +import dev.projectcapture.engine.model.ElementType; +import dev.projectcapture.engine.model.EvolutionRule; +import dev.projectcapture.engine.model.Genome; +import dev.projectcapture.engine.model.MoveDefinition; +import dev.projectcapture.engine.model.SpawnRule; +import dev.projectcapture.engine.model.SpeciesDefinition; +import dev.projectcapture.engine.service.ExperienceService; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +final class TestFixtures { + static final UUID OWNER_ONE = UUID.fromString("00000000-0000-0000-0000-000000000001"); + static final UUID OWNER_TWO = UUID.fromString("00000000-0000-0000-0000-000000000002"); + static final String BASIC = "projectcapture:tackle"; + static final List COMMANDS = List.of( + "projectcapture:vine_whip", + "projectcapture:growl", + "projectcapture:seed_burst"); + + private TestFixtures() { + } + + static BaseStats stats() { + return new BaseStats(50, 52, 48, 55, 50, 45); + } + + static SpeciesDefinition species(String id) { + return species(id, List.of(), List.of()); + } + + static SpeciesDefinition species( + String id, + List evolutions, + List spawnRules + ) { + return new SpeciesDefinition(SpeciesDefinition.CURRENT_SCHEMA_VERSION, id, "Test Creature", + List.of(ElementType.GRASS), SpeciesDefinition.Rarity.COMMON, stats(), 180, + BASIC, COMMANDS, evolutions, spawnRules); + } + + static Genome genome(long seed, String speciesId) { + return Genome.pure(seed, speciesId, Map.of( + "vitality", 20, + "strength", 18, + "agility", 15, + "temperament", 8)); + } + + static CreatureInstance creature(UUID id, SpeciesDefinition species, UUID owner, int level, long seed) { + long experience = new ExperienceService().thresholdForLevel(level); + return new CreatureInstance(CreatureInstance.CURRENT_SCHEMA_VERSION, id, species.id(), Optional.empty(), + Optional.ofNullable(owner), level, experience, species.baseStats().healthAtLevel(level), + genome(seed, species.id()), species.basicMoveId(), species.commandMoveIds(), Instant.EPOCH, 0); + } + + static Map moves() { + return Map.of( + BASIC, move(BASIC, 40, 1.0, 0), + COMMANDS.get(0), move(COMMANDS.get(0), 55, 1.0, 0), + COMMANDS.get(1), new MoveDefinition(MoveDefinition.CURRENT_SCHEMA_VERSION, COMMANDS.get(1), + "Growl", ElementType.NORMAL, MoveDefinition.Category.STATUS, 0, 1.0, 1), + COMMANDS.get(2), move(COMMANDS.get(2), 70, 0.85, 0)); + } + + static MoveDefinition move(String id, int power, double accuracy, int priority) { + return new MoveDefinition(MoveDefinition.CURRENT_SCHEMA_VERSION, id, id, + ElementType.NORMAL, MoveDefinition.Category.PHYSICAL, power, accuracy, priority); + } +} diff --git a/engine/src/test/java/dev/projectcapture/engine/TradeServiceTest.java b/engine/src/test/java/dev/projectcapture/engine/TradeServiceTest.java new file mode 100644 index 0000000..23fb716 --- /dev/null +++ b/engine/src/test/java/dev/projectcapture/engine/TradeServiceTest.java @@ -0,0 +1,101 @@ +package dev.projectcapture.engine; + +import dev.projectcapture.engine.model.PlayerInventory; +import dev.projectcapture.engine.model.TradeOffer; +import dev.projectcapture.engine.model.TradeTransaction; +import dev.projectcapture.engine.service.StaleRevisionException; +import dev.projectcapture.engine.service.TradeService; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TradeServiceTest { + private static final UUID CREATURE_ONE = new UUID(40, 1); + private static final UUID CREATURE_TWO = new UUID(40, 2); + private final TradeService service = new TradeService(); + + @Test + void bothPlayersMustAcceptBeforeAtomicCommit() { + var open = transaction(); + var oneAccepted = service.accept(open, TestFixtures.OWNER_ONE, 0); + assertEquals(TradeTransaction.Status.OPEN, oneAccepted.status()); + var locked = service.accept(oneAccepted, TestFixtures.OWNER_TWO, 1); + assertEquals(TradeTransaction.Status.LOCKED, locked.status()); + + var result = service.commit(locked, inventoryOne(), inventoryTwo(), + locked.revision(), 0, 0); + assertEquals(TradeTransaction.Status.COMMITTED, result.transaction().status()); + assertEquals(Set.of(CREATURE_TWO), result.playerOneInventory().creatureIds()); + assertEquals(Set.of(CREATURE_ONE), result.playerTwoInventory().creatureIds()); + assertEquals(Map.of("projectcapture:capture_device", 8, "projectcapture:herb", 1), + result.playerOneInventory().items()); + assertEquals(Map.of("projectcapture:capture_device", 2, "projectcapture:herb", 4), + result.playerTwoInventory().items()); + assertEquals(1, result.playerOneInventory().revision()); + assertEquals(1, result.playerTwoInventory().revision()); + } + + @Test + void failedCommitReturnsNoPartiallyModifiedAggregate() { + var locked = lock(transaction()); + var first = inventoryOne(); + var missingOfferedItems = new PlayerInventory(PlayerInventory.CURRENT_SCHEMA_VERSION, + TestFixtures.OWNER_TWO, Set.of(CREATURE_TWO), Map.of(), 0); + assertThrows(IllegalStateException.class, () -> service.commit(locked, first, missingOfferedItems, + locked.revision(), 0, 0)); + assertEquals(TradeTransaction.Status.LOCKED, locked.status()); + assertEquals(10, first.items().get("projectcapture:capture_device")); + assertTrue(first.creatureIds().contains(CREATURE_ONE)); + } + + @Test + void duplicateAcceptanceIsIdempotentAndStaleRevisionIsRejected() { + var open = transaction(); + var accepted = service.accept(open, TestFixtures.OWNER_ONE, 0); + assertSame(accepted, service.accept(accepted, TestFixtures.OWNER_ONE, accepted.revision())); + assertThrows(StaleRevisionException.class, + () -> service.accept(accepted, TestFixtures.OWNER_TWO, 0)); + } + + @Test + void cancellationPreventsCommitAndOutsidersCannotChangeTrade() { + var open = transaction(); + assertThrows(IllegalArgumentException.class, + () -> service.cancel(open, UUID.randomUUID(), 0)); + var cancelled = service.cancel(open, TestFixtures.OWNER_ONE, 0); + assertEquals(TradeTransaction.Status.CANCELLED, cancelled.status()); + assertThrows(IllegalStateException.class, + () -> service.accept(cancelled, TestFixtures.OWNER_TWO, cancelled.revision())); + } + + private TradeTransaction transaction() { + return service.create(new UUID(50, 1), + new TradeOffer(TestFixtures.OWNER_ONE, Set.of(CREATURE_ONE), + Map.of("projectcapture:capture_device", 2)), + new TradeOffer(TestFixtures.OWNER_TWO, Set.of(CREATURE_TWO), + Map.of("projectcapture:herb", 1))); + } + + private TradeTransaction lock(TradeTransaction transaction) { + var one = service.accept(transaction, TestFixtures.OWNER_ONE, transaction.revision()); + return service.accept(one, TestFixtures.OWNER_TWO, one.revision()); + } + + private static PlayerInventory inventoryOne() { + return new PlayerInventory(PlayerInventory.CURRENT_SCHEMA_VERSION, TestFixtures.OWNER_ONE, + Set.of(CREATURE_ONE), Map.of("projectcapture:capture_device", 10), 0); + } + + private static PlayerInventory inventoryTwo() { + return new PlayerInventory(PlayerInventory.CURRENT_SCHEMA_VERSION, TestFixtures.OWNER_TWO, + Set.of(CREATURE_TWO), Map.of("projectcapture:herb", 5), 0); + } +}