feat(engine): implement deterministic creature systems
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user