feat(engine): implement deterministic creature systems

This commit is contained in:
2026-08-15 08:50:35 +02:00
parent 86d6bbd7ce
commit 21371ee6e6
43 changed files with 2757 additions and 0 deletions
+33
View File
@@ -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<JavaCompile>().configureEach {
options.encoding = "UTF-8"
options.release = 25
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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<String> 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;
}
}
@@ -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<UUID> winnerCreatureId,
List<BattleEvent> 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);
}
}
@@ -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");
}
}
@@ -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> 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 <T> void noNulls(Collection<T> values, String name) {
notNull(values, name);
if (values.stream().anyMatch(Objects::isNull)) {
throw new IllegalArgumentException(name + " must not contain null values");
}
}
static <K, V> void noNulls(Map<K, V> 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 <T> Optional<T> optional(Optional<T> value, String name) {
return notNull(value, name);
}
}
@@ -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");
}
}
@@ -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<String> nickname,
Optional<UUID> ownerId,
int level,
long experience,
int currentHealth,
Genome genome,
String basicMoveId,
List<String> 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;
}
}
@@ -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);
}
}
@@ -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<UUID> acceptedBy,
Status status,
Optional<UUID> winnerId,
Optional<String> 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");
}
}
@@ -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
}
@@ -0,0 +1,21 @@
package dev.projectcapture.engine.model;
import java.util.Optional;
public record EvolutionRule(
String targetSpeciesId,
int minimumLevel,
Optional<String> 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<String> availableItemIds) {
Checks.notNull(availableItemIds, "availableItemIds");
return level >= minimumLevel && requiredItemId.map(availableItemIds::contains).orElse(true);
}
}
@@ -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<String, Integer> traits,
List<LineageContribution> lineages,
Set<String> 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<String, Integer>();
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<String, Integer> 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();
}
}
@@ -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<UUID> parentOneId,
Optional<UUID> 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);
}
}
@@ -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");
}
}
@@ -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");
}
}
}
@@ -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<UUID> creatureIds,
Optional<UUID> 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);
}
}
@@ -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<UUID> creatureIds,
Map<String, Integer> 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");
}
}
@@ -0,0 +1,35 @@
package dev.projectcapture.engine.model;
import java.util.Set;
public record SpawnRule(
Set<String> biomeTags,
Set<TimeOfDay> 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");
}
}
@@ -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<ElementType> types,
Rarity rarity,
BaseStats baseStats,
int captureRate,
String basicMoveId,
List<String> commandMoveIds,
List<EvolutionRule> evolutions,
List<SpawnRule> 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);
}
}
@@ -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<UUID> creatureIds,
Map<String, Integer> 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();
}
}
@@ -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<UUID> 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");
}
}
@@ -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<UUID, String> commandedMoves,
Map<String, MoveDefinition> 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<UUID> 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<Action>();
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<UUID, BattleParticipant>();
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<BattleEvent>();
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<UUID> 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<UUID, String> commands,
Map<String, MoveDefinition> 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) {
}
}
@@ -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");
}
}
}
}
@@ -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");
}
}
}
}
@@ -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<UUID> acceptedBy,
DuelSession.Status status,
Optional<UUID> winner,
Optional<String> 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());
}
}
}
@@ -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<String, SpeciesDefinition> speciesCatalog,
Set<String> 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<String> consumedItemId) {
public EvolutionResult {
if (creature == null || consumedItemId == null) {
throw new NullPointerException("evolution result values must not be null");
}
}
}
}
@@ -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);
}
}
}
@@ -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<String>();
traitNames.addAll(parentOne.traits().keySet());
traitNames.addAll(parentTwo.traits().keySet());
var traits = new HashMap<String, Integer>();
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<LineageContribution> mergeLineages(Genome first, Genome second) {
var rawWeights = new HashMap<String, Long>();
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.<String, Long>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<Integer>();
for (int index = 0; index < selected.size(); index++) {
remainderOrder.add(index);
}
remainderOrder.sort(Comparator.<Integer>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<LineageContribution>();
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);
}
}
@@ -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);
}
}
@@ -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<UUID> 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());
}
}
}
@@ -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<SpawnSelection> select(
List<SpeciesDefinition> 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<Candidate>();
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");
}
}
}
}
@@ -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);
}
}
@@ -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<UUID> exchangeCreatures(Set<UUID> 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<String, Integer> exchangeItems(
Map<String, Integer> 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<UUID> 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");
}
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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<LineageContribution> lineages) {
return new Genome(Genome.CURRENT_SCHEMA_VERSION, seed,
Map.of("vitality", 1, "strength", 31, "agility", 15), lineages, Set.of());
}
}
@@ -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<String, Integer>();
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<UUID>();
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));
}
}
@@ -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<UUID>();
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());
}
}
@@ -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<String> 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<EvolutionRule> evolutions,
List<SpawnRule> 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<String, MoveDefinition> 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);
}
}
@@ -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);
}
}