feat: add multiplayer capture vertical slice

This commit is contained in:
2026-08-15 09:09:17 +02:00
parent 9229040382
commit c418e2f36c
24 changed files with 3989 additions and 47 deletions
@@ -2,6 +2,9 @@ package dev.projectcapture.hytale;
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
import dev.projectcapture.hytale.command.ProjectCaptureCommands;
import dev.projectcapture.hytale.content.RuntimeContentCatalog;
import dev.projectcapture.hytale.gameplay.MultiplayerCreatureService;
import dev.projectcapture.hytale.persistence.JsonStateStore;
import java.util.concurrent.CompletableFuture;
@@ -13,6 +16,8 @@ import java.util.concurrent.CompletableFuture;
public final class ProjectCapturePlugin extends JavaPlugin {
private final JsonStateStore stateStore;
private final FeatureFlags featureFlags;
private RuntimeContentCatalog contentCatalog;
private MultiplayerCreatureService multiplayerService;
public ProjectCapturePlugin(JavaPluginInit init) {
super(init);
@@ -23,6 +28,9 @@ public final class ProjectCapturePlugin extends JavaPlugin {
@Override
public CompletableFuture<Void> preLoad() {
stateStore.initialize();
contentCatalog = RuntimeContentCatalog.load(getClass().getClassLoader());
multiplayerService = new MultiplayerCreatureService(stateStore, contentCatalog);
ProjectCaptureCommands.register(this, multiplayerService);
return CompletableFuture.completedFuture(null);
}
@@ -33,5 +41,18 @@ public final class ProjectCapturePlugin extends JavaPlugin {
FeatureFlags featureFlags() {
return featureFlags;
}
}
RuntimeContentCatalog contentCatalog() {
if (contentCatalog == null) {
throw new IllegalStateException("Content catalog is not loaded before preLoad");
}
return contentCatalog;
}
MultiplayerCreatureService multiplayerService() {
if (multiplayerService == null) {
throw new IllegalStateException("Multiplayer service is not loaded before preLoad");
}
return multiplayerService;
}
}
@@ -0,0 +1,170 @@
package dev.projectcapture.hytale.command;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.command.system.CommandContext;
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import dev.projectcapture.hytale.gameplay.MultiplayerCreatureService;
import java.util.UUID;
/** Registers the server-authoritative playable multiplayer vertical-slice commands. */
public final class ProjectCaptureCommands {
private ProjectCaptureCommands() {
}
public static void register(JavaPlugin plugin, MultiplayerCreatureService service) {
plugin.getCommandRegistry().registerCommand(new RootCommand(service));
}
private static final class RootCommand extends AbstractCommandCollection {
private RootCommand(MultiplayerCreatureService service) {
super("projectcapture", "ProjectCapture creature commands");
addAliases("pc");
addSubCommand(new PartyCommand(service));
addSubCommand(new CompanionCommand(service));
addSubCommand(new CaptureCommand(service));
addSubCommand(new EncounterCommand(service));
addSubCommand(new GrantCapsulesCommand(service));
}
}
private abstract static class PlayerCommand extends AbstractPlayerCommand {
protected final MultiplayerCreatureService service;
private PlayerCommand(String name, String description, MultiplayerCreatureService service) {
super(name, description);
this.service = service;
}
protected final UUID playerId(PlayerRef player) {
return player.getUuid();
}
protected final void message(CommandContext context, String text) {
context.sendMessage(Message.raw("[ProjectCapture] " + text));
}
}
private static final class PartyCommand extends PlayerCommand {
private PartyCommand(MultiplayerCreatureService service) {
super("party", "Show your ProjectCapture party", service);
}
@Override
protected void execute(
CommandContext context,
Store<EntityStore> store,
Ref<EntityStore> playerRef,
PlayerRef player,
World world
) {
MultiplayerCreatureService.PartyView party = service.party(playerId(player));
String creatures = party.creatures().isEmpty()
? "empty"
: party.creatures().stream()
.map(creature -> creature.speciesId() + " Lv." + creature.level())
.collect(java.util.stream.Collectors.joining(", "));
message(context, "Party: " + creatures + " | Field capsules: " + party.fieldCapsules()
+ " | Companion: " + party.activeCompanionId().map(UUID::toString).orElse("none"));
}
}
private static final class CompanionCommand extends PlayerCommand {
private final RequiredArg<UUID> creatureId = withRequiredArg("creatureId", "UUID from /pc party", ArgTypes.UUID);
private CompanionCommand(MultiplayerCreatureService service) {
super("companion", "Set a captured party creature as your companion", service);
}
@Override
protected void execute(
CommandContext context,
Store<EntityStore> store,
Ref<EntityStore> playerRef,
PlayerRef player,
World world
) {
UUID id = context.get(creatureId);
service.setCompanion(playerId(player), id);
message(context, "Companion saved: " + id);
}
}
private static final class CaptureCommand extends PlayerCommand {
private CaptureCommand(MultiplayerCreatureService service) {
super("capture", "Throw one crafted field capsule at your active encounter", service);
}
@Override
protected void execute(
CommandContext context,
Store<EntityStore> store,
Ref<EntityStore> playerRef,
PlayerRef player,
World world
) {
MultiplayerCreatureService.CaptureOutcome outcome = service.capture(playerId(player), System.nanoTime());
if (outcome.captured()) {
message(context, "Captured " + outcome.creatureId().orElseThrow() + " ("
+ Math.round(outcome.probability() * 100) + "% chance)."
+ (outcome.addedToParty() ? " Added to your party." : " Your party is full; stored safely."));
} else {
message(context, "The creature broke free ("
+ Math.round(outcome.probability() * 100) + "% chance). Try again with another capsule.");
}
}
}
private static final class EncounterCommand extends PlayerCommand {
private final RequiredArg<String> speciesId = withRequiredArg("speciesId", "Namespaced species ID", ArgTypes.STRING);
private EncounterCommand(MultiplayerCreatureService service) {
super("encounter", "Development command: open a personal wild encounter", service);
requirePermission("projectcapture.debug.encounter");
}
@Override
protected void execute(
CommandContext context,
Store<EntityStore> store,
Ref<EntityStore> playerRef,
PlayerRef player,
World world
) {
MultiplayerCreatureService.EncounterView encounter = service.openEncounter(
playerId(player), context.get(speciesId), 5, System.nanoTime());
message(context, "Personal wild encounter: " + encounter.speciesId() + " Lv." + encounter.level());
}
}
private static final class GrantCapsulesCommand extends PlayerCommand {
private final RequiredArg<Integer> amount = withRequiredArg("amount", "Number of field capsules", ArgTypes.INTEGER);
private GrantCapsulesCommand(MultiplayerCreatureService service) {
super("grantcapsules", "Development command: grant crafted field-capsule test inventory", service);
requirePermission("projectcapture.debug.grantcapsules");
}
@Override
protected void execute(
CommandContext context,
Store<EntityStore> store,
Ref<EntityStore> playerRef,
PlayerRef player,
World world
) {
int requested = context.get(amount);
service.grantFieldCapsules(playerId(player), requested);
message(context, "Added " + requested + " field capsules to your server-authoritative test inventory.");
}
}
}
@@ -0,0 +1,246 @@
package dev.projectcapture.hytale.content;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.projectcapture.engine.model.BaseStats;
import dev.projectcapture.engine.model.ElementType;
import dev.projectcapture.engine.model.EvolutionRule;
import dev.projectcapture.engine.model.MoveDefinition;
import dev.projectcapture.engine.model.SpawnRule;
import dev.projectcapture.engine.model.SpeciesDefinition;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/**
* Loads the embedded, versioned fan-content documents into the Hytale-free
* engine models. Invalid or incomplete content fails during plugin pre-load.
*/
public record RuntimeContentCatalog(
Map<String, SpeciesDefinition> species,
Map<String, MoveDefinition> moves
) {
public static final String RESOURCE_ROOT = "projectcapture/content/v1/";
private static final int SCHEMA_VERSION = 1;
public RuntimeContentCatalog {
species = Map.copyOf(Objects.requireNonNull(species, "species"));
moves = Map.copyOf(Objects.requireNonNull(moves, "moves"));
if (species.isEmpty() || moves.isEmpty()) {
throw new IllegalArgumentException("Runtime content catalog must not be empty");
}
}
public static RuntimeContentCatalog load(ClassLoader classLoader) {
Objects.requireNonNull(classLoader, "classLoader");
ObjectMapper json = new ObjectMapper().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
JsonNode moveDocument = read(json, classLoader, "moves.json");
JsonNode speciesDocument = read(json, classLoader, "species.json");
JsonNode spawnDocument = read(json, classLoader, "spawn-rules.json");
Map<String, MoveDefinition> moves = parseMoves(moveDocument);
Map<String, List<SpawnRule>> spawns = parseSpawns(spawnDocument);
Map<String, SpeciesDefinition> species = parseSpecies(speciesDocument, spawns);
validateReferences(species, moves);
return new RuntimeContentCatalog(species, moves);
}
private static JsonNode read(ObjectMapper json, ClassLoader classLoader, String name) {
String resource = RESOURCE_ROOT + name;
try (InputStream input = classLoader.getResourceAsStream(resource)) {
if (input == null) {
throw new IllegalStateException("Missing embedded content document: " + resource);
}
JsonNode document = json.readTree(input);
requireHeader(document, resource);
return document;
} catch (IOException exception) {
throw new IllegalStateException("Could not read embedded content document: " + resource, exception);
}
}
private static void requireHeader(JsonNode document, String resource) {
if (document == null || !document.isObject()) {
throw new IllegalStateException("Content document must be an object: " + resource);
}
if (document.path("schemaVersion").asInt(-1) != SCHEMA_VERSION) {
throw new IllegalStateException("Unsupported content schema in " + resource);
}
if (!document.path("unofficialFanContent").asBoolean(false)) {
throw new IllegalStateException("Missing unofficial fan-content marker in " + resource);
}
if (!document.path("items").isArray()) {
throw new IllegalStateException("Content document has no items array: " + resource);
}
}
private static Map<String, MoveDefinition> parseMoves(JsonNode document) {
Map<String, MoveDefinition> result = new LinkedHashMap<>();
for (JsonNode item : document.path("items")) {
MoveDefinition move = new MoveDefinition(
MoveDefinition.CURRENT_SCHEMA_VERSION,
requiredText(item, "id"),
requiredText(item, "displayName"),
ElementType.valueOf(requiredText(item, "type")),
MoveDefinition.Category.valueOf(requiredText(item, "category")),
requiredInt(item, "power"),
requiredDouble(item, "accuracy"),
requiredInt(item, "priority")
);
if (result.putIfAbsent(move.id(), move) != null) {
throw new IllegalStateException("Duplicate move ID: " + move.id());
}
}
return result;
}
private static Map<String, List<SpawnRule>> parseSpawns(JsonNode document) {
Map<String, List<SpawnRule>> result = new LinkedHashMap<>();
for (JsonNode item : document.path("items")) {
String speciesId = requiredText(item, "speciesId");
Set<String> biomes = values(item.path("biomes")).stream()
.map(value -> "projectcapture:" + value.toLowerCase(Locale.ROOT))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
String time = requiredText(item, "time");
Set<SpawnRule.TimeOfDay> times = "ANY".equals(time)
? EnumSet.allOf(SpawnRule.TimeOfDay.class)
: Set.of(SpawnRule.TimeOfDay.valueOf(time));
SpawnRule rule = new SpawnRule(
biomes,
times,
requiredInt(item, "rarityWeight"),
requiredInt(item, "minimumLevel"),
requiredInt(item, "maximumLevel")
);
result.computeIfAbsent(speciesId, ignored -> new ArrayList<>()).add(rule);
}
return result;
}
private static Map<String, SpeciesDefinition> parseSpecies(
JsonNode document,
Map<String, List<SpawnRule>> spawns
) {
Map<String, SpeciesDefinition> result = new LinkedHashMap<>();
for (JsonNode item : document.path("items")) {
String id = requiredText(item, "id");
JsonNode stats = item.path("baseStats");
int power = requiredInt(stats, "power");
int guard = requiredInt(stats, "guard");
BaseStats baseStats = new BaseStats(
requiredInt(stats, "vitality"),
power,
guard,
power,
guard,
requiredInt(stats, "agility")
);
List<ElementType> types = values(item.path("types")).stream()
.map(ElementType::valueOf)
.toList();
List<EvolutionRule> evolutions = parseEvolution(item.path("evolution"));
SpeciesDefinition definition = new SpeciesDefinition(
SpeciesDefinition.CURRENT_SCHEMA_VERSION,
id,
requiredText(item, "displayName"),
types,
SpeciesDefinition.Rarity.valueOf(requiredText(item, "rarity")),
baseStats,
requiredInt(item, "captureRate"),
requiredText(item, "basicMoveId"),
values(item.path("commandMoveIds")),
evolutions,
List.copyOf(spawns.getOrDefault(id, List.of()))
);
if (definition.spawnRules().isEmpty()) {
throw new IllegalStateException("Species has no runtime spawn rule: " + id);
}
if (result.putIfAbsent(id, definition) != null) {
throw new IllegalStateException("Duplicate species ID: " + id);
}
}
return result;
}
private static List<EvolutionRule> parseEvolution(JsonNode evolution) {
if (evolution.isMissingNode() || evolution.isNull()) {
return List.of();
}
String method = requiredText(evolution, "method");
int minimumLevel = "LEVEL".equals(method) ? requiredInt(evolution, "minimumLevel") : 1;
Optional<String> item = "ITEM".equals(method)
? Optional.of(requiredText(evolution, "itemId"))
: Optional.empty();
return List.of(new EvolutionRule(requiredText(evolution, "targetSpeciesId"), minimumLevel, item));
}
private static void validateReferences(
Map<String, SpeciesDefinition> species,
Map<String, MoveDefinition> moves
) {
for (SpeciesDefinition definition : species.values()) {
if (!moves.containsKey(definition.basicMoveId())) {
throw new IllegalStateException("Unknown basic move for " + definition.id());
}
for (String moveId : definition.commandMoveIds()) {
if (!moves.containsKey(moveId)) {
throw new IllegalStateException("Unknown command move for " + definition.id() + ": " + moveId);
}
}
for (EvolutionRule evolution : definition.evolutions()) {
if (!species.containsKey(evolution.targetSpeciesId())) {
throw new IllegalStateException(
"Unknown evolution target for " + definition.id() + ": " + evolution.targetSpeciesId());
}
}
}
}
private static List<String> values(JsonNode array) {
if (!array.isArray() || array.isEmpty()) {
throw new IllegalStateException("Expected a non-empty string array");
}
List<String> result = new ArrayList<>();
for (JsonNode value : array) {
if (!value.isTextual() || value.textValue().isBlank()) {
throw new IllegalStateException("Expected a non-blank string array value");
}
result.add(value.textValue());
}
return List.copyOf(result);
}
private static String requiredText(JsonNode node, String field) {
JsonNode value = node.path(field);
if (!value.isTextual() || value.textValue().isBlank()) {
throw new IllegalStateException("Missing non-blank text field: " + field);
}
return value.textValue();
}
private static int requiredInt(JsonNode node, String field) {
JsonNode value = node.path(field);
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
throw new IllegalStateException("Missing integer field: " + field);
}
return value.intValue();
}
private static double requiredDouble(JsonNode node, String field) {
JsonNode value = node.path(field);
if (!value.isNumber()) {
throw new IllegalStateException("Missing numeric field: " + field);
}
return value.doubleValue();
}
}
@@ -0,0 +1,355 @@
package dev.projectcapture.hytale.gameplay;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.projectcapture.engine.model.CaptureContext;
import dev.projectcapture.engine.model.CreatureInstance;
import dev.projectcapture.engine.model.Genome;
import dev.projectcapture.engine.model.LineageContribution;
import dev.projectcapture.engine.model.Party;
import dev.projectcapture.engine.model.SpeciesDefinition;
import dev.projectcapture.engine.service.CaptureService;
import dev.projectcapture.engine.service.PartyService;
import dev.projectcapture.hytale.content.RuntimeContentCatalog;
import dev.projectcapture.hytale.persistence.JsonStateStore;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Server-authoritative multiplayer state for the playable capture vertical slice.
* Every mutation is synchronized and atomically persisted through JsonStateStore.
*/
public final class MultiplayerCreatureService {
private static final int STATE_SCHEMA_VERSION = 1;
private static final String SAVE_ID = "multiplayer";
private static final String FIELD_CAPSULE = "projectcapture:field-capsule";
private final JsonStateStore stateStore;
private final RuntimeContentCatalog catalog;
private final CaptureService captureService = new CaptureService();
private final PartyService partyService = new PartyService();
private final ObjectMapper json = new ObjectMapper();
private final Map<UUID, PlayerProfile> players = new HashMap<>();
public MultiplayerCreatureService(JsonStateStore stateStore, RuntimeContentCatalog catalog) {
this.stateStore = java.util.Objects.requireNonNull(stateStore, "stateStore");
this.catalog = java.util.Objects.requireNonNull(catalog, "catalog");
load();
}
public synchronized EncounterView openEncounter(UUID playerId, String speciesId, int level, long seed) {
SpeciesDefinition species = species(speciesId);
if (level < 1 || level > 100) {
throw new IllegalArgumentException("Encounter level must be between 1 and 100");
}
PlayerProfile profile = player(playerId);
CreatureInstance wild = newCreature(species, Optional.empty(), level, seed);
profile.encounter = wild;
persist();
return toEncounterView(wild);
}
public synchronized CaptureOutcome capture(UUID playerId, long seed) {
PlayerProfile profile = player(playerId);
CreatureInstance wild = Optional.ofNullable(profile.encounter)
.orElseThrow(() -> new IllegalStateException("No active encounter for this player"));
int capsules = profile.items.getOrDefault(FIELD_CAPSULE, 0);
if (capsules < 1) {
throw new IllegalStateException("A crafted field capsule is required to capture a creature");
}
SpeciesDefinition species = species(wild.speciesId());
int maximumHealth = species.baseStats().healthAtLevel(wild.level());
CaptureService.CaptureResult result = captureService.attempt(new CaptureContext(
species.captureRate(), species.rarity(), wild.level(), wild.currentHealth(), maximumHealth,
1.0, CaptureContext.StatusCondition.NONE
), seed);
decrement(profile.items, FIELD_CAPSULE);
if (!result.captured()) {
persist();
return new CaptureOutcome(false, result.probability(), result.roll(), Optional.empty(), false);
}
CreatureInstance owned = new CreatureInstance(
wild.schemaVersion(), wild.id(), wild.speciesId(), wild.nickname(), Optional.of(playerId),
wild.level(), wild.experience(), wild.currentHealth(), wild.genome(), wild.basicMoveId(),
wild.commandMoveIds(), wild.createdAt(), wild.revision() + 1
);
profile.creatures.put(owned.id(), owned);
boolean addedToParty = profile.party.creatureIds().size() < Party.MAX_SIZE;
if (addedToParty) {
profile.party = partyService.add(profile.party, owned.id(), profile.party.revision());
}
profile.encounter = null;
persist();
return new CaptureOutcome(true, result.probability(), result.roll(), Optional.of(owned.id()), addedToParty);
}
public synchronized void grantFieldCapsules(UUID playerId, int amount) {
if (amount < 1 || amount > 999) {
throw new IllegalArgumentException("Amount must be between 1 and 999");
}
PlayerProfile profile = player(playerId);
profile.items.merge(FIELD_CAPSULE, amount, Math::addExact);
persist();
}
public synchronized PartyView party(UUID playerId) {
PlayerProfile profile = player(playerId);
List<CreatureView> creatures = profile.party.creatureIds().stream()
.map(profile.creatures::get)
.filter(java.util.Objects::nonNull)
.map(this::toCreatureView)
.toList();
return new PartyView(profile.party.revision(), creatures, profile.party.activeCompanionId(),
profile.items.getOrDefault(FIELD_CAPSULE, 0));
}
public synchronized void setCompanion(UUID playerId, UUID creatureId) {
PlayerProfile profile = player(playerId);
if (!profile.creatures.containsKey(creatureId)) {
throw new IllegalArgumentException("Creature does not belong to this player");
}
profile.party = partyService.setActiveCompanion(profile.party, Optional.of(creatureId), profile.party.revision());
persist();
}
public synchronized Optional<EncounterView> activeEncounter(UUID playerId) {
return Optional.ofNullable(player(playerId).encounter).map(this::toEncounterView);
}
private SpeciesDefinition species(String speciesId) {
SpeciesDefinition species = catalog.species().get(speciesId);
if (species == null) {
throw new IllegalArgumentException("Unknown species: " + speciesId);
}
return species;
}
private PlayerProfile player(UUID playerId) {
if (playerId == null) {
throw new NullPointerException("playerId must not be null");
}
return players.computeIfAbsent(playerId, PlayerProfile::new);
}
private CreatureInstance newCreature(SpeciesDefinition species, Optional<UUID> ownerId, int level, long seed) {
int maximumHealth = species.baseStats().healthAtLevel(level);
Map<String, Integer> traits = Map.of(
"resilience", Math.floorMod(seed, 32),
"instinct", Math.floorMod(seed >>> 5, 32),
"temperament", Math.floorMod(seed >>> 10, 32)
);
return new CreatureInstance(
CreatureInstance.CURRENT_SCHEMA_VERSION,
UUID.randomUUID(),
species.id(),
Optional.empty(),
ownerId,
level,
experienceFor(level),
maximumHealth,
Genome.pure(seed, species.id(), traits),
species.basicMoveId(),
species.commandMoveIds(),
Instant.now(),
0
);
}
private static long experienceFor(int level) {
long offset = level - 1L;
return offset * offset * offset * 100L;
}
private void load() {
JsonNode root = stateStore.read(SAVE_ID);
if (root == null) {
return;
}
if (root.path("schemaVersion").asInt(-1) != STATE_SCHEMA_VERSION) {
throw new IllegalStateException("Unsupported multiplayer state schema");
}
JsonNode playerArray = root.path("players");
if (!playerArray.isArray()) {
throw new IllegalStateException("Invalid multiplayer state: players is not an array");
}
for (JsonNode node : playerArray) {
UUID owner = UUID.fromString(requiredText(node, "ownerId"));
PlayerProfile profile = new PlayerProfile(owner);
for (JsonNode creatureNode : node.path("creatures")) {
CreatureInstance creature = readCreature(creatureNode);
if (!creature.ownerId().equals(Optional.of(owner))) {
throw new IllegalStateException("Persisted creature ownership mismatch");
}
profile.creatures.put(creature.id(), creature);
}
List<UUID> partyIds = new ArrayList<>();
for (JsonNode id : node.path("party")) {
partyIds.add(UUID.fromString(id.asText()));
}
Optional<UUID> companion = optionalUuid(node, "activeCompanionId");
profile.party = new Party(Party.CURRENT_SCHEMA_VERSION, owner, partyIds, companion,
node.path("partyRevision").asLong(0));
node.path("items").properties().forEach(entry -> profile.items.put(entry.getKey(), entry.getValue().asInt()));
JsonNode encounter = node.path("encounter");
if (!encounter.isMissingNode() && !encounter.isNull()) {
profile.encounter = readCreature(encounter);
if (profile.encounter.ownerId().isPresent()) {
throw new IllegalStateException("Wild encounter cannot have an owner");
}
}
players.put(owner, profile);
}
}
private void persist() {
ObjectNode root = json.createObjectNode().put("schemaVersion", STATE_SCHEMA_VERSION);
ArrayNode playerArray = root.putArray("players");
players.values().stream().sorted(java.util.Comparator.comparing(profile -> profile.ownerId.toString()))
.forEach(profile -> writeProfile(playerArray.addObject(), profile));
stateStore.write(SAVE_ID, root);
}
private void writeProfile(ObjectNode node, PlayerProfile profile) {
node.put("ownerId", profile.ownerId.toString());
node.put("partyRevision", profile.party.revision());
profile.party.activeCompanionId().ifPresent(id -> node.put("activeCompanionId", id.toString()));
ArrayNode party = node.putArray("party");
profile.party.creatureIds().forEach(id -> party.add(id.toString()));
ObjectNode items = node.putObject("items");
profile.items.forEach(items::put);
ArrayNode creatures = node.putArray("creatures");
profile.creatures.values().stream().sorted(java.util.Comparator.comparing(creature -> creature.id().toString()))
.forEach(creature -> writeCreature(creatures.addObject(), creature));
if (profile.encounter != null) {
writeCreature(node.putObject("encounter"), profile.encounter);
}
}
private static void writeCreature(ObjectNode node, CreatureInstance creature) {
node.put("id", creature.id().toString());
node.put("speciesId", creature.speciesId());
creature.ownerId().ifPresent(owner -> node.put("ownerId", owner.toString()));
creature.nickname().ifPresent(nickname -> node.put("nickname", nickname));
node.put("level", creature.level());
node.put("experience", creature.experience());
node.put("currentHealth", creature.currentHealth());
node.put("basicMoveId", creature.basicMoveId());
node.put("createdAtEpochMilli", creature.createdAt().toEpochMilli());
node.put("revision", creature.revision());
ArrayNode commands = node.putArray("commandMoveIds");
creature.commandMoveIds().forEach(commands::add);
ObjectNode genome = node.putObject("genome");
genome.put("seed", creature.genome().seed());
ObjectNode traits = genome.putObject("traits");
creature.genome().traits().forEach(traits::put);
ArrayNode revealed = genome.putArray("revealedTraits");
creature.genome().revealedTraits().forEach(revealed::add);
}
private CreatureInstance readCreature(JsonNode node) {
String speciesId = requiredText(node, "speciesId");
Map<String, Integer> traits = new LinkedHashMap<>();
node.path("genome").path("traits").properties().forEach(entry -> traits.put(entry.getKey(), entry.getValue().asInt()));
List<String> commands = new ArrayList<>();
node.path("commandMoveIds").forEach(command -> commands.add(command.asText()));
java.util.Set<String> revealed = new java.util.LinkedHashSet<>();
node.path("genome").path("revealedTraits").forEach(value -> revealed.add(value.asText()));
return new CreatureInstance(
CreatureInstance.CURRENT_SCHEMA_VERSION,
UUID.fromString(requiredText(node, "id")),
speciesId,
optionalText(node, "nickname"),
optionalUuid(node, "ownerId"),
node.path("level").asInt(),
node.path("experience").asLong(),
node.path("currentHealth").asInt(),
new Genome(
Genome.CURRENT_SCHEMA_VERSION,
node.path("genome").path("seed").asLong(),
traits,
List.of(new LineageContribution(speciesId, Genome.TOTAL_BASIS_POINTS)),
revealed),
requiredText(node, "basicMoveId"),
commands,
Instant.ofEpochMilli(node.path("createdAtEpochMilli").asLong()),
node.path("revision").asLong()
);
}
private static void decrement(Map<String, Integer> items, String itemId) {
int current = items.getOrDefault(itemId, 0);
if (current <= 1) {
items.remove(itemId);
} else {
items.put(itemId, current - 1);
}
}
private static String requiredText(JsonNode node, String name) {
String value = node.path(name).asText("");
if (value.isBlank()) {
throw new IllegalStateException("Missing persisted field: " + name);
}
return value;
}
private static Optional<String> optionalText(JsonNode node, String name) {
String value = node.path(name).asText("");
return value.isBlank() ? Optional.empty() : Optional.of(value);
}
private static Optional<UUID> optionalUuid(JsonNode node, String name) {
String value = node.path(name).asText("");
return value.isBlank() ? Optional.empty() : Optional.of(UUID.fromString(value));
}
private CreatureView toCreatureView(CreatureInstance creature) {
return new CreatureView(creature.id(), creature.speciesId(), creature.level(), creature.currentHealth());
}
private EncounterView toEncounterView(CreatureInstance creature) {
return new EncounterView(creature.id(), creature.speciesId(), creature.level(), creature.currentHealth());
}
private static final class PlayerProfile {
private final UUID ownerId;
private final Map<UUID, CreatureInstance> creatures = new LinkedHashMap<>();
private final Map<String, Integer> items = new LinkedHashMap<>();
private Party party;
private CreatureInstance encounter;
private PlayerProfile(UUID ownerId) {
this.ownerId = ownerId;
this.party = Party.empty(ownerId);
}
}
public record EncounterView(UUID creatureId, String speciesId, int level, int currentHealth) {
}
public record CreatureView(UUID creatureId, String speciesId, int level, int currentHealth) {
}
public record PartyView(long revision, List<CreatureView> creatures, Optional<UUID> activeCompanionId, int fieldCapsules) {
public PartyView {
creatures = List.copyOf(creatures);
activeCompanionId = Optional.ofNullable(activeCompanionId).orElseThrow();
}
}
public record CaptureOutcome(boolean captured, double probability, double roll, Optional<UUID> creatureId, boolean addedToParty) {
public CaptureOutcome {
creatureId = Optional.ofNullable(creatureId).orElseThrow();
}
}
}
@@ -0,0 +1,46 @@
package dev.projectcapture.hytale.content;
import dev.projectcapture.engine.model.ElementType;
import dev.projectcapture.engine.model.SpawnRule;
import dev.projectcapture.engine.service.SpawnService;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RuntimeContentCatalogTest {
@Test
void loadsAllFanSpeciesAndMovesIntoEngineModels() {
RuntimeContentCatalog catalog = RuntimeContentCatalog.load(getClass().getClassLoader());
assertThat(catalog.species()).hasSize(12);
assertThat(catalog.moves()).hasSize(17);
assertThat(catalog.species().get("projectcapture:bulbasaur").types())
.containsExactly(ElementType.GRASS, ElementType.POISON);
assertThat(catalog.species().get("projectcapture:charizard").types())
.containsExactly(ElementType.FIRE, ElementType.FLYING);
}
@Test
void embeddedCatalogCanDriveDeterministicSpawnSelection() {
RuntimeContentCatalog catalog = RuntimeContentCatalog.load(getClass().getClassLoader());
SpawnService service = new SpawnService();
SpawnService.SpawnSelection first = service.select(
List.copyOf(catalog.species().values()),
"projectcapture:forest",
SpawnRule.TimeOfDay.DAY,
42L
).orElseThrow();
SpawnService.SpawnSelection second = service.select(
List.copyOf(catalog.species().values()),
"projectcapture:forest",
SpawnRule.TimeOfDay.DAY,
42L
).orElseThrow();
assertThat(second).isEqualTo(first);
assertThat(first.speciesId()).isIn("projectcapture:bulbasaur", "projectcapture:pikachu");
}
}
@@ -0,0 +1,57 @@
package dev.projectcapture.hytale.gameplay;
import dev.projectcapture.hytale.content.RuntimeContentCatalog;
import dev.projectcapture.hytale.persistence.JsonStateStore;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MultiplayerCreatureServiceTest {
@TempDir
Path temporaryDirectory;
@Test
void captureIsPlayerScopedAndSurvivesServerRestart() {
JsonStateStore store = initializedStore();
MultiplayerCreatureService service = new MultiplayerCreatureService(store, catalog());
UUID firstPlayer = UUID.randomUUID();
UUID secondPlayer = UUID.randomUUID();
service.grantFieldCapsules(firstPlayer, 200);
service.openEncounter(firstPlayer, "projectcapture:bulbasaur", 4, 42L);
assertThatThrownBy(() -> service.capture(secondPlayer, 7L))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("No active encounter");
MultiplayerCreatureService.CaptureOutcome outcome = null;
for (long seed = 1; seed < 10_000 && (outcome == null || !outcome.captured()); seed++) {
outcome = service.capture(firstPlayer, seed);
}
assertThat(outcome).isNotNull();
assertThat(outcome.captured()).isTrue();
UUID captured = outcome.creatureId().orElseThrow();
service.setCompanion(firstPlayer, captured);
MultiplayerCreatureService restarted = new MultiplayerCreatureService(store, catalog());
MultiplayerCreatureService.PartyView party = restarted.party(firstPlayer);
assertThat(party.creatures()).extracting(MultiplayerCreatureService.CreatureView::creatureId).contains(captured);
assertThat(party.activeCompanionId()).contains(captured);
assertThat(restarted.party(secondPlayer).creatures()).isEmpty();
}
private JsonStateStore initializedStore() {
JsonStateStore store = new JsonStateStore(temporaryDirectory);
store.initialize();
return store;
}
private RuntimeContentCatalog catalog() {
return RuntimeContentCatalog.load(getClass().getClassLoader());
}
}