336 lines
13 KiB
Java
336 lines
13 KiB
Java
package games.dmg.spigottyrant;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.nio.file.StandardCopyOption;
|
|
import java.time.Duration;
|
|
import java.time.Instant;
|
|
import java.time.format.DateTimeParseException;
|
|
import java.util.ArrayList;
|
|
import java.util.EnumMap;
|
|
import java.util.EnumSet;
|
|
import java.util.HashMap;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Optional;
|
|
import java.util.Set;
|
|
import java.util.UUID;
|
|
import org.bukkit.configuration.ConfigurationSection;
|
|
import org.bukkit.configuration.InvalidConfigurationException;
|
|
import org.bukkit.configuration.file.YamlConfiguration;
|
|
import org.bukkit.entity.EntityType;
|
|
|
|
public final class YamlTyrantStateRepository {
|
|
private static final String PLAYERS = "players";
|
|
private final Path stateFile;
|
|
|
|
public YamlTyrantStateRepository(Path stateFile) {
|
|
this.stateFile = stateFile;
|
|
}
|
|
|
|
public PersistentState load() throws IOException {
|
|
if (!Files.exists(stateFile)) {
|
|
return PersistentState.empty();
|
|
}
|
|
YamlConfiguration yaml = loadYaml();
|
|
return new PersistentState(loadGameSafely(yaml), loadPlayers(yaml));
|
|
}
|
|
|
|
public void save(PersistentState state) throws IOException {
|
|
Path parent = stateFile.toAbsolutePath().getParent();
|
|
if (parent != null) {
|
|
Files.createDirectories(parent);
|
|
}
|
|
YamlConfiguration yaml = Files.exists(stateFile) ? loadYaml() : new YamlConfiguration();
|
|
saveGame(yaml, state.game());
|
|
removeAbsentPlayers(yaml, state.players().keySet());
|
|
for (PlayerState player : state.players().values()) {
|
|
savePlayer(yaml, player);
|
|
}
|
|
|
|
Path temporary = Files.createTempFile(parent, "spigot-tyrant-state-", ".yml");
|
|
try {
|
|
yaml.save(temporary.toFile());
|
|
try {
|
|
Files.move(
|
|
temporary,
|
|
stateFile,
|
|
StandardCopyOption.REPLACE_EXISTING,
|
|
StandardCopyOption.ATOMIC_MOVE
|
|
);
|
|
} catch (IOException atomicMoveFailure) {
|
|
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
|
|
}
|
|
} finally {
|
|
Files.deleteIfExists(temporary);
|
|
}
|
|
}
|
|
|
|
private YamlConfiguration loadYaml() throws IOException {
|
|
YamlConfiguration yaml = new YamlConfiguration();
|
|
try {
|
|
yaml.load(stateFile.toFile());
|
|
return yaml;
|
|
} catch (InvalidConfigurationException exception) {
|
|
throw new IOException("state file is not valid YAML", exception);
|
|
}
|
|
}
|
|
|
|
private static GameState loadGameSafely(YamlConfiguration yaml) {
|
|
try {
|
|
GameLifecycle lifecycle = enumValue(
|
|
GameLifecycle.class,
|
|
yaml.getString("game.lifecycle"),
|
|
GameLifecycle.UNSTARTED
|
|
);
|
|
return new GameState(
|
|
lifecycle,
|
|
uuid(yaml.getString("game.tyrant-id")),
|
|
uuid(yaml.getString("game.vigilante-id")),
|
|
pending(yaml, "game.pending-tyrant"),
|
|
pending(yaml, "game.pending-vigilante"),
|
|
instant(yaml.getString("game.paused-at")),
|
|
Duration.ofSeconds(nonNegativeLong(yaml, "game.accumulated-paused-seconds")),
|
|
nonNegativeInt(yaml, "game.tyrant-level"),
|
|
nonNegativeInt(yaml, "game.unspent-choices"),
|
|
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases"))
|
|
);
|
|
} catch (IllegalArgumentException exception) {
|
|
return GameState.empty();
|
|
}
|
|
}
|
|
|
|
private static Map<UUID, PlayerState> loadPlayers(YamlConfiguration yaml) {
|
|
Map<UUID, PlayerState> players = new HashMap<>();
|
|
ConfigurationSection section = yaml.getConfigurationSection(PLAYERS);
|
|
if (section == null) {
|
|
return players;
|
|
}
|
|
for (String key : section.getKeys(false)) {
|
|
try {
|
|
UUID playerId = UUID.fromString(key);
|
|
String path = PLAYERS + "." + key;
|
|
String name = requireText(yaml.getString(path + ".name"), "player name");
|
|
PlayerState player = new PlayerState(
|
|
playerId,
|
|
name,
|
|
instant(yaml.getString(path + ".last-login")),
|
|
instant(yaml.getString(path + ".opted-out-until")),
|
|
enumValue(
|
|
TyrantClass.class,
|
|
yaml.getString(path + ".tyrant-class"),
|
|
TyrantClass.NONE
|
|
),
|
|
uuid(yaml.getString(path + ".follower-of")),
|
|
loadCooldowns(yaml, path + ".cooldowns"),
|
|
enumSet(Ability.class, yaml.getStringList(path + ".ready-ability-items")),
|
|
loadCapturedMobs(yaml.getMapList(path + ".captured-mobs"))
|
|
);
|
|
players.put(playerId, player);
|
|
} catch (IllegalArgumentException ignored) {
|
|
// Invalid player records grant no state.
|
|
}
|
|
}
|
|
return players;
|
|
}
|
|
|
|
private static Map<Ability, Instant> loadCooldowns(YamlConfiguration yaml, String path) {
|
|
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
|
|
ConfigurationSection section = yaml.getConfigurationSection(path);
|
|
if (section == null) {
|
|
return cooldowns;
|
|
}
|
|
for (String key : section.getKeys(false)) {
|
|
Ability ability = enumValue(Ability.class, key, null);
|
|
Optional<Instant> end = instant(yaml.getString(path + "." + key));
|
|
if (ability != null && end.isPresent()) {
|
|
cooldowns.put(ability, end.orElseThrow());
|
|
}
|
|
}
|
|
return cooldowns;
|
|
}
|
|
|
|
private static List<CapturedMob> loadCapturedMobs(List<Map<?, ?>> rawMobs) {
|
|
List<CapturedMob> mobs = new ArrayList<>();
|
|
for (Map<?, ?> raw : rawMobs) {
|
|
try {
|
|
String type = requireText(
|
|
raw.get("entity-type") == null ? null : raw.get("entity-type").toString(),
|
|
"captured mob entity type"
|
|
);
|
|
EntityType entityType = EntityType.valueOf(type.toUpperCase(java.util.Locale.ROOT));
|
|
if (entityType == EntityType.ENDER_DRAGON) {
|
|
continue;
|
|
}
|
|
Map<String, String> data = new HashMap<>();
|
|
Object rawData = raw.get("data");
|
|
if (rawData instanceof Map<?, ?> values) {
|
|
for (Map.Entry<?, ?> entry : values.entrySet()) {
|
|
if (entry.getKey() != null && entry.getValue() != null) {
|
|
data.put(entry.getKey().toString(), entry.getValue().toString());
|
|
}
|
|
}
|
|
}
|
|
mobs.add(new CapturedMob(entityType.name(), data));
|
|
} catch (IllegalArgumentException ignored) {
|
|
// Unknown or malformed entities are not restored.
|
|
}
|
|
}
|
|
return List.copyOf(mobs);
|
|
}
|
|
|
|
private static void saveGame(YamlConfiguration yaml, GameState game) {
|
|
yaml.set("game.lifecycle", game.lifecycle().name());
|
|
yaml.set("game.tyrant-id", text(game.tyrantId()));
|
|
yaml.set("game.vigilante-id", text(game.vigilanteId()));
|
|
savePending(yaml, "game.pending-tyrant", game.pendingTyrant());
|
|
savePending(yaml, "game.pending-vigilante", game.pendingVigilante());
|
|
yaml.set("game.paused-at", instantText(game.pausedAt()));
|
|
yaml.set("game.accumulated-paused-seconds", game.accumulatedPausedTime().toSeconds());
|
|
yaml.set("game.tyrant-level", game.tyrantLevel());
|
|
yaml.set("game.unspent-choices", game.unspentChoices());
|
|
yaml.set("game.purchases", game.purchases().stream().map(Enum::name).sorted().toList());
|
|
}
|
|
|
|
private static void savePlayer(YamlConfiguration yaml, PlayerState player) {
|
|
String path = PLAYERS + "." + player.playerId();
|
|
yaml.set(path + ".name", player.latestName());
|
|
yaml.set(path + ".last-login", instantText(player.lastLogin()));
|
|
yaml.set(path + ".opted-out-until", instantText(player.optedOutUntil()));
|
|
yaml.set(path + ".tyrant-class", player.tyrantClass().name());
|
|
yaml.set(path + ".follower-of", text(player.followerOf()));
|
|
yaml.set(path + ".cooldowns", null);
|
|
for (Map.Entry<Ability, Instant> cooldown : player.cooldownEnds().entrySet()) {
|
|
yaml.set(path + ".cooldowns." + cooldown.getKey().name(), cooldown.getValue().toString());
|
|
}
|
|
yaml.set(
|
|
path + ".ready-ability-items",
|
|
player.readyAbilityItems().stream().map(Enum::name).sorted().toList()
|
|
);
|
|
List<Map<String, Object>> mobs = new ArrayList<>();
|
|
for (CapturedMob mob : player.capturedMobs()) {
|
|
Map<String, Object> serialized = new LinkedHashMap<>();
|
|
serialized.put("entity-type", mob.entityType());
|
|
serialized.put("data", mob.data());
|
|
mobs.add(serialized);
|
|
}
|
|
yaml.set(path + ".captured-mobs", mobs);
|
|
}
|
|
|
|
private static void removeAbsentPlayers(YamlConfiguration yaml, Set<UUID> retained) {
|
|
ConfigurationSection section = yaml.getConfigurationSection(PLAYERS);
|
|
if (section == null) {
|
|
return;
|
|
}
|
|
for (String key : section.getKeys(false)) {
|
|
try {
|
|
if (!retained.contains(UUID.fromString(key))) {
|
|
yaml.set(PLAYERS + "." + key, null);
|
|
}
|
|
} catch (IllegalArgumentException exception) {
|
|
yaml.set(PLAYERS + "." + key, null);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static Optional<PendingSelection> pending(YamlConfiguration yaml, String path) {
|
|
Optional<UUID> candidate = uuid(yaml.getString(path + ".candidate-id"));
|
|
Optional<Instant> expiresAt = instant(yaml.getString(path + ".expires-at"));
|
|
if (candidate.isEmpty() && expiresAt.isEmpty()) {
|
|
return Optional.empty();
|
|
}
|
|
if (candidate.isEmpty() || expiresAt.isEmpty()) {
|
|
throw new IllegalArgumentException("incomplete pending selection");
|
|
}
|
|
return Optional.of(new PendingSelection(candidate.orElseThrow(), expiresAt.orElseThrow()));
|
|
}
|
|
|
|
private static void savePending(
|
|
YamlConfiguration yaml,
|
|
String path,
|
|
Optional<PendingSelection> pending
|
|
) {
|
|
yaml.set(path, null);
|
|
pending.ifPresent(selection -> {
|
|
yaml.set(path + ".candidate-id", selection.candidateId().toString());
|
|
yaml.set(path + ".expires-at", selection.expiresAt().toString());
|
|
});
|
|
}
|
|
|
|
private static Optional<UUID> uuid(String value) {
|
|
if (value == null || value.isBlank()) {
|
|
return Optional.empty();
|
|
}
|
|
return Optional.of(UUID.fromString(value));
|
|
}
|
|
|
|
private static Optional<Instant> instant(String value) {
|
|
if (value == null || value.isBlank()) {
|
|
return Optional.empty();
|
|
}
|
|
try {
|
|
return Optional.of(Instant.parse(value));
|
|
} catch (DateTimeParseException exception) {
|
|
throw new IllegalArgumentException("invalid RFC 3339 instant", exception);
|
|
}
|
|
}
|
|
|
|
private static String instantText(Optional<Instant> value) {
|
|
return value.map(Instant::toString).orElse(null);
|
|
}
|
|
|
|
private static String text(Optional<UUID> value) {
|
|
return value.map(UUID::toString).orElse(null);
|
|
}
|
|
|
|
private static int nonNegativeInt(YamlConfiguration yaml, String path) {
|
|
if (!yaml.isInt(path)) {
|
|
return 0;
|
|
}
|
|
int value = yaml.getInt(path);
|
|
if (value < 0) {
|
|
throw new IllegalArgumentException(path + " must not be negative");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private static long nonNegativeLong(YamlConfiguration yaml, String path) {
|
|
if (!yaml.isLong(path) && !yaml.isInt(path)) {
|
|
return 0L;
|
|
}
|
|
long value = yaml.getLong(path);
|
|
if (value < 0L) {
|
|
throw new IllegalArgumentException(path + " must not be negative");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private static <E extends Enum<E>> E enumValue(
|
|
Class<E> type,
|
|
String value,
|
|
E defaultValue
|
|
) {
|
|
if (value == null || value.isBlank()) {
|
|
return defaultValue;
|
|
}
|
|
return Enum.valueOf(type, value.trim().toUpperCase(java.util.Locale.ROOT));
|
|
}
|
|
|
|
private static <E extends Enum<E>> Set<E> enumSet(Class<E> type, List<String> values) {
|
|
Set<E> result = EnumSet.noneOf(type);
|
|
for (String value : values) {
|
|
result.add(enumValue(type, value, null));
|
|
}
|
|
return Set.copyOf(result);
|
|
}
|
|
|
|
private static String requireText(String value, String description) {
|
|
if (value == null || value.isBlank()) {
|
|
throw new IllegalArgumentException(description + " must not be blank");
|
|
}
|
|
return value.trim();
|
|
}
|
|
}
|