feat: add multiplayer capture vertical slice
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+170
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -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();
|
||||
}
|
||||
}
|
||||
+355
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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");
|
||||
}
|
||||
}
|
||||
+57
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user