Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc1a0b1f29 |
No files matched your search
@@ -465,6 +465,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
passiveRegistry.register(healthSteal.definition());
|
||||
passiveRegistry.register(speedI.definition());
|
||||
passiveRegistry.register(lowLightVision.definition());
|
||||
passiveRegistry.register(new games.dmg.fantasystick.passive.StickKnockback().definition());
|
||||
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
|
||||
+ "; passive " + problem.passive(), problem.cause()));
|
||||
|
||||
@@ -35,9 +35,10 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
final ControlInput input;
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
final boolean knockback;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +73,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
if (ready != null && now.isBefore(ready)) return;
|
||||
BigInteger damage = damage(record.stats().get(Stat.STR));
|
||||
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
|
||||
var attempt = new Pending(input, target, now);
|
||||
var attempt = new Pending(input, target, now,
|
||||
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
|
||||
pending = attempt;
|
||||
try {
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
|
||||
@@ -82,7 +84,13 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
});
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
if (attempt.accepted) {
|
||||
readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
if (attempt.knockback && !trainingDamage.target(target) && input.stillCurrent()
|
||||
&& sessions.current(input.session())) {
|
||||
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +104,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
|
||||
@@ -93,6 +93,9 @@ public final class SessionControls {
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
meta.setMaxStackSize(1);
|
||||
if (slot == 1 && character.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID)) {
|
||||
meta.addEnchant(org.bukkit.enchantments.Enchantment.KNOCKBACK, 1, true);
|
||||
}
|
||||
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Fixed reviewed metadata and native level-I behavior for the basic-stick Knockback passive. */
|
||||
public final class StickKnockback {
|
||||
public static final String ID = "stick_knockback";
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Knockback", Map.of(Stat.STR, BigInteger.valueOf(12)), Set.of(),
|
||||
BigInteger.ONE, List.of("Your bound basic attack stick carries native Knockback I.",
|
||||
"Only positive accepted basic-stick damage can knock the target back."));
|
||||
}
|
||||
|
||||
public static void apply(org.bukkit.entity.Player source, org.bukkit.entity.LivingEntity target) {
|
||||
double radians = Math.toRadians(source.getLocation().getYaw());
|
||||
double directionX = Math.sin(radians);
|
||||
double directionZ = -Math.cos(radians);
|
||||
if (!Double.isFinite(directionX) || !Double.isFinite(directionZ)) return;
|
||||
boolean sprinting = source.isSprinting();
|
||||
target.knockback(sprinting ? 2 : 1, directionX, directionZ);
|
||||
if (sprinting) source.setSprinting(false);
|
||||
}
|
||||
}
|
||||
@@ -287,8 +287,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
|
||||
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
|
||||
if(gear!=null&&!previous.gear().equals(saved.gear()))gear.install(attempt.player.getInventory(),saved,attempt.owner,attempt.session);
|
||||
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
|
||||
&& !refreshControls(attempt, session)) return false;
|
||||
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns())
|
||||
|| !previous.passives().equals(saved.passives())) && !refreshControls(attempt, session)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
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++) {
|
||||
for (int slot = 1; 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]);
|
||||
|
||||
@@ -148,7 +148,7 @@ class BasicAttackNativeTest {
|
||||
old.revision());
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
static final class MutableClock extends Clock {
|
||||
private Instant now;
|
||||
MutableClock(Instant now) { this.now = now; }
|
||||
void advance(java.time.Duration duration) { now = now.plus(duration); }
|
||||
|
||||
@@ -1339,6 +1339,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
healthAttributes.getInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
|
||||
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
|
||||
boolean healthScaled;
|
||||
boolean sprinting;
|
||||
double healthScale = 20;
|
||||
double absorption;
|
||||
Runnable scaleMutation = () -> {};
|
||||
@@ -1402,6 +1403,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
yield null;
|
||||
}
|
||||
case "isSneaking", "isInvisible" -> false;
|
||||
case "isSprinting" -> sprinting;
|
||||
case "setSprinting" -> { sprinting = (Boolean) args[0]; yield null; }
|
||||
case "getScoreboard" -> craftBoard;
|
||||
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
|
||||
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class StickKnockbackNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookLearningRefreshesAuthenticStickAcceptedAttackTransfersAndUnlearns() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.passive.PassiveRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
|
||||
var definition = registry.find("stick_knockback").orElseThrow();
|
||||
assertEquals("Knockback", definition.name()); assertEquals(BigInteger.valueOf(12),
|
||||
definition.requirements().get(games.dmg.fantasystick.progression.Stat.STR));
|
||||
assertEquals(BigInteger.ONE, definition.pointCost()); assertTrue(definition.prerequisites().isEmpty());
|
||||
|
||||
var ineligible = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, ineligible, 11, List.of("", "", ""));
|
||||
f.players.put(ineligible.core.id, ineligible.player); f.activate(ineligible); PartyBookNativeTest.open(f, ineligible);
|
||||
var refusedPages = pages(ineligible); var refusedDetail = changePageTo(refusedPages, "Knockback"); assertNotNull(refusedDetail);
|
||||
String refused = refusedPages.get(refusedDetail.page() - 1).getString().replaceAll("\\s+", "");
|
||||
assertTrue(refused.contains("STR12")); assertNull(findCustom(refusedPages.get(refusedDetail.page() - 1),
|
||||
"fantasystick:passive_unlock"));
|
||||
ineligible.core.inventory.setItemInOffHand(null); f.changed(ineligible); f.awaitCharacterAndReceipt(ineligible);
|
||||
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, 12, List.of("", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor); PartyBookNativeTest.open(f, actor);
|
||||
var pages = pages(actor); var detail = changePageTo(pages, "Knockback"); assertNotNull(detail);
|
||||
assertTrue(pages.get(detail.page() - 1).getString().replaceAll("\\s+", "").contains("STR12[Learn]"));
|
||||
PartyBookNativeTest.click(f, actor, custom(pages.get(detail.page() - 1), "fantasystick:passive_unlock"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var learned = storage(f).find(actor.core.record.id()).get().orElseThrow();
|
||||
assertEquals(List.of("stick_knockback", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
assertEquals(1, actor.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
|
||||
actor.core.inventory.getInventory().setSelectedSlot(1);
|
||||
var target = target();
|
||||
doAnswer(call -> { accepted(f, actor, target, (Double) call.getArgument(0), false, false); return null; })
|
||||
.when(target).damage(anyDouble(), same(actor.player));
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
|
||||
verify(target).knockback(eq(1.0), anyDouble(), anyDouble());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
|
||||
verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
|
||||
actor.core.inventory.setItemInOffHand(null); f.changed(actor); f.awaitCharacterAndReceipt(actor);
|
||||
var holder = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
holder.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(learned));
|
||||
f.players.put(holder.core.id, holder.player); f.activate(holder);
|
||||
assertEquals(1, holder.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
PartyBookNativeTest.open(f, holder);
|
||||
PartyBookNativeTest.click(f, holder, custom(pages(holder), "fantasystick:passive_unlearn"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage(f).find(learned.id()).get().orElseThrow();
|
||||
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertEquals(0, holder.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
|
||||
holder.core.inventory.setItemInOffHand(null); f.changed(holder); f.awaitCharacterAndReceipt(holder);
|
||||
assertTrue(holder.core.inventory.getItem(1) == null || holder.core.inventory.getItem(1).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledZeroStaleEarlyAndTrainingHitsCannotKnockBackWhileNativeSprintStrengthIsRetained() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, 12,
|
||||
List.of("stick_knockback", "", ""));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(actor.core.id).orElseThrow();
|
||||
var clock = new BasicAttackNativeTest.MutableClock(java.time.Instant.parse("2026-09-21T00:00:00Z"));
|
||||
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
|
||||
var target = target();
|
||||
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
doAnswer(call -> {
|
||||
var event = accepted(f, actor, target, (Double) call.getArgument(0), cancelled.get(), zero.get());
|
||||
basic.acceptedDamage(event); return null;
|
||||
}).when(target).damage(anyDouble(), same(actor.player));
|
||||
var input = new games.dmg.fantasystick.session.ControlInput(actor.player, active, 1, "",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> sessions.current(active));
|
||||
basic.accept(input); verify(target, never()).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
cancelled.set(false); zero.set(true); basic.accept(input);
|
||||
verify(target, never()).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
zero.set(false); actor.sprinting = true;
|
||||
basic.accept(input); verify(target).knockback(eq(2.0), anyDouble(), anyDouble()); assertFalse(actor.sprinting);
|
||||
basic.accept(input); verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
actor.core.inventory.setItemInOffHand(null); f.changed(actor);
|
||||
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
|
||||
verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
|
||||
var current = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, current, 12,
|
||||
List.of("stick_knockback", "", "")); f.players.put(current.core.id, current.player); f.activate(current);
|
||||
var currentSession = sessions.active(current.core.id).orElseThrow();
|
||||
var dummy = target();
|
||||
var training = new games.dmg.fantasystick.training.TrainingDamage() {
|
||||
@Override public java.util.Optional<java.math.BigDecimal> apply(org.bukkit.entity.Player source,
|
||||
LivingEntity ignored, Runnable nativeDamage) {
|
||||
return java.util.Optional.of(java.math.BigDecimal.ONE);
|
||||
}
|
||||
@Override public boolean target(org.bukkit.entity.Entity entity) { return entity == dummy; }
|
||||
};
|
||||
var measured = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock, null, training);
|
||||
measured.accept(new games.dmg.fantasystick.session.ControlInput(current.player, currentSession, 1, "",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, dummy, 0, 1,
|
||||
() -> sessions.current(currentSession)));
|
||||
verify(dummy, never()).knockback(anyDouble(), anyDouble(), anyDouble());
|
||||
}
|
||||
}
|
||||
|
||||
private static LivingEntity target() {
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return target;
|
||||
}
|
||||
|
||||
private static EntityDamageByEntityEvent accepted(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, LivingEntity target, double amount, boolean cancelled, boolean zero) {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(actor.player); when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var event = new EntityDamageByEntityEvent(actor.player, target, DamageCause.ENTITY_ATTACK, source, amount);
|
||||
event.setCancelled(cancelled); if (zero) event.setDamage(0); f.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
private static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength,
|
||||
List<String> passives) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(strength));
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
|
||||
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), BigInteger.valueOf(40), old.health(),
|
||||
old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null,
|
||||
old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()), old.revision());
|
||||
}
|
||||
|
||||
private static games.dmg.fantasystick.persistence.CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static List<net.minecraft.network.chat.Component> pages(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream().map(page -> page.raw()).toList();
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(
|
||||
List<net.minecraft.network.chat.Component> pages, String heading) {
|
||||
for (var page : pages) { var found = changePageTo(page, pages, heading); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(net.minecraft.network.chat.Component component,
|
||||
List<net.minecraft.network.chat.Component> pages, String heading) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page
|
||||
&& page.page() > 0 && page.page() <= pages.size()
|
||||
&& pages.get(page.page() - 1).getString().replaceAll("\\s+", "")
|
||||
.startsWith(heading.replaceAll("\\s+", ""))) return page;
|
||||
for (var child : component.getSiblings()) { var found = changePageTo(child, pages, heading); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom custom(net.minecraft.network.chat.Component component, String id) {
|
||||
var found = findCustom(component, id);
|
||||
if (found == null) throw new AssertionError("Missing custom action " + id);
|
||||
return found;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
|
||||
for (var page : pages) { var found = findCustom(page, id); if (found != null) return found; }
|
||||
throw new AssertionError("Missing custom action " + id);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom findCustom(
|
||||
net.minecraft.network.chat.Component component, String id) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom action
|
||||
&& action.id().toString().equals(id)) return action;
|
||||
for (var child : component.getSiblings()) { var found = findCustom(child, id); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user