Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4517aa3a64 |
No files matched your search
@@ -221,6 +221,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
sessions.refreshCooldownControls(64);
|
||||
int checked = 0;
|
||||
outer: for (var runtimes : List.copyOf(active.values())) {
|
||||
for (var runtime : List.copyOf(runtimes)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -18,6 +19,7 @@ import org.bukkit.persistence.PersistentDataType;
|
||||
*/
|
||||
public final class SessionControls {
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
|
||||
private final Clock clock;
|
||||
private static final NamespacedKey VERSION = key("control_version");
|
||||
private static final NamespacedKey OWNER = key("control_owner_id");
|
||||
private static final NamespacedKey SESSION = key("control_session_id");
|
||||
@@ -52,9 +54,13 @@ public final class SessionControls {
|
||||
|
||||
public record Identity(UUID owner, UUID session, int slot) { }
|
||||
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry()); }
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry(), Clock.systemUTC()); }
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
this(registry, Clock.systemUTC());
|
||||
}
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry, Clock clock) {
|
||||
this.registry = Objects.requireNonNull(registry);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
private Icon icon(String ability) {
|
||||
@@ -71,13 +77,17 @@ public final class SessionControls {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
var items = new ItemStack[9];
|
||||
var now = clock.instant();
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
|
||||
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
|
||||
var deadline = ability.isEmpty() ? null : character.cooldowns().get(ability);
|
||||
boolean cooling = abilityIcon != null && deadline != null && now.isBefore(deadline);
|
||||
Icon icon = slot == 0 ? new Icon(Material.BOOK, "Character book")
|
||||
: slot == 1 ? new Icon(Material.STICK, "Basic attack")
|
||||
: ability.isEmpty() ? new Icon(Material.GRAY_DYE, "No Ability")
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability) : abilityIcon;
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability)
|
||||
: cooling ? new Icon(Material.GRAY_STAINED_GLASS_PANE, abilityIcon.name() + " — Cooling Down") : abilityIcon;
|
||||
var item = new ItemStack(icon.material());
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
@@ -88,7 +98,7 @@ public final class SessionControls {
|
||||
data.set(SESSION, PersistentDataType.STRING, session.toString());
|
||||
data.set(SLOT, PersistentDataType.INTEGER, slot);
|
||||
data.set(KIND, PersistentDataType.STRING, slot == 0 ? "book" : slot == 1 ? "basic_attack"
|
||||
: ability.isEmpty() ? "empty" : abilityIcon != null ? "ability" : "unavailable");
|
||||
: ability.isEmpty() ? "empty" : abilityIcon == null ? "unavailable" : cooling ? "cooldown" : "ability");
|
||||
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
|
||||
item.setItemMeta(meta);
|
||||
items[slot] = item;
|
||||
@@ -123,7 +133,8 @@ public final class SessionControls {
|
||||
public Optional<String> ability(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return Optional.empty();
|
||||
var data = item.getPersistentDataContainer();
|
||||
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
return kind.equals("ability") || kind.equals("cooldown")
|
||||
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
|
||||
}
|
||||
|
||||
@@ -137,6 +148,7 @@ public final class SessionControls {
|
||||
if (ability.isBlank()) return false;
|
||||
Icon icon = icon(ability);
|
||||
return kind.equals("ability") ? icon != null && icon.material() == material
|
||||
: kind.equals("cooldown") ? icon != null && material == Material.GRAY_STAINED_GLASS_PANE
|
||||
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
|
||||
}
|
||||
|
||||
|
||||
@@ -424,12 +424,11 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
private void emit(Player player, ItemStack item, EquipmentSessions.SelectedControl selected,
|
||||
ControlInput.Gesture gesture, org.bukkit.entity.Entity target, int heldTicks, float strength) {
|
||||
var captured = item.clone();
|
||||
var kind = selected.slot() == 0 ? ControlInput.Kind.BOOK
|
||||
: selected.slot() == 1 ? ControlInput.Kind.BASIC_ATTACK : ControlInput.Kind.ABILITY;
|
||||
var input = new ControlInput(player, selected.session(), selected.slot(), selected.ability(), kind,
|
||||
gesture, target, heldTicks, strength,
|
||||
() -> !closed && sessions.selectedControl(player, captured).filter(selected::equals).isPresent());
|
||||
() -> !closed && sessions.currentControl(player, selected));
|
||||
if (!input.stillCurrent()) return;
|
||||
try { inputs.accept(input); }
|
||||
catch (RuntimeException failure) { report(player.getUniqueId(), selected.session().session(), failure); }
|
||||
|
||||
@@ -49,11 +49,13 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private games.dmg.fantasystick.presentation.SessionPresentation presentation = games.dmg.fantasystick.presentation.SessionPresentation.NONE;
|
||||
private final SessionClaims claims = new SessionClaims();
|
||||
private final EquipmentStorage equipment = new EquipmentStorage();
|
||||
private SessionControls controls = new SessionControls();
|
||||
private SessionControls controls;
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
private final Map<UUID, Attempt> active = new HashMap<>();
|
||||
private final java.util.ArrayDeque<UUID> cooldownRefreshOrder = new java.util.ArrayDeque<>();
|
||||
private final java.util.Set<UUID> cooldownRefreshQueued = new java.util.HashSet<>();
|
||||
// Covers reentrant inventory setters and client sync, not just asynchronous work.
|
||||
private final Map<UUID, Attempt> transitioning = new HashMap<>();
|
||||
private Consumer<UUID> cursorCustody = ignored -> { };
|
||||
@@ -82,6 +84,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean effectsStarted;
|
||||
String previousGameMode;
|
||||
boolean modeStarted;
|
||||
Instant nextCooldownVisualChange;
|
||||
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
|
||||
boolean vitalsStarted;
|
||||
boolean healthSaveRequested;
|
||||
@@ -125,6 +128,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
var initialSwitchDelay = Objects.requireNonNull(switchDelay);
|
||||
this.switchDelay = () -> initialSwitchDelay;
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.controls = new SessionControls(new games.dmg.fantasystick.ability.AbilityRegistry(), clock);
|
||||
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.notices = Objects.requireNonNull(notices);
|
||||
@@ -163,7 +167,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
/** Install before exposing entry so registered definitions drive delivered control metadata. */
|
||||
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
controls = new SessionControls(Objects.requireNonNull(registry));
|
||||
controls = new SessionControls(Objects.requireNonNull(registry), clock);
|
||||
}
|
||||
|
||||
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
|
||||
@@ -259,18 +263,70 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
var previous = attempt.record;
|
||||
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
|
||||
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
|
||||
if (!previous.loadout().equals(saved.loadout())) {
|
||||
var refreshed = controls.create(saved, attempt.owner, attempt.session);
|
||||
for (int slot = 2; slot < 9; slot++) {
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
attempt.player.getInventory().setItem(slot, refreshed[slot]);
|
||||
}
|
||||
attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
|
||||
}
|
||||
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
|
||||
&& !refreshControls(attempt, session)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bounded server-tick refresh of active cooldown controls; persisted deadlines are not mutated. */
|
||||
public synchronized void refreshCooldownControls(int maximumSessions) {
|
||||
if (maximumSessions <= 0) throw new IllegalArgumentException("Cooldown refresh bound must be positive");
|
||||
int checks = Math.min(maximumSessions, cooldownRefreshOrder.size());
|
||||
for (int checked = 0; checked < checks; checked++) {
|
||||
UUID owner = cooldownRefreshOrder.removeFirst();
|
||||
var attempt = active.get(owner);
|
||||
if (attempt == null || attempt.record == null || attempt.session == null) {
|
||||
cooldownRefreshQueued.remove(owner);
|
||||
continue;
|
||||
}
|
||||
cooldownRefreshOrder.addLast(owner);
|
||||
if (attempt.nextCooldownVisualChange != null && !clock.instant().isBefore(attempt.nextCooldownVisualChange)) {
|
||||
refreshControls(attempt, new Active(attempt.owner, attempt.session, attempt.character));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void queueCooldownRefresh(Attempt attempt) {
|
||||
if (cooldownRefreshQueued.add(attempt.owner)) cooldownRefreshOrder.addLast(attempt.owner);
|
||||
}
|
||||
|
||||
private boolean refreshControls(Attempt attempt, Active session) {
|
||||
var refreshed = controls.create(attempt.record, attempt.owner, attempt.session);
|
||||
boolean changed = false;
|
||||
for (int slot = 2; slot < 9; slot++) {
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
if (Objects.equals(attempt.player.getInventory().getItem(slot), refreshed[slot])) continue;
|
||||
attempt.player.getInventory().setItem(slot, refreshed[slot]);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
attempt.nextCooldownVisualChange = nextCooldownVisualChange(attempt.record);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Instant nextCooldownVisualChange(CharacterRecord record) {
|
||||
var now = clock.instant();
|
||||
return record.loadout().stream().filter(ability -> !ability.isEmpty())
|
||||
.map(record.cooldowns()::get).filter(Objects::nonNull).filter(deadline -> now.isBefore(deadline))
|
||||
.min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
/** Server-thread authorization for the currently held, issued control and saved loadout. */
|
||||
public synchronized boolean currentControl(Player player, SelectedControl selected) {
|
||||
if (selected == null) return false;
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || attempt.record == null
|
||||
|| !current(selected.session()) || selected.slot() != player.getInventory().getHeldItemSlot()) return false;
|
||||
var actual = player.getInventory().getItem(selected.slot());
|
||||
var identity = controls.identify(actual).orElse(null);
|
||||
if (identity == null || identity.slot() != selected.slot() || !identity.owner().equals(attempt.owner)
|
||||
|| !identity.session().equals(attempt.session)) return false;
|
||||
String ability = selected.slot() < 2 ? "" : controls.ability(actual).orElse("");
|
||||
return ability.equals(selected.ability()) && (selected.slot() < 2
|
||||
|| ability.equals(attempt.record.loadout().get(selected.slot() - 2)));
|
||||
}
|
||||
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|
||||
@@ -456,7 +512,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.exitRequested) {
|
||||
attempt.record = record;
|
||||
attempt.nextCooldownVisualChange = nextCooldownVisualChange(record);
|
||||
active.put(attempt.owner, attempt);
|
||||
queueCooldownRefresh(attempt);
|
||||
attempt.presentationInstalled = true;
|
||||
try { presentation.activate(attempt.player, attempt.session, record); }
|
||||
catch (RuntimeException | LinkageError error) { withdrawPresentation(attempt); diagnostic(attempt, error); }
|
||||
|
||||
@@ -170,9 +170,15 @@ class AbilityEngineNativeTest {
|
||||
assertEquals(1, effects.get());
|
||||
var cooled = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(cooled.cooldowns().get("test_focus").isAfter(java.time.Instant.now()));
|
||||
var cooling = a.core.inventory.getItem(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, cooling.getType());
|
||||
assertEquals(net.kyori.adventure.text.Component.text("Test Focus — Cooling Down"), cooling.getItemMeta().displayName());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(cooling).orElseThrow());
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
var repeated = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
cooling, null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(repeated);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, repeated.useItemInHand(), "The pane remains a contained control");
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, effects.get(), "The persisted character deadline refuses an immediate repeated cast");
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
@@ -182,6 +188,7 @@ class AbilityEngineNativeTest {
|
||||
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, a.core.inventory.getItem(2).getType());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry)
|
||||
.ability(a.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
|
||||
@@ -1059,6 +1059,38 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedCharacterCooldownUsesAnAuthorizedPaneAcrossResumeAndRestoresAtTheExactDeadline() throws Exception {
|
||||
var clock = new MutableClock();
|
||||
try (var f = new Fixture(Duration.ofSeconds(900), clock)) {
|
||||
var actor = f.actor(); f.activate(actor);
|
||||
var active = f.sessions.active(actor.id).orElseThrow();
|
||||
var current = f.sessions.currentRecord(active).orElseThrow();
|
||||
var learnedMutation = f.characters.unlockAbility(current.id(), current.revision(), "fireball", java.util.Map.of(), java.math.BigInteger.ONE)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
var learned = learnedMutation.record().orElseThrow();
|
||||
assertTrue(f.sessions.acknowledgeProgress(active, current.revision(), learned));
|
||||
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
|
||||
|
||||
var deadline = clock.now.plusSeconds(30);
|
||||
var cooldownMutation = f.characters.commitAbilityCooldown(learned.id(), learned.revision(), "fireball", deadline)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
var cooled = cooldownMutation.record().orElseThrow();
|
||||
assertTrue(f.sessions.acknowledgeProgress(active, learned.revision(), cooled));
|
||||
var pane = actor.inventory.getItem(2).clone();
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
|
||||
|
||||
f.sessions.exit(actor.player); f.state(actor); f.activate(actor);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, actor.inventory.getItem(2).getType(),
|
||||
"Resume reconstructs the future persisted deadline");
|
||||
clock.advance(Duration.ofSeconds(30));
|
||||
f.sessions.refreshCooldownControls(1);
|
||||
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
|
||||
assertNotEquals(pane, actor.inventory.getItem(2), "The retired pane is no longer installed");
|
||||
assertEquals(deadline, f.sessions.currentRecord(f.sessions.active(actor.id).orElseThrow()).orElseThrow()
|
||||
.cooldowns().get("fireball"), "Visual restoration does not rewrite the deadline");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sameIdFailedEquipmentPreflightPreservesExistingDeadlineAndAllItems() throws Exception {
|
||||
for (boolean full : new boolean[]{false, true}) {
|
||||
var clock = new MutableClock();
|
||||
|
||||
@@ -157,6 +157,8 @@ class FireballNativeTest {
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(transferredSheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
b.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(org.bukkit.Material.GRAY_STAINED_GLASS_PANE, b.core.inventory.getItem(2).getType(),
|
||||
"The transferred character reconstructs its retained cooldown pane");
|
||||
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(b.core.inventory.getItem(2)).orElseThrow());
|
||||
@@ -285,6 +287,8 @@ class FireballNativeTest {
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
|
||||
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).launchProjectile(
|
||||
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class), org.mockito.ArgumentMatchers.any());
|
||||
org.mockito.Mockito.verify(sessions, org.mockito.Mockito.never()).acknowledgeProgress(
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
assertEquals(1, failures.size());
|
||||
engine.close(); fireball.close(); parties.close();
|
||||
}
|
||||
|
||||
@@ -94,6 +94,30 @@ class SessionControlsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void futureCharacterCooldownsUseContainedPanesWhileElapsedAndUnknownControlsKeepTheirPresentation() {
|
||||
var now = java.time.Instant.parse("2000-01-01T00:00:00Z");
|
||||
var clock = java.time.Clock.fixed(now, java.time.ZoneOffset.UTC);
|
||||
var timed = new SessionControls(new games.dmg.fantasystick.ability.AbilityRegistry(), clock);
|
||||
var base = character(List.of("fireball", "fireball", "revive", "removed_spell", "", "", ""));
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(),
|
||||
Map.of("fireball", now.plusSeconds(5), "revive", now, "removed_spell", now.plusSeconds(5)),
|
||||
base.dead(), base.deathId(), base.diedAt());
|
||||
|
||||
var items = timed.create(record, owner, session);
|
||||
for (int slot : List.of(2, 3)) {
|
||||
var pane = roundTrip(items[slot]);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
|
||||
assertEquals(Component.text("Fireball — Cooling Down"), pane.getItemMeta().displayName());
|
||||
assertEquals("cooldown", pane.getPersistentDataContainer().get(key("control_kind"), PersistentDataType.STRING));
|
||||
assertEquals(java.util.Optional.of("fireball"), timed.ability(pane));
|
||||
assertEquals(new SessionControls.Identity(owner, session, slot), timed.identify(pane).orElseThrow());
|
||||
}
|
||||
assertEquals(Material.ECHO_SHARD, items[4].getType(), "A deadline equal to now is ready");
|
||||
assertEquals(Material.PAPER, items[5].getType(), "Unknown saved IDs remain unavailable, not cooling panes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSavedAbilitiesRemainUnavailableWithoutChangingTheRecordOrCallerLoadout() {
|
||||
var loadout = new ArrayList<>(List.of("fireball", "removed_spell", "", "revive", "", "", ""));
|
||||
|
||||
Reference in new issue
Block a user