2023-07-17 02:56:17 +00:00
|
|
|
/*
|
|
|
|
* This file is part of Baritone.
|
|
|
|
*
|
|
|
|
* Baritone is free software: you can redistribute it and/or modify
|
|
|
|
* it under the terms of the GNU Lesser General Public License as published by
|
|
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
|
|
* (at your option) any later version.
|
|
|
|
*
|
|
|
|
* Baritone is distributed in the hope that it will be useful,
|
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
* GNU Lesser General Public License for more details.
|
|
|
|
*
|
|
|
|
* You should have received a copy of the GNU Lesser General Public License
|
|
|
|
* along with Baritone. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
*/
|
|
|
|
|
|
|
|
package baritone.process;
|
|
|
|
|
|
|
|
import baritone.Baritone;
|
2023-07-19 04:35:09 +00:00
|
|
|
import baritone.api.IBaritone;
|
2023-07-17 04:34:48 +00:00
|
|
|
import baritone.api.event.events.*;
|
2023-07-18 02:57:24 +00:00
|
|
|
import baritone.api.event.events.type.EventState;
|
2023-07-17 04:34:48 +00:00
|
|
|
import baritone.api.event.listener.AbstractGameEventListener;
|
2023-07-17 02:56:17 +00:00
|
|
|
import baritone.api.pathing.goals.Goal;
|
|
|
|
import baritone.api.pathing.goals.GoalYLevel;
|
|
|
|
import baritone.api.pathing.movement.IMovement;
|
|
|
|
import baritone.api.pathing.path.IPathExecutor;
|
|
|
|
import baritone.api.process.IBaritoneProcess;
|
|
|
|
import baritone.api.process.IElytraProcess;
|
|
|
|
import baritone.api.process.PathingCommand;
|
|
|
|
import baritone.api.process.PathingCommandType;
|
|
|
|
import baritone.api.utils.BetterBlockPos;
|
|
|
|
import baritone.api.utils.Rotation;
|
|
|
|
import baritone.api.utils.RotationUtils;
|
|
|
|
import baritone.api.utils.input.Input;
|
2023-07-19 04:35:09 +00:00
|
|
|
import baritone.pathing.movement.CalculationContext;
|
2023-07-17 02:56:17 +00:00
|
|
|
import baritone.pathing.movement.movements.MovementFall;
|
|
|
|
import baritone.process.elytra.LegacyElytraBehavior;
|
|
|
|
import baritone.process.elytra.NetherPathfinderContext;
|
|
|
|
import baritone.process.elytra.NullElytraProcess;
|
|
|
|
import baritone.utils.BaritoneProcessHelper;
|
|
|
|
import baritone.utils.PathingCommandContext;
|
2023-07-23 05:00:23 +00:00
|
|
|
import net.minecraft.block.Block;
|
|
|
|
import net.minecraft.block.material.Material;
|
2023-07-19 04:35:09 +00:00
|
|
|
import net.minecraft.block.state.IBlockState;
|
2023-07-22 23:12:53 +00:00
|
|
|
import net.minecraft.init.Blocks;
|
2023-07-17 02:56:17 +00:00
|
|
|
import net.minecraft.util.math.BlockPos;
|
|
|
|
import net.minecraft.util.math.Vec3d;
|
|
|
|
|
2023-07-22 23:12:53 +00:00
|
|
|
import java.util.HashSet;
|
|
|
|
import java.util.LinkedList;
|
|
|
|
import java.util.Queue;
|
|
|
|
import java.util.Set;
|
|
|
|
|
2023-07-19 04:35:09 +00:00
|
|
|
import static baritone.api.pathing.movement.ActionCosts.COST_INF;
|
|
|
|
|
2023-07-17 04:34:48 +00:00
|
|
|
public class ElytraProcess extends BaritoneProcessHelper implements IBaritoneProcess, IElytraProcess, AbstractGameEventListener {
|
2023-07-18 21:48:00 +00:00
|
|
|
|
2023-07-17 02:56:17 +00:00
|
|
|
public State state;
|
2023-07-22 23:12:53 +00:00
|
|
|
private boolean goingToLandingSpot;
|
2023-07-23 00:24:36 +00:00
|
|
|
private BetterBlockPos landingSpot;
|
2023-07-17 02:56:17 +00:00
|
|
|
private Goal goal;
|
|
|
|
private LegacyElytraBehavior behavior;
|
|
|
|
|
|
|
|
private ElytraProcess(Baritone baritone) {
|
|
|
|
super(baritone);
|
2023-07-17 04:34:48 +00:00
|
|
|
baritone.getGameEventHandler().registerEventListener(this);
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public static <T extends IElytraProcess> T create(final Baritone baritone) {
|
|
|
|
return (T) (NetherPathfinderContext.isSupported()
|
|
|
|
? new ElytraProcess(baritone)
|
|
|
|
: new NullElytraProcess(baritone));
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public boolean isActive() {
|
2023-07-18 21:48:00 +00:00
|
|
|
return this.behavior != null;
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
|
|
|
|
2023-07-18 02:57:24 +00:00
|
|
|
@Override
|
|
|
|
public void resetState() {
|
|
|
|
BlockPos destination = this.currentDestination();
|
|
|
|
this.onLostControl();
|
|
|
|
this.pathTo(destination);
|
|
|
|
this.repackChunks();
|
|
|
|
}
|
|
|
|
|
2023-07-17 02:56:17 +00:00
|
|
|
@Override
|
|
|
|
public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
|
2023-07-18 02:57:24 +00:00
|
|
|
final long seedSetting = Baritone.settings().elytraNetherSeed.value;
|
2023-07-18 21:48:00 +00:00
|
|
|
if (seedSetting != this.behavior.context.getSeed()) {
|
2023-07-18 02:57:24 +00:00
|
|
|
logDirect("Nether seed changed, recalculating path");
|
|
|
|
this.resetState();
|
|
|
|
}
|
|
|
|
|
2023-07-18 18:52:09 +00:00
|
|
|
this.behavior.onTick();
|
|
|
|
|
2023-07-17 02:56:17 +00:00
|
|
|
if (calcFailed) {
|
|
|
|
onLostControl();
|
|
|
|
logDirect("Failed to get to jump off spot, canceling");
|
|
|
|
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
|
|
|
|
}
|
|
|
|
|
2023-07-22 23:12:53 +00:00
|
|
|
if (ctx.player().isElytraFlying() && this.state != State.LANDING) {
|
|
|
|
final BetterBlockPos last = this.behavior.pathManager.path.getLast();
|
2023-07-23 00:24:36 +00:00
|
|
|
if (last != null && ctx.player().getDistanceSqToCenter(last) < 1) {
|
2023-07-18 19:36:29 +00:00
|
|
|
if (Baritone.settings().notificationOnPathComplete.value) {
|
|
|
|
logNotification("Pathing complete", false);
|
|
|
|
}
|
|
|
|
if (Baritone.settings().disconnectOnArrival.value) {
|
|
|
|
// don't be active when the user logs back in
|
|
|
|
this.onLostControl();
|
|
|
|
ctx.world().sendQuittingDisconnectingPacket();
|
|
|
|
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
|
|
|
|
}
|
2023-07-22 23:12:53 +00:00
|
|
|
if (!goingToLandingSpot) {
|
2023-07-23 00:24:36 +00:00
|
|
|
BetterBlockPos landingSpot = findSafeLandingSpot();
|
2023-07-22 23:12:53 +00:00
|
|
|
if (landingSpot != null) {
|
|
|
|
this.pathTo(landingSpot);
|
2023-07-23 00:24:36 +00:00
|
|
|
this.landingSpot = landingSpot;
|
2023-07-22 23:12:53 +00:00
|
|
|
this.goingToLandingSpot = true;
|
|
|
|
return this.onTick(calcFailed, isSafeToCancel);
|
|
|
|
}
|
2023-07-23 00:24:36 +00:00
|
|
|
// don't spam call findLandingSpot if it somehow fails (it's slow)
|
|
|
|
this.goingToLandingSpot = true;
|
2023-07-22 23:12:53 +00:00
|
|
|
}
|
2023-07-17 02:56:17 +00:00
|
|
|
this.state = State.LANDING;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.state == State.LANDING) {
|
2023-07-23 00:24:36 +00:00
|
|
|
final BetterBlockPos endPos = this.landingSpot != null ? this.landingSpot : behavior.pathManager.path.getLast();
|
2023-07-17 02:56:17 +00:00
|
|
|
if (ctx.player().isElytraFlying() && endPos != null) {
|
|
|
|
Vec3d from = ctx.player().getPositionVector();
|
2023-07-23 00:24:36 +00:00
|
|
|
Vec3d to = new Vec3d(((double) endPos.x) + 0.5, from.y, ((double) endPos.z) + 0.5);
|
2023-07-17 02:56:17 +00:00
|
|
|
Rotation rotation = RotationUtils.calcRotationFromVec3d(from, to, ctx.playerRotations());
|
|
|
|
baritone.getLookBehavior().updateTarget(rotation, false);
|
|
|
|
} else {
|
|
|
|
this.onLostControl();
|
|
|
|
return new PathingCommand(null, PathingCommandType.REQUEST_PAUSE);
|
|
|
|
}
|
|
|
|
} else if (ctx.player().isElytraFlying()) {
|
|
|
|
this.state = State.FLYING;
|
|
|
|
this.goal = null;
|
|
|
|
baritone.getInputOverrideHandler().clearAllKeys();
|
|
|
|
behavior.tick();
|
|
|
|
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.state == State.FLYING || this.state == State.START_FLYING) {
|
|
|
|
this.state = ctx.player().onGround && Baritone.settings().elytraAutoJump.value
|
|
|
|
? State.LOCATE_JUMP
|
|
|
|
: State.START_FLYING;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.state == State.LOCATE_JUMP) {
|
|
|
|
if (this.goal == null) {
|
|
|
|
this.goal = new GoalYLevel(31);
|
|
|
|
}
|
|
|
|
final IPathExecutor executor = baritone.getPathingBehavior().getCurrent();
|
|
|
|
if (executor != null && executor.getPath().getGoal() == this.goal) {
|
|
|
|
final IMovement fall = executor.getPath().movements().stream()
|
|
|
|
.filter(movement -> movement instanceof MovementFall)
|
|
|
|
.findFirst().orElse(null);
|
|
|
|
|
|
|
|
if (fall != null) {
|
|
|
|
final BetterBlockPos from = new BetterBlockPos(
|
|
|
|
(fall.getSrc().x + fall.getDest().x) / 2,
|
|
|
|
(fall.getSrc().y + fall.getDest().y) / 2,
|
|
|
|
(fall.getSrc().z + fall.getDest().z) / 2
|
|
|
|
);
|
|
|
|
behavior.pathManager.pathToDestination(from).whenComplete((result, ex) -> {
|
|
|
|
if (!behavior.clearView(new Vec3d(from), behavior.pathManager.getPath().getVec(0), false)) {
|
|
|
|
onLostControl();
|
|
|
|
// TODO: Get to higher ground and then look again
|
|
|
|
logDirect("Can't see start of path from jump spot, canceling");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (ex == null) {
|
|
|
|
this.state = State.GET_TO_JUMP;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
onLostControl();
|
|
|
|
});
|
|
|
|
this.state = State.PAUSE;
|
|
|
|
} else {
|
|
|
|
onLostControl();
|
|
|
|
logDirect("Jump off path didn't include a fall movement, canceling");
|
|
|
|
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
|
|
|
|
}
|
|
|
|
}
|
2023-07-19 04:35:09 +00:00
|
|
|
return new PathingCommandContext(this.goal, PathingCommandType.SET_GOAL_AND_PAUSE, new WalkOffCalculationContext(baritone));
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// yucky
|
|
|
|
if (this.state == State.PAUSE) {
|
|
|
|
return new PathingCommand(null, PathingCommandType.REQUEST_PAUSE);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.state == State.GET_TO_JUMP) {
|
|
|
|
final IPathExecutor executor = baritone.getPathingBehavior().getCurrent();
|
|
|
|
final boolean canStartFlying = ctx.player().fallDistance > 1.0f
|
|
|
|
&& !isSafeToCancel
|
|
|
|
&& executor != null
|
|
|
|
&& executor.getPath().movements().get(executor.getPosition()) instanceof MovementFall;
|
|
|
|
|
|
|
|
if (canStartFlying) {
|
|
|
|
this.state = State.START_FLYING;
|
|
|
|
} else {
|
|
|
|
return new PathingCommand(null, PathingCommandType.SET_GOAL_AND_PATH);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.state == State.START_FLYING) {
|
|
|
|
if (!isSafeToCancel) {
|
|
|
|
// owned
|
|
|
|
baritone.getPathingBehavior().secretInternalSegmentCancel();
|
|
|
|
}
|
|
|
|
baritone.getInputOverrideHandler().clearAllKeys();
|
|
|
|
if (ctx.player().fallDistance > 1.0f) {
|
|
|
|
baritone.getInputOverrideHandler().setInputForceState(Input.JUMP, true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public void onLostControl() {
|
|
|
|
this.goal = null;
|
2023-07-22 23:12:53 +00:00
|
|
|
this.goingToLandingSpot = false;
|
2023-07-17 04:34:48 +00:00
|
|
|
this.state = State.START_FLYING; // TODO: null state?
|
2023-07-18 18:52:09 +00:00
|
|
|
if (this.behavior != null) {
|
|
|
|
this.behavior.destroy();
|
|
|
|
this.behavior = null;
|
|
|
|
}
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public String displayName0() {
|
2023-07-18 02:57:24 +00:00
|
|
|
return "Elytra - " + this.state.description;
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
2023-07-18 02:57:24 +00:00
|
|
|
public void repackChunks() {
|
2023-07-18 18:52:09 +00:00
|
|
|
if (this.behavior != null) {
|
|
|
|
this.behavior.repackChunks();
|
|
|
|
}
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
2023-07-18 02:57:24 +00:00
|
|
|
public BlockPos currentDestination() {
|
|
|
|
return this.behavior != null ? this.behavior.destination : null;
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public void pathTo(BlockPos destination) {
|
2023-07-22 23:12:53 +00:00
|
|
|
this.onLostControl();
|
2023-07-18 21:48:00 +00:00
|
|
|
this.behavior = new LegacyElytraBehavior(this.baritone, this, destination);
|
2023-07-17 04:34:48 +00:00
|
|
|
if (ctx.world() != null) {
|
|
|
|
this.behavior.repackChunks();
|
|
|
|
}
|
2023-07-18 21:48:00 +00:00
|
|
|
this.behavior.pathTo();
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public boolean isLoaded() {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public boolean isSafeToCancel() {
|
2023-07-17 04:34:48 +00:00
|
|
|
return !this.isActive() || !(this.state == State.FLYING || this.state == State.START_FLYING);
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public enum State {
|
2023-07-18 02:57:24 +00:00
|
|
|
LOCATE_JUMP("Finding spot to jump off"),
|
|
|
|
PAUSE("Waiting for elytra path"),
|
|
|
|
GET_TO_JUMP("Walking to takeoff"),
|
|
|
|
START_FLYING("Begin flying"),
|
|
|
|
FLYING("Flying"),
|
|
|
|
LANDING("Landing");
|
|
|
|
|
2023-07-18 04:02:23 +00:00
|
|
|
public final String description;
|
2023-07-18 02:57:24 +00:00
|
|
|
|
|
|
|
State(String desc) {
|
|
|
|
this.description = desc;
|
|
|
|
}
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|
2023-07-17 04:34:48 +00:00
|
|
|
|
|
|
|
@Override
|
|
|
|
public void onRenderPass(RenderEvent event) {
|
|
|
|
if (this.behavior != null) this.behavior.onRenderPass(event);
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public void onWorldEvent(WorldEvent event) {
|
2023-07-18 18:52:09 +00:00
|
|
|
if (event.getWorld() != null && event.getState() == EventState.POST && this.behavior != null) {
|
2023-07-18 02:57:24 +00:00
|
|
|
// Exiting the world, just destroy
|
2023-07-18 18:52:09 +00:00
|
|
|
this.behavior.destroy();
|
2023-07-18 21:48:00 +00:00
|
|
|
this.behavior = null;
|
2023-07-18 02:57:24 +00:00
|
|
|
}
|
2023-07-17 04:34:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public void onChunkEvent(ChunkEvent event) {
|
|
|
|
if (this.behavior != null) this.behavior.onChunkEvent(event);
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public void onBlockChange(BlockChangeEvent event) {
|
|
|
|
if (this.behavior != null) this.behavior.onBlockChange(event);
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public void onReceivePacket(PacketEvent event) {
|
|
|
|
if (this.behavior != null) this.behavior.onReceivePacket(event);
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public void onPostTick(TickEvent event) {
|
2023-07-17 22:16:09 +00:00
|
|
|
IBaritoneProcess procThisTick = baritone.getPathingControlManager().mostRecentInControl().orElse(null);
|
|
|
|
if (this.behavior != null && procThisTick == this) this.behavior.onPostTick(event);
|
2023-07-17 04:34:48 +00:00
|
|
|
}
|
2023-07-19 04:35:09 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Custom calculation context which makes the player fall into lava
|
|
|
|
*/
|
|
|
|
public static final class WalkOffCalculationContext extends CalculationContext {
|
|
|
|
|
|
|
|
public WalkOffCalculationContext(IBaritone baritone) {
|
|
|
|
super(baritone, true);
|
|
|
|
this.allowFallIntoLava = true;
|
|
|
|
this.minFallHeight = 8;
|
|
|
|
this.maxFallHeightNoWater = 10000;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public double costOfPlacingAt(int x, int y, int z, IBlockState current) {
|
|
|
|
return COST_INF;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public double breakCostMultiplierAt(int x, int y, int z, IBlockState current) {
|
|
|
|
return COST_INF;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Override
|
|
|
|
public double placeBucketCost() {
|
|
|
|
return COST_INF;
|
|
|
|
}
|
|
|
|
}
|
2023-07-22 23:12:53 +00:00
|
|
|
|
|
|
|
private static boolean isInBounds(BlockPos pos) {
|
|
|
|
return pos.getY() >= 0 && pos.getY() < 128;
|
|
|
|
}
|
|
|
|
|
2023-07-23 00:24:36 +00:00
|
|
|
private boolean isAtEdge(BlockPos pos) {
|
|
|
|
return ctx.world().isAirBlock(pos.north())
|
|
|
|
|| ctx.world().isAirBlock(pos.south())
|
|
|
|
|| ctx.world().isAirBlock(pos.east())
|
|
|
|
|| ctx.world().isAirBlock(pos.west())
|
|
|
|
// corners
|
|
|
|
|| ctx.world().isAirBlock(pos.north().west())
|
|
|
|
|| ctx.world().isAirBlock(pos.north().east())
|
|
|
|
|| ctx.world().isAirBlock(pos.south().west())
|
|
|
|
|| ctx.world().isAirBlock(pos.south().east());
|
|
|
|
}
|
|
|
|
|
2023-07-22 23:12:53 +00:00
|
|
|
private boolean isSafeLandingSpot(BlockPos pos) {
|
|
|
|
BlockPos.MutableBlockPos mut = new BlockPos.MutableBlockPos(pos);
|
|
|
|
while (mut.getY() >= 0) {
|
|
|
|
IBlockState state = ctx.world().getBlockState(mut);
|
2023-07-23 05:00:23 +00:00
|
|
|
Block block = state.getBlock();
|
|
|
|
|
|
|
|
if (block == Blocks.NETHERRACK || block == Blocks.GRAVEL || state.getMaterial() == Material.ROCK) {
|
|
|
|
return true;
|
|
|
|
} else if (block != Blocks.AIR) {
|
2023-07-22 23:12:53 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
mut.setPos(mut.getX(), mut.getY() - 1, mut.getZ());
|
|
|
|
}
|
|
|
|
return false; // void
|
|
|
|
}
|
|
|
|
|
2023-07-23 00:24:36 +00:00
|
|
|
private BetterBlockPos findSafeLandingSpot() {
|
|
|
|
final BetterBlockPos start = ctx.playerFeet();
|
|
|
|
Queue<BetterBlockPos> queue = new LinkedList<>();
|
|
|
|
Set<BetterBlockPos> visited = new HashSet<>();
|
2023-07-22 23:12:53 +00:00
|
|
|
queue.add(start);
|
|
|
|
|
|
|
|
while (!queue.isEmpty()) {
|
2023-07-23 00:24:36 +00:00
|
|
|
BetterBlockPos pos = queue.poll();
|
2023-07-22 23:12:53 +00:00
|
|
|
if (ctx.world().isBlockLoaded(pos) && isInBounds(pos) && ctx.world().getBlockState(pos).getBlock() == Blocks.AIR) {
|
|
|
|
if (isSafeLandingSpot(pos)) {
|
|
|
|
return pos;
|
|
|
|
}
|
|
|
|
if (visited.add(pos.north())) queue.add(pos.north());
|
|
|
|
if (visited.add(pos.east())) queue.add(pos.east());
|
|
|
|
if (visited.add(pos.south())) queue.add(pos.south());
|
|
|
|
if (visited.add(pos.west())) queue.add(pos.west());
|
|
|
|
if (visited.add(pos.up())) queue.add(pos.up());
|
|
|
|
if (visited.add(pos.down())) queue.add(pos.down());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
2023-07-17 02:56:17 +00:00
|
|
|
}
|