diff --git a/src/main/java/autostore/AutoStoreMod.java b/src/main/java/autostore/AutoStoreMod.java
new file mode 100644
index 0000000..64628cf
--- /dev/null
+++ b/src/main/java/autostore/AutoStoreMod.java
@@ -0,0 +1,25 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore;
+
+import net.fabricmc.api.ModInitializer;
+
+/**
+ * 模组主入口(ModInitializer)。
+ *
+ *
本模组为纯客户端模组,真正的初始化逻辑在 {@link autostore.client.AutoStoreModClient}
+ * 中完成,因此这里的 {@link #onInitialize()} 为空实现。
+ */
+public class AutoStoreMod implements ModInitializer {
+
+ /**
+ * Fabric 加载器调用此方法完成模组初始化(服务端/通用侧)。
+ * 本模组所有功能都位于客户端,故此处无需处理。
+ */
+ @Override
+ public void onInitialize() {
+ // 客户端初始化请参见 AutoStoreModClient
+ }
+}
diff --git a/src/main/java/autostore/client/AutoStoreModClient.java b/src/main/java/autostore/client/AutoStoreModClient.java
new file mode 100644
index 0000000..c30c904
--- /dev/null
+++ b/src/main/java/autostore/client/AutoStoreModClient.java
@@ -0,0 +1,145 @@
+/*
+ * AutoStore 模组客户端主入口(Fabric ClientModInitializer)。
+ * 负责初始化配置、启动领土数据服务、注册 tick 事件与聊天监听、注册命令。
+ *
+ * 通过单例 {@link #instance()} 向其他模块提供访问入口。
+ */
+package autostore.client;
+
+import autostore.client.command.AutoStoreCommands;
+import autostore.client.config.ConfigStore;
+import autostore.client.config.ExpansionConfig;
+import autostore.client.controller.ExpansionController;
+import autostore.client.gui.AutoStoreGui;
+import autostore.client.monitor.SubtitleChatMonitor;
+import autostore.client.territory.TerritoryDataService;
+import com.mojang.brigadier.CommandDispatcher;
+import net.fabricmc.api.ClientModInitializer;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
+import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
+import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
+import net.minecraft.text.Text;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.option.KeyBinding;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.util.InputUtil;
+
+@Environment(value=EnvType.CLIENT)
+/**
+ * 客户端主入口(Fabric ClientModInitializer):
+ * 负责初始化配置、启动领土数据服务、注册 tick 事件与聊天监听、注册命令。
+ *
+ *
通过单例 {@link #instance()} 向其他模块提供访问入口。
+ */
+public class AutoStoreModClient
+implements ClientModInitializer {
+ /** 全局单例实例 */
+ private static AutoStoreModClient instance;
+ /** 配置存储(读写 JSON 配置文件) */
+ private final ConfigStore configStore = new ConfigStore();
+ /** 领土数据服务(定时拉取邦国地图) */
+ private final TerritoryDataService dataService = new TerritoryDataService();
+ /** 自动扩地控制器 */
+ private final ExpansionController controller = new ExpansionController(this.dataService);
+ /** 当前生效的扩地配置 */
+ private ExpansionConfig config;
+ /** GUI 打开请求标记(在下一帧安全地打开界面) */
+ private boolean openGuiRequested;
+ /** 一键唤出 GUI 的快捷键(默认 Ctrl+R,可在控制设置中修改) */
+ private KeyBinding guiKey;
+
+ /** GLFW 键码:R 键 */
+ private static final int KEY_R = 82;
+ /** GLFW 键码:左/右 Ctrl */
+ private static final int KEY_LEFT_CTRL = 341;
+ private static final int KEY_RIGHT_CTRL = 345;
+
+ /**
+ * 客户端初始化入口:
+ * 加载配置、以 60 秒间隔启动领土数据刷新、
+ * 注册每帧 tick(驱动控制器 + 延迟打开 GUI)、
+ * 注册聊天/游戏消息监听(转发给扩地监听器)、注册 /autostore 命令。
+ */
+ public void onInitializeClient() {
+ instance = this;
+ this.config = this.configStore.load();
+ this.dataService.start(Math.max(10L, this.config.refreshIntervalSeconds));
+ // 注册一键唤出 GUI 的快捷键(默认 Ctrl+R,可在"控制"设置中重新绑定)
+ this.guiKey = KeyBindingHelper.registerKeyBinding(new KeyBinding("key.autostore.open", KEY_R, "category.autostore"));
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ block3: {
+ this.controller.tick();
+ // Ctrl+R 组合键按下时请求打开 GUI(与 /autostore gui 命令走同一安全路径)
+ while (this.guiKey.wasPressed()) {
+ if (this.isCtrlDown(client)) {
+ this.openGui();
+ }
+ }
+ if (this.openGuiRequested) {
+ this.openGuiRequested = false;
+ try {
+ client.setScreenAndRender(new AutoStoreGui(this));
+ }
+ catch (Throwable throwable) {
+ if (client.player == null) break block3;
+ client.player.sendMessage(Text.literal(("AutoStore GUI \u6253\u5f00\u5931\u8d25: " + String.valueOf(throwable))), false);
+ }
+ }
+ }
+ });
+ ClientReceiveMessageEvents.GAME.register((message, overlay) -> this.controller.monitor().onChat(message.getString()));
+ ClientReceiveMessageEvents.CHAT.register((message, signed, profile, params, instant) -> this.controller.monitor().onChat(message.getString()));
+ ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> AutoStoreCommands.register(dispatcher, this));
+ }
+
+ /** 返回全局单例实例 */
+ public static AutoStoreModClient instance() {
+ return instance;
+ }
+
+ /** 返回自动扩地控制器 */
+ public ExpansionController controller() {
+ return this.controller;
+ }
+
+ /** 返回领土数据服务 */
+ public TerritoryDataService dataService() {
+ return this.dataService;
+ }
+
+ /** 返回配置存储 */
+ public ConfigStore configStore() {
+ return this.configStore;
+ }
+
+ /** 返回当前生效的扩地配置 */
+ public ExpansionConfig config() {
+ return this.config;
+ }
+
+ /** 保存当前配置到磁盘 */
+ public void saveConfig() {
+ this.configStore.save(this.config);
+ }
+
+ /** 返回扩地消息监听器 */
+ public SubtitleChatMonitor monitor() {
+ return this.controller.monitor();
+ }
+
+ /** 请求打开扩地 GUI(下一帧安全执行) */
+ public void openGui() {
+ this.openGuiRequested = true;
+ }
+
+ /** 检查是否按下了 Ctrl 键(左或右) */
+ private boolean isCtrlDown(MinecraftClient client) {
+ long handle = client.getWindow().getHandle();
+ return InputUtil.isKeyPressed(handle, KEY_LEFT_CTRL)
+ || InputUtil.isKeyPressed(handle, KEY_RIGHT_CTRL);
+ }
+}
diff --git a/src/main/java/autostore/client/AutoStoreModDataGenerator.java b/src/main/java/autostore/client/AutoStoreModDataGenerator.java
new file mode 100644
index 0000000..4304e48
--- /dev/null
+++ b/src/main/java/autostore/client/AutoStoreModDataGenerator.java
@@ -0,0 +1,31 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint;
+import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator;
+
+/**
+ * Fabric 数据生成入口(DataGeneratorEntrypoint)。
+ *
+ *
将模组对 Baritone 寻路机器人的调用集中在此类中,包括:应用寻路行为配置、
+ * 前往目标区块、取消寻路、查询寻路状态等。所有调用都做了空安全保护,
+ * 当 Baritone 未加载时相关操作会安全地静默失败。
+ */
+@Environment(EnvType.CLIENT)
+public final class BaritoneBridge {
+
+ /**
+ * 将模组配置同步到 Baritone 的全局设置。
+ *
+ * @param config 扩地配置
+ */
+ public void applyConfig(ExpansionConfig config) {
+ Settings settings = BaritoneAPI.getSettings();
+ settings.allowSprint.value = config.allowSprint;
+ settings.allowBreak.value = config.allowBreak;
+ settings.allowPlace.value = config.allowPlace;
+ settings.allowParkour.value = config.allowJump;
+ settings.avoidance.value = config.avoidMobs;
+ }
+
+ /**
+ * 让 Baritone 寻路前往指定区块的中心。
+ *
+ * @param chunk 目标区块
+ */
+ public void pathTo(ChunkPos chunk) {
+ IBaritone baritone = this.primary();
+ if (baritone == null) {
+ return;
+ }
+ // 使用 GoalXZ 前往区块中心:Baritone 的 GoalXZ 自带约 10 格容差,
+ // 只需到达区块中心附近即可,避免中心点不可达导致寻路卡死
+ baritone.getCustomGoalProcess().setGoalAndPath(new GoalXZ(chunk.centerBlockX(), chunk.centerBlockZ()));
+ }
+
+ /** 强制取消当前寻路任务。 */
+ public void cancel() {
+ IBaritone baritone = this.primary();
+ if (baritone == null) {
+ return;
+ }
+ baritone.getPathingBehavior().forceCancel();
+ }
+
+ /** @return 当前是否正在寻路 */
+ public boolean isPathing() {
+ IBaritone baritone = this.primary();
+ return baritone != null && baritone.getPathingBehavior().isPathing();
+ }
+
+ /** @return Baritone 是否可用(已加载并存在主实例) */
+ public boolean isAvailable() {
+ return this.primary() != null;
+ }
+
+ /**
+ * 获取 Baritone 主实例;加载失败或异常时返回 null。
+ *
+ * @return 主 Baritone 实例,或 null
+ */
+ private IBaritone primary() {
+ try {
+ return BaritoneAPI.getProvider().getPrimaryBaritone();
+ } catch (Throwable ignored) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/autostore/client/command/AutoStoreCommands.java b/src/main/java/autostore/client/command/AutoStoreCommands.java
new file mode 100644
index 0000000..3967799
--- /dev/null
+++ b/src/main/java/autostore/client/command/AutoStoreCommands.java
@@ -0,0 +1,121 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.command;
+
+import autostore.client.AutoStoreModClient;
+import autostore.client.config.ExpansionConfig;
+import autostore.client.controller.ExpansionController;
+import autostore.client.territory.ChunkPos;
+import autostore.client.territory.TerritoryState;
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.arguments.ArgumentType;
+import com.mojang.brigadier.arguments.IntegerArgumentType;
+import com.mojang.brigadier.arguments.StringArgumentType;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.context.CommandContext;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager;
+import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.text.Text;
+
+@Environment(value=EnvType.CLIENT)
+/**
+ * 客户端命令注册:向游戏注册 /autostore 系列命令。
+ *
+ * dispatcher, AutoStoreModClient mod) {
+ dispatcher.register((((((((((ClientCommandManager.literal((String)"autostore").then(ClientCommandManager.literal((String)"gui").executes(ctx -> {
+ (ctx.getSource()).sendFeedback(Text.literal((String)"\u5df2\u6253\u5f00 AutoStore GUI"));
+ mod.openGui();
+ return 1;
+ }))).then((ClientCommandManager.literal((String)"start").executes(ctx -> AutoStoreCommands.start(ctx.getSource(), mod, mod.config().targetCount, mod.config().strategy))).then(ClientCommandManager.argument((String)"count", IntegerArgumentType.integer()).executes(ctx -> AutoStoreCommands.start(ctx.getSource(), mod, IntegerArgumentType.getInteger(ctx, (String)"count"), mod.config().strategy))))).then(ClientCommandManager.literal((String)"stop").executes(ctx -> {
+ mod.controller().stop();
+ (ctx.getSource()).sendFeedback(Text.literal((String)"\u5df2\u505c\u6b62\u81ea\u52a8\u6269\u5730"));
+ return 1;
+ }))).then(ClientCommandManager.literal((String)"status").executes(ctx -> AutoStoreCommands.status(ctx.getSource(), mod)))).then(ClientCommandManager.literal((String)"refresh").executes(ctx -> {
+ mod.dataService().refreshAsync();
+ (ctx.getSource()).sendFeedback(Text.literal((String)"\u6b63\u5728\u5237\u65b0\u90a6\u56fd\u5730\u56fe"));
+ return 1;
+ }))).then(ClientCommandManager.literal((String)"auto").executes(ctx -> {
+ TerritoryState state = mod.dataService().model().findContainingState(AutoStoreCommands.playerChunk(ctx.getSource()));
+ if (state == null) {
+ (ctx.getSource()).sendError(Text.literal((String)"\u65e0\u6cd5\u8bc6\u522b\u5f53\u524d\u90a6\u56fd"));
+ } else {
+ mod.config().stateName = state.name();
+ mod.saveConfig();
+ (ctx.getSource()).sendFeedback(Text.literal(("\u5df2\u81ea\u52a8\u8bc6\u522b\u90a6\u56fd\uff1a" + state.name())));
+ }
+ return 1;
+ }))).then(ClientCommandManager.literal((String)"state").then(ClientCommandManager.argument((String)"name", StringArgumentType.greedyString()).executes(ctx -> {
+ String name;
+ mod.config().stateName = name = StringArgumentType.getString(ctx, (String)"name");
+ mod.saveConfig();
+ (ctx.getSource()).sendFeedback(Text.literal(("\u5df2\u8bbe\u7f6e\u90a6\u56fd\uff1a" + name)));
+ return 1;
+ })))).then(ClientCommandManager.literal((String)"strategy").then(ClientCommandManager.argument((String)"strategy", IntegerArgumentType.integer((int)1, (int)4)).executes(ctx -> {
+ int strategy;
+ mod.config().strategy = strategy = IntegerArgumentType.getInteger(ctx, (String)"strategy");
+ mod.saveConfig();
+ (ctx.getSource()).sendFeedback(Text.literal(("\u5df2\u8bbe\u7f6e\u6269\u5f20\u7b56\u7565\uff1a" + strategy)));
+ return 1;
+ })))).then(ClientCommandManager.literal((String)"rings").then(ClientCommandManager.argument((String)"rings", IntegerArgumentType.integer((int)0)).executes(ctx -> {
+ int rings;
+ mod.config().maxRings = rings = IntegerArgumentType.getInteger(ctx, (String)"rings");
+ mod.saveConfig();
+ (ctx.getSource()).sendFeedback(Text.literal((rings <= 0 ? "\u5df2\u8bbe\u7f6e\u6301\u7eed\u6269\u5730" : "\u5df2\u8bbe\u7f6e\u6269\u5708\u4e0a\u9650\uff1a" + rings)));
+ return 1;
+ })))).then(ClientCommandManager.literal((String)"help").executes(ctx -> {
+ (ctx.getSource()).sendFeedback(Text.literal((String)"/autostore help \u663e\u793a\u5e2e\u52a9\n/autostore gui \u6253\u5f00 GUI\uff08\u6216\u6309 F6 \u5feb\u6377\u952e\uff09\n/autostore start \u5f00\u59cb\u6269\u5730\n/autostore start <\u6570\u91cf> \u6307\u5b9a\u76ee\u6807\u6570\u91cf\uff0c-1 \u4e3a\u6301\u7eed\n/autostore stop \u505c\u6b62\n/autostore status \u67e5\u770b\u72b6\u6001\n/autostore refresh \u5237\u65b0\u5730\u56fe\n/autostore auto \u81ea\u52a8\u8bc6\u522b\u90a6\u56fd\n/autostore state <\u90a6\u56fd\u540d> \u8bbe\u7f6e\u90a6\u56fd\n/autostore strategy <1|2|3|4> \u8bbe\u7f6e\u7b56\u7565\n/autostore rings <\u5c42\u6570> \u8bbe\u7f6e\u5708\u6570\u4e0a\u9650"));
+ return 1;
+ })));
+ }
+
+ /** /autostore start 命令处理:复制当前配置并覆盖目标数量与策略,然后启动扩地 */
+ private static int start(FabricClientCommandSource source, AutoStoreModClient mod, int targetCount, int strategy) {
+ ExpansionConfig config = mod.config().copy();
+ config.targetCount = targetCount;
+ config.strategy = strategy;
+ mod.config().targetCount = targetCount;
+ mod.config().strategy = strategy;
+ mod.saveConfig();
+ boolean started = mod.controller().start(config);
+ source.sendFeedback(Text.literal((started ? "\u5f00\u59cb\u81ea\u52a8\u6269\u5730" : "\u542f\u52a8\u5931\u8d25\uff1a" + mod.controller().status())));
+ return started ? 1 : 0;
+ }
+
+ /** /autostore status 命令处理:汇总状态、进度、队列、策略与邦国信息并反馈 */
+ private static int status(FabricClientCommandSource source, AutoStoreModClient mod) {
+ ExpansionController controller = mod.controller();
+ String text = "\u72b6\u6001\uff1a" + controller.status() + " | \u8fdb\u5ea6\uff1a" + controller.completedClaims() + (mod.config().targetCount > 0 ? "/" + mod.config().targetCount : "/\u6301\u7eed") + " | \u961f\u5217\uff1a" + controller.queuedTargets() + " | \u7b56\u7565\uff1a" + mod.config().strategy + " | \u90a6\u56fd\uff1a" + (mod.config().stateName.isEmpty() ? "\u81ea\u52a8" : mod.config().stateName);
+ source.sendFeedback(Text.literal((String)text));
+ return 1;
+ }
+
+ /** 获取命令执行者(玩家)当前所在区块 */
+ private static ChunkPos playerChunk(FabricClientCommandSource source) {
+ BlockPos pos = source.getPlayer().getBlockPos();
+ return ChunkPos.fromBlock(pos.getX(), pos.getZ());
+ }
+}
+
diff --git a/src/main/java/autostore/client/config/ConfigStore.java b/src/main/java/autostore/client/config/ConfigStore.java
new file mode 100644
index 0000000..441f557
--- /dev/null
+++ b/src/main/java/autostore/client/config/ConfigStore.java
@@ -0,0 +1,80 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.config;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileAttribute;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.loader.api.FabricLoader;
+
+/**
+ * 配置文件读写器。
+ *
+ * 使用 Gson 将 {@link ExpansionConfig} 序列化为 JSON,
+ * 配置文件位于 Fabric 配置目录下的 {@code autostore.json}。
+ */
+@Environment(EnvType.CLIENT)
+public final class ConfigStore {
+
+ /** 用于格式化的 Gson 实例(美化输出) */
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+ /**
+ * 从磁盘加载配置;文件不存在或解析失败时返回默认配置。
+ *
+ * @return 加载到的配置(不会为 null)
+ */
+ public ExpansionConfig load() {
+ Path path = this.path();
+ if (!Files.exists(path, new LinkOption[0])) {
+ return new ExpansionConfig();
+ }
+ try {
+ String json = Files.readString(path, StandardCharsets.UTF_8);
+ ExpansionConfig loaded = GSON.fromJson(json, ExpansionConfig.class);
+ if (loaded == null) {
+ return new ExpansionConfig();
+ }
+ // 以下字段为固定默认值,防止旧配置文件中的异常值影响运行
+ loaded.autoEat = true;
+ loaded.eatThreshold = 14;
+ loaded.maxRings = 0;
+ return loaded;
+ } catch (Exception e) {
+ // 解析失败时回退到默认配置
+ return new ExpansionConfig();
+ }
+ }
+
+ /**
+ * 将配置保存到磁盘(自动创建父目录)。
+ *
+ * @param config 要保存的配置
+ */
+ public void save(ExpansionConfig config) {
+ Path path = this.path();
+ try {
+ if (path.getParent() != null) {
+ Files.createDirectories(path.getParent(), new FileAttribute[0]);
+ }
+ Files.writeString(path, GSON.toJson(config), StandardCharsets.UTF_8, new OpenOption[0]);
+ } catch (IOException iOException) {
+ // 保存失败时静默忽略(不阻塞游戏运行)
+ }
+ }
+
+ /** @return 配置文件路径(Fabric 配置目录 / autostore.json) */
+ private Path path() {
+ return FabricLoader.getInstance().getConfigDir().resolve("autostore.json");
+ }
+}
diff --git a/src/main/java/autostore/client/config/ExpansionConfig.java b/src/main/java/autostore/client/config/ExpansionConfig.java
new file mode 100644
index 0000000..0b5f88b
--- /dev/null
+++ b/src/main/java/autostore/client/config/ExpansionConfig.java
@@ -0,0 +1,116 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.config;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+/**
+ * 自动扩地配置数据类。
+ *
+ * 保存了扩地策略、目标数量、超时重试、区域限制以及 Baritone 寻路行为等全部可配置项,
+ * 通过 {@link ConfigStore} 持久化为 JSON 文件。
+ */
+@Environment(EnvType.CLIENT)
+public class ExpansionConfig {
+
+ /** 扩地策略:1=方形外扩,2=区域扩地,3=玩家周围,4=绕国一圈 */
+ public int strategy = 1;
+
+ /** 目标邦国名称;为空时表示自动识别玩家当前所在邦国 */
+ public String stateName = "";
+
+ /** 目标扩地数量;-1 表示持续扩地直到无可用区块 */
+ public int targetCount = -1;
+
+ /** 等待/寻路超时时间(秒) */
+ public int waitTimeoutSeconds = 60;
+
+ /** 超时后的最大重试次数 */
+ public int retries = 2;
+
+ /** 区域扩地策略的中心 X 坐标(区块) */
+ public int regionCenterX;
+
+ /** 区域扩地策略的中心 Z 坐标(区块) */
+ public int regionCenterZ;
+
+ /** 区域扩地策略的半径(区块) */
+ public int regionRadius = 5;
+
+ /** 区域扩地策略的矩形边界(区块坐标);minX > maxX 表示未圈选,使用中心+半径 */
+ public int regionMinX = 0;
+
+ /** 区域扩地策略的矩形边界(区块坐标);未圈选时为 -1 */
+ public int regionMaxX = -1;
+
+ /** 区域扩地策略的矩形边界(区块坐标);minZ > maxZ 表示未圈选,使用中心+半径 */
+ public int regionMinZ = 0;
+
+ /** 区域扩地策略的矩形边界(区块坐标);未圈选时为 -1 */
+ public int regionMaxZ = -1;
+
+ /** 是否允许 Baritone 疾跑 */
+ public boolean allowSprint = true;
+
+ /** 是否允许 Baritone 游泳 */
+ public boolean allowSwim = true;
+
+ /** 是否允许 Baritone 跳跃 */
+ public boolean allowJump = true;
+
+ /** 是否允许 Baritone 破坏方块 */
+ public boolean allowBreak;
+
+ /** 是否允许 Baritone 放置方块 */
+ public boolean allowPlace;
+
+ /** 是否启用避开怪物 */
+ public boolean avoidMobs = true;
+
+ /** 是否启用自动进食(饥饿时自动吃背包食物) */
+ public boolean autoEat = true;
+
+ /** 触发自动进食的饥饿值阈值(0-20) */
+ public int eatThreshold = 14;
+
+ /** 扩圈上限(0 表示不限制) */
+ public int maxRings;
+
+ /** 地图自动刷新间隔(秒),最小 10 秒 */
+ public int refreshIntervalSeconds = 30;
+
+ /**
+ * 深拷贝当前配置,返回一个独立的副本。
+ *
+ * @return 与当前配置完全相同的副本
+ */
+ public ExpansionConfig copy() {
+ ExpansionConfig c = new ExpansionConfig();
+ c.strategy = this.strategy;
+ c.stateName = this.stateName;
+ c.targetCount = this.targetCount;
+ c.waitTimeoutSeconds = this.waitTimeoutSeconds;
+ c.retries = this.retries;
+ c.regionCenterX = this.regionCenterX;
+ c.regionCenterZ = this.regionCenterZ;
+ c.regionRadius = this.regionRadius;
+ c.regionMinX = this.regionMinX;
+ c.regionMaxX = this.regionMaxX;
+ c.regionMinZ = this.regionMinZ;
+ c.regionMaxZ = this.regionMaxZ;
+ c.allowSprint = this.allowSprint;
+ c.allowSwim = this.allowSwim;
+ c.allowJump = this.allowJump;
+ c.allowBreak = this.allowBreak;
+ c.allowPlace = this.allowPlace;
+ c.avoidMobs = this.avoidMobs;
+ c.autoEat = this.autoEat;
+ c.eatThreshold = this.eatThreshold;
+ c.maxRings = this.maxRings;
+ c.refreshIntervalSeconds = this.refreshIntervalSeconds;
+ return c;
+ }
+}
diff --git a/src/main/java/autostore/client/controller/ExpansionController.java b/src/main/java/autostore/client/controller/ExpansionController.java
new file mode 100644
index 0000000..2664774
--- /dev/null
+++ b/src/main/java/autostore/client/controller/ExpansionController.java
@@ -0,0 +1,587 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.controller;
+
+import autostore.client.bridge.BaritoneBridge;
+import autostore.client.config.ExpansionConfig;
+import autostore.client.monitor.SubtitleChatMonitor;
+import autostore.client.planner.ExpansionPlanner;
+import autostore.client.territory.ChunkPos;
+import autostore.client.territory.TerritoryDataService;
+import autostore.client.territory.TerritoryModel;
+import autostore.client.territory.TerritoryState;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.util.Hand;
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.entity.player.PlayerInventory;
+import net.minecraft.item.ItemStack;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.component.DataComponentTypes;
+
+@Environment(value=EnvType.CLIENT)
+/**
+ * 自动扩地控制器:扩地任务的核心状态机。
+ *
+ * 工作流程:规划(PLANNING)→ 寻路(PATHING)→ 等待开垦(WAITING)→ 成功/失败(SUCCESS/FAIL)→ 下一个目标(NEXT)→ 完成(COMPLETE)。
+ * 每帧由 {@link #tick()} 驱动,通过 Baritone 自动寻路到目标区块,
+ * 并监听聊天/字幕消息判断开垦是否成功。支持自动进食、超时重试、手动停止等功能。
+ */
+public final class ExpansionController
+implements SubtitleChatMonitor.Listener {
+ /** 日志环形缓冲区的最大条数 */
+ private static final int MAX_LOGS = 80;
+ /** Baritone 寻路桥接(负责自动移动) */
+ private final BaritoneBridge baritoneBridge = new BaritoneBridge();
+ /** 扩地规划器(决定下一个目标区块) */
+ private final ExpansionPlanner planner = new ExpansionPlanner();
+ /** 聊天/字幕监听器(识别开垦进度与结果) */
+ private final SubtitleChatMonitor monitor = new SubtitleChatMonitor(this);
+ /** 领土数据服务(提供实时领土模型) */
+ private final TerritoryDataService dataService;
+ /** 待占领区块队列 */
+ private final Deque queue = new ArrayDeque();
+ /** 运行日志队列(线程安全) */
+ private final Deque logs = new ArrayDeque();
+ /** 当前扩地配置(启动时从请求配置复制) */
+ private ExpansionConfig config = new ExpansionConfig();
+ /** 当前领土模型 */
+ private TerritoryModel model = TerritoryModel.empty();
+ /** 当前状态机状态 */
+ private State state = State.IDLE;
+ /** 当前状态描述文本(显示在 GUI/命令中) */
+ private String status = "\u672a\u542f\u52a8";
+ /** 当前目标区块 */
+ private ChunkPos target;
+ /** 玩家当前所在区块(每帧更新) */
+ private ChunkPos playerChunk = new ChunkPos(0, 0);
+ /** 已成功开垦的区块数量 */
+ private int completedClaims;
+ /** 当前目标的连续重试次数 */
+ private int retryCount;
+ /** 当前阶段(寻路/等待)的开始时间戳 */
+ private long stageStartedMillis;
+ /** 最近一次开垦进度(0-100) */
+ private int lastProgress;
+ /** 当前目标区块的哈希集合,用于去重(防止重复计数) */
+ private final Set claimedSet = new HashSet<>();
+ /** 是否正在自动进食 */
+ private boolean eating;
+
+ public ExpansionController(TerritoryDataService dataService) {
+ this.dataService = dataService;
+ }
+
+ public SubtitleChatMonitor monitor() {
+ return this.monitor;
+ }
+
+ public State state() {
+ return this.state;
+ }
+
+ public String status() {
+ return this.status;
+ }
+
+ public int completedClaims() {
+ return this.completedClaims;
+ }
+
+ public int queuedTargets() {
+ return this.queue.size();
+ }
+
+ public ChunkPos target() {
+ return this.target;
+ }
+
+ public int lastProgress() {
+ return this.lastProgress;
+ }
+
+ /** 返回运行日志的副本(线程安全) */
+ public List logs() {
+ Deque deque = this.logs;
+ synchronized (deque) {
+ return new ArrayList(this.logs);
+ }
+ }
+
+ public boolean isRunning() {
+ return this.state != State.IDLE && this.state != State.COMPLETE && this.state != State.STOPPED;
+ }
+
+ /**
+ * 启动自动扩地任务。
+ *
+ * 前置校验:必须在服务器世界中、Baritone 已就绪;
+ * 邦国名称为空时自动识别玩家所在邦国,否则校验指定邦国是否存在。
+ * 校验通过后清空队列、生成扩地计划并进入 PLANNING 状态。
+ *
+ * @param requestedConfig 本次任务的扩地配置
+ * @return 是否成功启动
+ */
+ public boolean start(ExpansionConfig requestedConfig) {
+ MinecraftClient client = MinecraftClient.getInstance();
+ if (client == null || client.player == null || client.world == null) {
+ this.status = "\u8bf7\u5148\u8fdb\u5165\u670d\u52a1\u5668\u4e16\u754c";
+ return false;
+ }
+ if (!this.baritoneBridge.isAvailable()) {
+ this.status = "Baritone \u5c1a\u672a\u521d\u59cb\u5316\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5";
+ return false;
+ }
+ this.config = requestedConfig.copy();
+ this.model = this.dataService.model();
+ this.playerChunk = this.playerChunk(client);
+ if (this.config.stateName.isEmpty()) {
+ TerritoryState auto = this.model.findContainingState(this.playerChunk);
+ if (auto == null) {
+ this.status = "\u65e0\u6cd5\u81ea\u52a8\u8bc6\u522b\u90a6\u56fd\uff0c\u8bf7\u5728 GUI \u4e2d\u624b\u52a8\u9009\u62e9";
+ this.state = State.IDLE;
+ return false;
+ }
+ this.model.setSelfStateName(auto.name());
+ this.dataService.setSelectedStateName(auto.name());
+ } else {
+ if (this.model.state(this.config.stateName) == null) {
+ this.status = "\u6240\u9009\u90a6\u56fd\u4e0d\u5b58\u5728\uff1a" + this.config.stateName;
+ this.state = State.IDLE;
+ return false;
+ }
+ this.model.setSelfStateName(this.config.stateName);
+ this.dataService.setSelectedStateName(this.config.stateName);
+ }
+ this.completedClaims = 0;
+ this.retryCount = 0;
+ this.queue.clear();
+ this.claimedSet.clear();
+ this.target = null;
+ this.eating = false;
+ this.baritoneBridge.applyConfig(this.config);
+ this.setState(State.PLANNING, "\u6b63\u5728\u751f\u6210\u6269\u5f20\u8ba1\u5212");
+ this.log("\u5f00\u59cb\u81ea\u52a8\u6269\u5730\uff0c\u90a6\u56fd\uff1a" + this.model.selfStateName());
+ this.plan();
+ return true;
+ }
+
+ /** 手动停止任务:取消 Baritone 寻路并进入 STOPPED 状态 */
+ public void stop() {
+ if (this.state == State.IDLE || this.state == State.COMPLETE || this.state == State.STOPPED) {
+ return;
+ }
+ this.baritoneBridge.cancel();
+ this.eating = false;
+ this.setState(State.STOPPED, "\u5df2\u624b\u52a8\u505c\u6b62");
+ this.log("\u5df2\u624b\u52a8\u505c\u6b62");
+ }
+
+ /**
+ * 每帧驱动逻辑(由客户端 tick 事件调用):
+ * 更新玩家区块,优先处理自动进食,再按当前状态分发到对应的 tick 方法。
+ * 若已退出世界则自动停止任务。
+ */
+ public void tick() {
+ MinecraftClient client = MinecraftClient.getInstance();
+ if (client == null || client.player == null || client.world == null) {
+ if (this.isRunning()) {
+ this.stop();
+ this.status = "\u4e16\u754c\u5df2\u9000\u51fa\uff0c\u4efb\u52a1\u505c\u6b62";
+ }
+ return;
+ }
+ this.playerChunk = this.playerChunk(client);
+ if (this.tickAutoFood(client)) {
+ return;
+ }
+ switch (this.state.ordinal()) {
+ case 1: {
+ this.plan();
+ break;
+ }
+ case 2: {
+ this.tickPathing();
+ break;
+ }
+ case 3: {
+ this.tickWaiting();
+ break;
+ }
+ case 4: {
+ this.tickSuccess();
+ break;
+ }
+ case 5: {
+ this.tickFail();
+ break;
+ }
+ case 6: {
+ this.tickNext();
+ break;
+ }
+ }
+ }
+
+ /**
+ * 生成扩地计划:调用规划器计算候选区块,按配置过滤圈数上限后入队。
+ * 若已达成目标数量或没有可用区块则直接完成。
+ */
+ private void plan() {
+ this.model = this.dataService.model();
+ if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) {
+ this.finish();
+ return;
+ }
+ this.queue.clear();
+ List planned = this.planner.generate(this.model, this.config, this.playerChunk);
+ // 过滤掉规划后已被他人占领的区块,避免寻路到无效目标
+ for (ChunkPos chunk : planned) {
+ if (!this.isClaimedByOthers(chunk)) {
+ this.queue.add(chunk);
+ }
+ }
+ // 矩形区域扩地时不受扩圈上限限制(玩家圈选的矩形即为明确边界,需完整填充)
+ boolean rectangleMode = this.config.strategy == 2 && this.config.regionMaxX >= this.config.regionMinX;
+ if (this.config.maxRings > 0 && !rectangleMode) {
+ this.queue.removeIf(chunk -> this.ringDistance(chunk, this.model) > this.config.maxRings);
+ }
+ this.log("\u89c4\u5212\u5b8c\u6210\uff0c\u5171 " + this.queue.size() + " \u4e2a\u5019\u9009\u533a\u5757");
+ if (this.queue.isEmpty()) {
+ this.setState(State.COMPLETE, "\u6ca1\u6709\u53ef\u7528\u7684\u76f8\u90bb\u65e0\u4e3b\u533a\u5757");
+ this.log("\u6ca1\u6709\u53ef\u7528\u7684\u76f8\u90bb\u65e0\u4e3b\u533a\u5757");
+ return;
+ }
+ this.target = this.queue.removeFirst();
+ this.startPathing();
+ }
+
+ /** 让 Baritone 寻路前往当前目标区块,并进入 PATHING 状态 */
+ private void startPathing() {
+ this.retryCount = 0;
+ this.stageStartedMillis = System.currentTimeMillis();
+ this.lastProgress = 0;
+ this.baritoneBridge.pathTo(this.target);
+ this.setState(State.PATHING, "\u6b63\u5728\u524d\u5f80\u76ee\u6807\u533a\u5757 " + this.target.x() + "," + this.target.z());
+ }
+
+ /**
+ * 寻路阶段每帧逻辑:到达目标区块后进入等待开垦状态;
+ * 若寻路超时则重试,超过重试次数则跳过该目标。
+ */
+ private void tickPathing() {
+ if (this.target == null) {
+ this.setState(State.NEXT, "\u76ee\u6807\u4e3a\u7a7a");
+ return;
+ }
+ if (this.playerChunk.equals(this.target)) {
+ this.baritoneBridge.cancel();
+ this.stageStartedMillis = System.currentTimeMillis();
+ this.setState(State.WAITING, "\u5df2\u5230\u8fbe\uff0c\u7b49\u5f85\u9886\u5730\u5f00\u62d3");
+ return;
+ }
+ long elapsedSeconds = (System.currentTimeMillis() - this.stageStartedMillis) / 1000L;
+ if (elapsedSeconds >= this.config.waitTimeoutSeconds) {
+ ++this.retryCount;
+ if (this.retryCount >= this.config.retries) {
+ this.log("\u5bfb\u8def\u8d85\u65f6\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z());
+ this.setState(State.FAIL, "\u5bfb\u8def\u8d85\u65f6");
+ } else {
+ this.log("\u5bfb\u8def\u8d85\u65f6\uff0c\u91cd\u8bd5 " + this.retryCount + "/" + this.config.retries);
+ this.retrySameTarget("\u91cd\u8bd5\u524d\u5f80 " + this.target.x() + "," + this.target.z());
+ }
+ }
+ }
+
+ /** 等待开垦阶段每帧逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过当前目标 */
+ private void tickWaiting() {
+ if (this.target == null) {
+ this.setState(State.NEXT, "\u76ee\u6807\u4e3a\u7a7a");
+ return;
+ }
+ // 玩家离开了目标区块(被击退/寻路偏差),重新寻路回去
+ if (!this.playerChunk.equals(this.target)) {
+ this.retrySameTarget("\u79bb\u5f00\u76ee\u6807\u533a\u5757\uff0c\u91cd\u65b0\u524d\u5f80 " + this.target.x() + "," + this.target.z());
+ return;
+ }
+ long elapsedSeconds = (System.currentTimeMillis() - this.stageStartedMillis) / 1000L;
+ if (elapsedSeconds >= this.config.waitTimeoutSeconds) {
+ ++this.retryCount;
+ if (this.retryCount >= this.config.retries) {
+ this.log("\u7b49\u5f85\u8d85\u65f6\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z());
+ this.setState(State.FAIL, "\u7b49\u5f85\u9886\u5730\u5f00\u62d3\u8d85\u65f6");
+ } else {
+ this.log("\u7b49\u5f85\u8d85\u65f6\uff0c\u91cd\u8bd5 " + this.retryCount + "/" + this.config.retries);
+ this.retrySameTarget("\u7b49\u5f85\u8d85\u65f6\uff0c\u91cd\u65b0\u524d\u5f80 " + this.target.x() + "," + this.target.z());
+ }
+ }
+ }
+
+ /** 重试当前目标:取消旧寻路,重新让 Baritone 前往同一目标区块 */
+ private void retrySameTarget(String statusText) {
+ this.baritoneBridge.cancel();
+ this.stageStartedMillis = System.currentTimeMillis();
+ this.baritoneBridge.pathTo(this.target);
+ this.setState(State.PATHING, statusText);
+ }
+
+ /** 成功后的过渡:进入 NEXT 状态,准备处理下一个目标 */
+ private void tickSuccess() {
+ this.setState(State.NEXT, "\u5df2\u5b8c\u6210 " + this.completedClaims + (this.config.targetCount > 0 ? "/" + this.config.targetCount : "/\u6301\u7eed"));
+ }
+
+ /** 失败后的过渡:进入 NEXT 状态,跳过当前目标 */
+ private void tickFail() {
+ this.setState(State.NEXT, "\u51c6\u5907\u4e0b\u4e00\u4e2a\u76ee\u6807");
+ }
+
+ /**
+ * 处理下一个目标:若已达成目标数量则完成;
+ * 队列为空时重新规划,否则取出队首区块开始寻路。
+ */
+ private void tickNext() {
+ this.target = null;
+ if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) {
+ this.finish();
+ return;
+ }
+ if (this.queue.isEmpty()) {
+ this.plan();
+ return;
+ }
+ this.target = this.queue.removeFirst();
+ // 目标已被他人占领时跳过,避免无效寻路
+ if (this.isClaimedByOthers(this.target)) {
+ this.log("\u76ee\u6807\u5df2\u88ab\u5360\u9886\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z());
+ this.setState(State.NEXT, "\u8df3\u8fc7\u5df2\u5360\u9886\u533a\u5757");
+ return;
+ }
+ this.startPathing();
+ }
+
+ /** 完成任务:取消寻路、停止进食,进入 COMPLETE 状态 */
+ private void finish() {
+ this.baritoneBridge.cancel();
+ this.eating = false;
+ this.setState(State.COMPLETE, this.config.targetCount > 0 ? "\u5df2\u5b8c\u6210\u76ee\u6807\u6570\u91cf " + this.completedClaims + "/" + this.config.targetCount : "\u5df2\u65e0\u53ef\u6269\u533a\u5757\uff0c\u7d2f\u8ba1\u6269\u5730 " + this.completedClaims);
+ this.log("\u81ea\u52a8\u6269\u5730\u5b8c\u6210");
+ }
+
+ /** 计算区块到己方领土的"圈数"(切比雪夫距离的最小值),用于过滤扩圈上限 */
+ private int ringDistance(ChunkPos chunk, TerritoryModel model) {
+ int distance;
+ int min = Integer.MAX_VALUE;
+ TerritoryState self = model.selfState();
+ if (self != null) {
+ for (ChunkPos own : self.chunks()) {
+ distance = Math.max(Math.abs(chunk.x() - own.x()), Math.abs(chunk.z() - own.z()));
+ min = Math.min(min, distance);
+ }
+ }
+ for (ChunkPos own : model.sessionClaims()) {
+ distance = Math.max(Math.abs(chunk.x() - own.x()), Math.abs(chunk.z() - own.z()));
+ min = Math.min(min, distance);
+ }
+ return min == Integer.MAX_VALUE ? 0 : min;
+ }
+
+ /** 监听器回调:开垦进度更新(0-100%) */
+ @Override
+ public void onProgress(int percent) {
+ if (!this.isRunning()) {
+ return;
+ }
+ this.lastProgress = percent;
+ this.status = "\u5f00\u62d3\u4e2d " + percent + "%";
+ }
+
+ /**
+ * 监听器回调:开垦成功(带具体坐标)。
+ * 将新占领区块加入模型与数据服务,累计完成数并进入 SUCCESS 状态。
+ */
+ @Override
+ public void onSuccess(ChunkPos chunk, int blockX, int blockZ) {
+ if (!this.isRunning()) {
+ return;
+ }
+ if (chunk == null) {
+ chunk = this.target;
+ }
+ if (chunk == null) {
+ return;
+ }
+ // 去重:同一区块只计一次
+ if (!this.claimedSet.add(chunk)) {
+ return;
+ }
+ this.model.addSessionClaim(chunk);
+ this.dataService.addSessionClaim(chunk);
+ ++this.completedClaims;
+ this.target = chunk;
+ this.lastProgress = 100;
+ this.log("\u5360\u9886\u6210\u529f " + chunk.x() + "," + chunk.z() + "\uff08\u533a\u5757 " + chunk.x() + "," + chunk.z() + " / \u4e16\u754c " + blockX + "," + blockZ + "\uff09");
+ this.setState(State.SUCCESS, "\u7586\u571f\u5df2\u5f00\u62d3 " + chunk.x() + "," + chunk.z());
+ }
+
+ /** 监听器回调:开垦成功(无具体坐标的通用消息),按当前目标区块记录 */
+ @Override
+ public void onGenericSuccess() {
+ if (!this.isRunning()) {
+ return;
+ }
+ if (this.target == null) {
+ return;
+ }
+ // 去重:同一区块只计一次
+ if (!this.claimedSet.add(this.target)) {
+ return;
+ }
+ this.model.addSessionClaim(this.target);
+ this.dataService.addSessionClaim(this.target);
+ ++this.completedClaims;
+ this.lastProgress = 100;
+ this.log("\u5360\u9886\u6210\u529f " + this.target.x() + "," + this.target.z());
+ this.setState(State.SUCCESS, "\u7586\u571f\u5df2\u5f00\u62d3 " + this.target.x() + "," + this.target.z());
+ }
+
+ /** 获取玩家当前所在区块(由方块坐标换算) */
+ private ChunkPos playerChunk(MinecraftClient client) {
+ BlockPos pos = client.player.getBlockPos();
+ return ChunkPos.fromBlock(pos.getX(), pos.getZ());
+ }
+
+ /** 判断区块是否已被任何邦国占领(含己方与本次会话) */
+ private boolean isClaimedByOthers(ChunkPos chunk) {
+ return this.dataService.model().allClaimedChunks().contains(chunk);
+ }
+
+ /** 返回区块的四个正方向邻居 */
+ private List cardinalNeighbors(ChunkPos chunk) {
+ return List.of(new ChunkPos(chunk.x() + 1, chunk.z()), new ChunkPos(chunk.x() - 1, chunk.z()), new ChunkPos(chunk.x(), chunk.z() + 1), new ChunkPos(chunk.x(), chunk.z() - 1));
+ }
+
+ /**
+ * 自动进食逻辑(每帧调用):
+ * 开启 autoEat 且饥饿值低于阈值时暂停寻路并自动吃背包里的食物,
+ * 吃饱后恢复任务。返回 true 表示本帧被进食占用。
+ */
+ private boolean tickAutoFood(MinecraftClient client) {
+ if (!this.config.autoEat || !this.isRunning()) {
+ this.eating = false;
+ return false;
+ }
+ int foodLevel = client.player.getHungerManager().getFoodLevel();
+ if (this.eating) {
+ if (client.player.isUsingItem()) {
+ this.status = "\u81ea\u52a8\u8fdb\u98df\u4e2d";
+ return true;
+ }
+ if (foodLevel >= 20 || foodLevel > this.config.eatThreshold) {
+ this.eating = false;
+ this.resumeAfterEating();
+ this.log("\u81ea\u52a8\u8fdb\u98df\u5b8c\u6210");
+ return false;
+ }
+ return this.beginEating(client, foodLevel);
+ }
+ if (foodLevel > this.config.eatThreshold) {
+ return false;
+ }
+ return this.beginEating(client, foodLevel);
+ }
+
+ /** 开始进食:在背包中找食物并切换主手使用;找不到食物则提示并返回 false */
+ private boolean beginEating(MinecraftClient client, int foodLevel) {
+ if (client.player.isUsingItem()) {
+ this.eating = true;
+ this.status = "\u81ea\u52a8\u8fdb\u98df\u4e2d";
+ return true;
+ }
+ int slot = this.findFoodSlot(client.player.getInventory());
+ if (slot < 0) {
+ this.status = "\u9965\u997f\u4f46\u80cc\u5305\u91cc\u6ca1\u6709\u98df\u7269";
+ return false;
+ }
+ this.baritoneBridge.cancel();
+ client.player.getInventory().selectedSlot = slot;
+ client.interactionManager.interactItem(client.player, Hand.field_5808);
+ this.eating = true;
+ this.status = "\u81ea\u52a8\u8fdb\u98df\u4e2d";
+ this.log("\u81ea\u52a8\u8fdb\u98df\uff0c\u9965\u997f\u503c " + foodLevel);
+ return true;
+ }
+
+ /** 在背包前 36 格中查找第一个可食用的物品槽位(含 FOOD 组件),找不到返回 -1 */
+ private int findFoodSlot(PlayerInventory inventory) {
+ for (int slot = 0; slot < 36; ++slot) {
+ ItemStack stack = inventory.getStack(slot);
+ if (stack.isEmpty() || stack.getComponents().get(DataComponentTypes.field_50075) == null) continue;
+ return slot;
+ }
+ return -1;
+ }
+
+ /** 进食完成后恢复任务:若仍在寻路阶段则重新发起寻路 */
+ private void resumeAfterEating() {
+ this.eating = false;
+ if (this.state == State.PATHING && this.target != null) {
+ this.stageStartedMillis = System.currentTimeMillis();
+ this.baritoneBridge.pathTo(this.target);
+ }
+ }
+
+ /** 更新状态与状态描述文本 */
+ private void setState(State newState, String newStatus) {
+ this.state = newState;
+ this.status = newStatus;
+ }
+
+ /** 追加一条带时间戳的日志,超出上限时移除最旧记录 */
+ private void log(String message) {
+ Deque deque = this.logs;
+ synchronized (deque) {
+ this.logs.addLast(System.currentTimeMillis() + " " + message);
+ while (this.logs.size() > 80) {
+ this.logs.removeFirst();
+ }
+ }
+ }
+
+ /**
+ * 扩地任务状态机枚举:
+ *
+ * - IDLE:空闲(未启动)
+ * - PLANNING:正在生成扩地计划
+ * - PATHING:正在寻路前往目标区块
+ * - WAITING:已到达,等待开垦完成
+ * - SUCCESS:当前区块开垦成功
+ * - FAIL:当前区块开垦失败/超时
+ * - NEXT:准备处理下一个目标
+ * - COMPLETE:全部目标完成
+ * - STOPPED:被手动停止
+ *
+ */
+ @Environment(value=EnvType.CLIENT)
+ public static enum State {
+ IDLE,
+ PLANNING,
+ PATHING,
+ WAITING,
+ SUCCESS,
+ FAIL,
+ NEXT,
+ COMPLETE,
+ STOPPED;
+
+ }
+}
+
diff --git a/src/main/java/autostore/client/gui/AutoStoreGui.java b/src/main/java/autostore/client/gui/AutoStoreGui.java
new file mode 100644
index 0000000..8360a3f
--- /dev/null
+++ b/src/main/java/autostore/client/gui/AutoStoreGui.java
@@ -0,0 +1,660 @@
+/*
+ * AutoStore 模组自动扩地 GUI 界面:提供可视化的领土地图与扩地配置面板。
+ *
+ * 左侧显示实时领土地图(各邦国区块着色 + 标签 + 玩家位置 + 目标高亮),
+ * 右侧配置面板包含邦国选择、目标数量、策略选择、高级设置(超时/重试/区域/Baritone 行为)。
+ * 支持地图缩放(滚轮)与拖动(鼠标拖拽)。
+ */
+package autostore.client.gui;
+
+import autostore.client.AutoStoreModClient;
+import autostore.client.config.ExpansionConfig;
+import autostore.client.controller.ExpansionController;
+import autostore.client.territory.ChunkPos;
+import autostore.client.territory.TerritoryModel;
+import autostore.client.territory.TerritoryState;
+import java.util.List;
+import java.util.function.Consumer;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.text.Text;
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.widget.TextFieldWidget;
+import net.minecraft.client.gui.Element;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.gui.widget.CheckboxWidget;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.text.StringVisitable;
+
+@Environment(value=EnvType.CLIENT)
+/**
+ * 自动扩地 GUI 界面:提供可视化的领土地图与扩地配置面板。
+ *
+ *
左侧显示实时领土地图(各邦国区块着色 + 标签 + 玩家位置 + 目标高亮),
+ * 右侧配置面板包含邦国选择、目标数量、策略选择、高级设置(超时/重试/区域/Baritone 行为)。
+ * 支持地图缩放(滚轮)与拖动(鼠标拖拽)。
+ */
+public final class AutoStoreGui
+extends Screen {
+ private final AutoStoreModClient mod;
+ private final ExpansionConfig config;
+ private final ExpansionController controller;
+ // ---- 输入控件 ----
+ private TextFieldWidget stateField;
+ private TextFieldWidget targetCountField;
+ private TextFieldWidget waitTimeoutField;
+ private TextFieldWidget retriesField;
+ private TextFieldWidget regionXField;
+ private TextFieldWidget regionZField;
+ private TextFieldWidget regionRadiusField;
+ private TextFieldWidget ringsField;
+ private TextFieldWidget refreshIntervalField;
+ private CheckboxWidget sprintBox;
+ private CheckboxWidget jumpBox;
+ private CheckboxWidget avoidBox;
+ private CheckboxWidget breakBox;
+ private CheckboxWidget placeBox;
+ private ButtonWidget refreshButton;
+ // ---- 区域圈选状态 ----
+ private boolean selectingRegion;
+ private boolean draggingRect;
+ private int rectStartX;
+ private int rectStartZ;
+ private int rectEndX;
+ private int rectEndZ;
+ private boolean updatingFields;
+ // ---- 布局尺寸 ----
+ private int mapX = 12;
+ private int mapY = 40;
+ private int mapW = 420;
+ private int mapH = 260;
+ private int panelX = 460;
+ private int advancedY = 208;
+ private int stateFieldY = 84;
+ private int targetFieldY = 116;
+ private int advRowGap = 24;
+ // ---- 地图视图 ----
+ private double scale = 5.0;
+ private int viewCenterX;
+ private int viewCenterZ;
+
+ public AutoStoreGui(AutoStoreModClient mod) {
+ super(Text.literal((String)"AutoStore \u81ea\u52a8\u6269\u5730"));
+ this.mod = mod;
+ this.config = mod.config().copy();
+ this.controller = mod.controller();
+ }
+
+ /** 界面初始化:根据窗口尺寸自适应布局,创建所有控件并设置初始可见性 */
+ protected void init() {
+ // ===== 自适应布局计算 =====
+ // 右侧配置面板:宽度取窗口 1/3(230~260),右对齐并留 8px 边距
+ int panelWidth = Math.max(230, Math.min(260, this.width / 3));
+ this.panelX = Math.max(12, this.width - panelWidth - 8);
+ // 左侧地图:填满面板左侧的剩余空间(窄屏时允许更小,避免与面板重叠)
+ this.mapX = 12;
+ this.mapY = 40;
+ this.mapW = Math.max(120, this.panelX - this.mapX - 8);
+ // 底部信息区高度(日志 + 状态文本),地图填满其上方空间
+ int infoHeight = 78;
+ this.mapH = Math.max(140, this.height - this.mapY - infoHeight - 8);
+
+ // 屏幕较矮时压缩垂直间距,保证右侧面板内容不被截断
+ boolean compact = this.height < 480;
+ int rowGap = compact ? 24 : 28; // 主字段行间距
+ this.advRowGap = compact ? 20 : 24; // 高级面板字段行间距
+ int advCheckGap = compact ? 16 : 18; // 高级面板复选框行间距
+
+ // ===== 顶部控制按钮 =====
+ int topX = this.panelX;
+ ButtonWidget startButton = ButtonWidget.builder(Text.literal((String)"\u5f00\u59cb\u81ea\u52a8\u6269\u5730"), button -> this.start()).dimensions(topX, 34, 154, 30).build();
+ ButtonWidget stopButton = ButtonWidget.builder(Text.literal((String)"\u505c\u6b62"), button -> this.controller.stop()).dimensions(topX + 160, 34, 74, 30).build();
+ this.addDrawableChild(startButton);
+ this.addDrawableChild(stopButton);
+ // 地图手动刷新按钮(位于地图右上角,点击立即重新拉取领土数据)
+ this.refreshButton = ButtonWidget.builder(Text.literal("\u5237\u65b0\u5730\u56fe"), button -> this.mod.dataService().refreshAsync()).dimensions(this.mapX + this.mapW - 64, this.mapY - 16, 60, 16).build();
+ this.addDrawableChild(this.refreshButton);
+
+ // ===== 右侧配置字段 =====
+ int left = this.panelX + 4;
+ int fieldX = this.panelX + 82;
+ int fieldWidth = Math.max(64, panelWidth - 82 - 64 - 6);
+ int y = 84;
+ this.stateFieldY = y;
+ this.stateField = this.textField(this.config.stateName, fieldX, y, fieldWidth, 16);
+ this.addDrawableChild(this.stateField);
+ ButtonWidget autoButton = ButtonWidget.builder(Text.literal((String)"\u81ea\u52a8\u8bc6\u522b"), button -> this.autoState()).dimensions(fieldX + fieldWidth + 6, y, 64, 20).build();
+ this.addDrawableChild(autoButton);
+ this.targetFieldY = y += rowGap;
+ this.targetCountField = this.numberField(this.config.targetCount, fieldX, this.targetFieldY, fieldWidth);
+ this.addDrawableChild(this.targetCountField);
+ this.addDrawableChild(ButtonWidget.builder(Text.literal((String)"\u6301\u7eed"), button -> {
+ this.targetCountField.setText("-1");
+ this.config.targetCount = -1;
+ }).dimensions(fieldX + fieldWidth + 6, this.targetFieldY, 64, 20).build());
+ int strategyY = y += rowGap;
+ this.addDrawableChild(this.strategyButton("\u65b9\u5f62\u5916\u6269", 1, left, strategyY, 90));
+ this.addDrawableChild(this.strategyButton("\u533a\u57df\u6269\u5730", 2, left + 96, strategyY, 90));
+ this.addDrawableChild(this.strategyButton("\u73a9\u5bb6\u5468\u56f4", 3, left, strategyY + 24, 90));
+ this.addDrawableChild(this.strategyButton("\u7ed5\u56fd\u4e00\u5708", 4, left + 96, strategyY + 24, 90));
+
+ // ===== 高级设置面板:直接显示(无展开/收起按钮),紧贴策略按钮下方 =====
+ this.updateRegionButtons();
+ // 策略按钮第二行底部 + 8px 间距,作为高级面板起始位置
+ this.advancedY = strategyY + 24 + 8;
+ int advFieldX1 = this.panelX + 62;
+ int advFieldX2 = this.panelX + 178;
+ int advFieldWidth = Math.max(46, panelWidth - 184);
+ this.waitTimeoutField = this.numberField(this.config.waitTimeoutSeconds, advFieldX1, this.advancedY, advFieldWidth);
+ this.retriesField = this.numberField(this.config.retries, advFieldX2, this.advancedY, advFieldWidth);
+ this.regionXField = this.numberField(this.config.regionCenterX, advFieldX1, this.advancedY + this.advRowGap, advFieldWidth);
+ this.regionZField = this.numberField(this.config.regionCenterZ, advFieldX2, this.advancedY + this.advRowGap, advFieldWidth);
+ this.regionRadiusField = this.numberField(this.config.regionRadius, advFieldX1, this.advancedY + this.advRowGap * 2, advFieldWidth);
+ this.ringsField = this.numberField(this.config.maxRings, advFieldX2, this.advancedY + this.advRowGap * 2, advFieldWidth);
+ this.refreshIntervalField = this.numberField(this.config.refreshIntervalSeconds, advFieldX1, this.advancedY + this.advRowGap * 3, advFieldWidth);
+ this.addDrawableChild(this.waitTimeoutField);
+ this.addDrawableChild(this.retriesField);
+ this.addDrawableChild(this.regionXField);
+ this.addDrawableChild(this.regionZField);
+ this.addDrawableChild(this.regionRadiusField);
+ this.addDrawableChild(this.ringsField);
+ this.addDrawableChild(this.refreshIntervalField);
+ // 用户手动编辑中心/半径时,清除圈选矩形(避免边界与输入框不一致)
+ Consumer clearRectOnEdit = s -> {
+ if (!this.updatingFields) {
+ this.config.regionMinX = 0;
+ this.config.regionMaxX = -1;
+ this.config.regionMinZ = 0;
+ this.config.regionMaxZ = -1;
+ this.updateRegionButtons();
+ }
+ };
+ this.regionXField.setChangedListener(clearRectOnEdit);
+ this.regionZField.setChangedListener(clearRectOnEdit);
+ this.regionRadiusField.setChangedListener(clearRectOnEdit);
+ int checkY1 = this.advancedY + this.advRowGap * 3 + 20 + 8;
+ int checkY2 = checkY1 + advCheckGap;
+ int checkX = left;
+ this.sprintBox = this.checkbox("\u75be\u8dd1", checkX, checkY1, this.config.allowSprint, value -> {
+ this.config.allowSprint = value;
+ });
+ this.jumpBox = this.checkbox("\u8df3\u8dc3", checkX + 88, checkY1, this.config.allowJump, value -> {
+ this.config.allowJump = value;
+ });
+ this.avoidBox = this.checkbox("\u907f\u602a", checkX + 160, checkY1, this.config.avoidMobs, value -> {
+ this.config.avoidMobs = value;
+ });
+ this.breakBox = this.checkbox("\u7834\u574f", checkX, checkY2, this.config.allowBreak, value -> {
+ this.config.allowBreak = value;
+ });
+ this.placeBox = this.checkbox("\u653e\u7f6e", checkX + 88, checkY2, this.config.allowPlace, value -> {
+ this.config.allowPlace = value;
+ });
+ this.addDrawableChild(this.sprintBox);
+ this.addDrawableChild(this.jumpBox);
+ this.addDrawableChild(this.avoidBox);
+ this.addDrawableChild(this.breakBox);
+ this.addDrawableChild(this.placeBox);
+ this.initializeMapView();
+ }
+
+ /** 创建整数输入框(初始值为 value,最多 12 位) */
+ private TextFieldWidget numberField(int value, int x, int y, int width) {
+ TextFieldWidget field = new TextFieldWidget(this.textRenderer, x, y, width, 20, Text.literal((String)""));
+ field.setText(Integer.toString(value));
+ field.setMaxLength(12);
+ return field;
+ }
+
+ /** 创建文本输入框(初始值为 value,限制最大长度) */
+ private TextFieldWidget textField(String value, int x, int y, int width, int maxLength) {
+ TextFieldWidget field = new TextFieldWidget(this.textRenderer, x, y, width, 20, Text.literal((String)""));
+ field.setText(value);
+ field.setMaxLength(maxLength);
+ return field;
+ }
+
+ /** 创建策略选择按钮(点击后设置对应策略编号;区域扩地时自动进入圈选模式,切换其他模式时清除圈选) */
+ private ButtonWidget strategyButton(String label, int strategy, int x, int y, int width) {
+ return ButtonWidget.builder(Text.literal((String)label), button -> {
+ this.config.strategy = strategy;
+ if (strategy == 2) {
+ // 区域扩地:点击后自动进入圈选模式,提示用户在地图上拖拽圈选
+ this.selectingRegion = true;
+ this.draggingRect = false;
+ } else {
+ // 切换其他模式:退出圈选并清除已圈选的矩形区域
+ this.selectingRegion = false;
+ this.draggingRect = false;
+ this.clearRegion();
+ }
+ this.updateRegionButtons();
+ }).dimensions(x, y, width, 20).build();
+ }
+
+ /** 创建复选框(带回调设置器) */
+ private CheckboxWidget checkbox(String label, int x, int y, boolean checked, Consumer setter) {
+ return CheckboxWidget.builder(Text.literal((String)label), this.textRenderer).pos(x, y).checked(checked).callback((box, value) -> setter.accept(value)).build();
+ }
+
+ // ========== 区域圈选方法 ==========
+
+ /** 清除已圈选的矩形区域 */
+ private void clearRegion() {
+ this.config.regionMinX = 0;
+ this.config.regionMaxX = -1;
+ this.config.regionMinZ = 0;
+ this.config.regionMaxZ = -1;
+ this.selectingRegion = false;
+ this.draggingRect = false;
+ this.updateRegionButtons();
+ }
+
+ /** 更新圈选状态:非区域扩地模式下自动退出圈选 */
+ private void updateRegionButtons() {
+ if (this.config.strategy != 2) {
+ this.selectingRegion = false;
+ this.draggingRect = false;
+ }
+ }
+
+ /** 将当前拖拽的矩形提交到配置,并更新高级面板的输入框 */
+ private void finalizeRegion() {
+ if (this.rectStartX == this.rectEndX && this.rectStartZ == this.rectEndZ) {
+ // 单点无效,清空
+ this.clearRegion();
+ return;
+ }
+ this.config.regionMinX = Math.min(this.rectStartX, this.rectEndX);
+ this.config.regionMaxX = Math.max(this.rectStartX, this.rectEndX);
+ this.config.regionMinZ = Math.min(this.rectStartZ, this.rectEndZ);
+ this.config.regionMaxZ = Math.max(this.rectStartZ, this.rectEndZ);
+ this.selectingRegion = false;
+ this.draggingRect = false;
+ this.updateRegionButtons();
+ // 同步更新中心+半径输入框,使高级面板保持一致
+ int centerX = (this.config.regionMinX + this.config.regionMaxX) / 2;
+ int centerZ = (this.config.regionMinZ + this.config.regionMaxZ) / 2;
+ int radius = Math.max(
+ this.config.regionMaxX - this.config.regionMinX,
+ this.config.regionMaxZ - this.config.regionMinZ
+ ) / 2 + 1;
+ this.updatingFields = true;
+ this.regionXField.setText(Integer.toString(centerX));
+ this.regionZField.setText(Integer.toString(centerZ));
+ this.regionRadiusField.setText(Integer.toString(radius));
+ this.updatingFields = false;
+ }
+
+ /** 绘制区域圈选矩形(覆盖层 + 边框);仅区域扩地模式下绘制 */
+ private void drawSelectionRect(DrawContext context) {
+ if (this.config.strategy != 2) {
+ return;
+ }
+ if (this.config.regionMaxX < this.config.regionMinX && !this.draggingRect) {
+ return;
+ }
+ int minX, maxX, minZ, maxZ;
+ if (this.draggingRect) {
+ // 正在拖拽中,使用实时拖拽坐标
+ minX = Math.min(this.rectStartX, this.rectEndX);
+ maxX = Math.max(this.rectStartX, this.rectEndX);
+ minZ = Math.min(this.rectStartZ, this.rectEndZ);
+ maxZ = Math.max(this.rectStartZ, this.rectEndZ);
+ } else {
+ // 使用已保存的矩形边界
+ minX = this.config.regionMinX;
+ maxX = this.config.regionMaxX;
+ minZ = this.config.regionMinZ;
+ maxZ = this.config.regionMaxZ;
+ }
+ // 转换四个角到屏幕坐标
+ int sx1 = this.chunkScreenX(minX);
+ int sz1 = this.chunkScreenZ(minZ);
+ int sx2 = this.chunkScreenX(maxX);
+ int sz2 = this.chunkScreenZ(maxZ);
+ int size = Math.max(1, (int)Math.ceil(this.scale));
+ int right = Math.max(sx1, sx2) + size;
+ int bottom = Math.max(sz1, sz2) + size;
+ int left = Math.min(sx1, sx2);
+ int top = Math.min(sz1, sz2);
+ // 限定在地图范围内
+ left = Math.max(left, this.mapX);
+ top = Math.max(top, this.mapY);
+ right = Math.min(right, this.mapX + this.mapW);
+ bottom = Math.min(bottom, this.mapY + this.mapH);
+ if (left >= right || top >= bottom) return;
+ // 半透明绿色填充
+ context.fill(left, top, right, bottom, 0x4022DD22);
+ // 绿色边框
+ context.drawBorder(left, top, right - left, bottom - top, 0xFF22DD22);
+ }
+
+ /** 初始化地图视图:以玩家所在区块(或己方王城)为地图中心 */
+ private void initializeMapView() {
+ TerritoryModel model = this.mod.dataService().model();
+ ChunkPos center = this.playerChunk();
+ TerritoryState self = model.selfState();
+ if (self != null) {
+ center = self.capital();
+ }
+ this.viewCenterX = center.x();
+ this.viewCenterZ = center.z();
+ this.scale = 5.0;
+ }
+
+ /** 每帧渲染:先渲染父类控件,再绘制地图与信息面板 */
+ public void render(DrawContext context, int mouseX, int mouseY, float delta) {
+ super.render(context, mouseX, mouseY, delta);
+ this.drawMap(context);
+ this.drawInfo(context);
+ }
+
+ /** 绘制地图背景、领土着色、玩家/目标标记与边框标题 */
+ private void drawMap(DrawContext context) {
+ context.fill(this.mapX, this.mapY, this.mapX + this.mapW, this.mapY + this.mapH, -16052716);
+ this.drawTerritories(context);
+ this.drawPlayerAndTarget(context);
+ this.drawSelectionRect(context);
+ // 区域扩地模式下显示圈选引导提示
+ if (this.config.strategy == 2) {
+ if (this.selectingRegion) {
+ context.drawCenteredTextWithShadow(this.textRenderer, "\u8bf7\u5728\u5730\u56fe\u4e0a\u62d6\u62fd\u5708\u9009\u9700\u8981\u5360\u9886\u7684\u533a\u57df\uff0c\u677e\u5f00\u9f20\u6807\u786e\u8ba4", this.mapX + this.mapW / 2, this.mapY + this.mapH - 14, -10163);
+ } else if (this.config.regionMaxX < this.config.regionMinX) {
+ context.drawCenteredTextWithShadow(this.textRenderer, "\u5c1a\u672a\u5708\u9009\u533a\u57df\uff0c\u8bf7\u5728\u5730\u56fe\u4e0a\u62d6\u62fd\u9009\u62e9\u9700\u8981\u5360\u9886\u7684\u533a\u57df", this.mapX + this.mapW / 2, this.mapY + this.mapH - 14, -65536);
+ }
+ }
+ context.drawBorder(this.mapX, this.mapY, this.mapW, this.mapH, -12761518);
+ context.drawTextWithShadow(this.textRenderer, "\u9886\u571f\u5730\u56fe", this.mapX + 2, this.mapY - 12, -1513240);
+ }
+
+ /** 绘制各邦国领土区块(己方高亮、其他半透明),并绘制会话新占领区块 */
+ private void drawTerritories(DrawContext context) {
+ TerritoryModel model = this.mod.dataService().model();
+ for (TerritoryState state : model.states()) {
+ boolean self = state.equals(model.selfState());
+ int color = state.color();
+ int fill = this.withAlpha(color, self ? 155 : 92);
+ for (ChunkPos chunk : state.chunks()) {
+ this.drawChunkFill(context, chunk, fill);
+ }
+ if (!this.isVisible(state.capital())) continue;
+ this.drawStateLabel(context, state, self);
+ }
+ for (ChunkPos chunk : model.sessionClaims()) {
+ this.drawChunkFill(context, chunk, -1938440928);
+ }
+ }
+
+ /** 绘制玩家位置(白色方块)与当前目标区块(橙色描边) */
+ private void drawPlayerAndTarget(DrawContext context) {
+ ChunkPos target;
+ ChunkPos player = this.playerChunk();
+ if (this.isVisible(player)) {
+ int sx = this.chunkScreenX(player.x());
+ int sz = this.chunkScreenZ(player.z());
+ context.fill(sx - 2, sz - 2, sx + 4, sz + 4, -1);
+ context.drawBorder(sx - 2, sz - 2, 7, 7, -16777216);
+ }
+ if ((target = this.controller.target()) != null && this.isVisible(target)) {
+ this.drawChunkOutline(context, target, -10163);
+ }
+ }
+
+ /** 在邦国首都位置绘制邦国名称标签(缩放太小时不显示) */
+ private void drawStateLabel(DrawContext context, TerritoryState state, boolean self) {
+ if (this.scale < 2.5) {
+ return;
+ }
+ int sx = this.chunkScreenX(state.capital().x());
+ int sz = this.chunkScreenZ(state.capital().z());
+ int color = self ? -10163 : -1;
+ context.drawCenteredTextWithShadow(this.textRenderer, state.name(), sx, sz - 10, color);
+ }
+
+ /** 填充绘制一个区块(超出地图范围时跳过) */
+ private void drawChunkFill(DrawContext context, ChunkPos chunk, int color) {
+ int sx = this.chunkScreenX(chunk.x());
+ int sz = this.chunkScreenZ(chunk.z());
+ int size = Math.max(1, (int)Math.ceil(this.scale));
+ int right = sx + size;
+ int bottom = sz + size;
+ if (right < this.mapX || sx > this.mapX + this.mapW || bottom < this.mapY || sz > this.mapY + this.mapH) {
+ return;
+ }
+ context.fill(sx, sz, right, bottom, color);
+ }
+
+ /** 描边绘制一个区块(用于高亮目标,超出地图范围时跳过) */
+ private void drawChunkOutline(DrawContext context, ChunkPos chunk, int color) {
+ int sx = this.chunkScreenX(chunk.x());
+ int sz = this.chunkScreenZ(chunk.z());
+ int size = Math.max(1, (int)Math.ceil(this.scale));
+ int right = sx + size;
+ int bottom = sz + size;
+ if (right < this.mapX || sx > this.mapX + this.mapW || bottom < this.mapY || sz > this.mapY + this.mapH) {
+ return;
+ }
+ context.fill(sx, sz, right, bottom, color);
+ context.drawBorder(sx, sz, size, size, -16777216);
+ }
+
+ /** 区块 X 坐标 → 屏幕 X 坐标(以视图中心为基准,按缩放比例换算) */
+ private int chunkScreenX(int chunkX) {
+ return (int)Math.round(this.mapX + this.mapW / 2.0 + (chunkX - this.viewCenterX) * this.scale);
+ }
+
+ /** 区块 Z 坐标 → 屏幕 Y 坐标 */
+ private int chunkScreenZ(int chunkZ) {
+ return (int)Math.round(this.mapY + this.mapH / 2.0 + (chunkZ - this.viewCenterZ) * this.scale);
+ }
+
+ /** 屏幕 X 坐标 → 区块 X 坐标(圈选矩形用) */
+ private int screenToChunkX(double screenX) {
+ return (int)Math.round((screenX - this.mapX - this.mapW / 2.0) / this.scale + this.viewCenterX);
+ }
+
+ /** 屏幕 Y 坐标 → 区块 Z 坐标(圈选矩形用) */
+ private int screenToChunkZ(double screenY) {
+ return (int)Math.round((screenY - this.mapY - this.mapH / 2.0) / this.scale + this.viewCenterZ);
+ }
+
+ /** 判断区块是否在地图可视范围内(含少量边距) */
+ private boolean isVisible(ChunkPos chunk) {
+ int sx = this.chunkScreenX(chunk.x());
+ int sz = this.chunkScreenZ(chunk.z());
+ int margin = (int)Math.ceil(this.scale) + 2;
+ return sx >= this.mapX - margin && sx <= this.mapX + this.mapW + margin && sz >= this.mapY - margin && sz <= this.mapY + this.mapH + margin;
+ }
+
+ /** 为颜色设置透明度(保留 RGB,替换 alpha 通道) */
+ private int withAlpha(int color, int alpha) {
+ return alpha << 24 | color & 0xFFFFFF;
+ }
+
+ /** 绘制右侧信息面板:字段标签、运行日志与状态/进度信息 */
+ private void drawInfo(DrawContext context) {
+ int shown;
+ context.drawTextWithShadow(this.textRenderer, "\u90a6\u56fd", this.panelX + 4, this.stateFieldY + 4, -5592406);
+ context.drawTextWithShadow(this.textRenderer, "\u76ee\u6807\u6570\u91cf", this.panelX + 4, this.targetFieldY + 4, -5592406);
+ context.drawTextWithShadow(this.textRenderer, "\u7b49\u5f85\u8d85\u65f6", this.panelX + 4, this.advancedY + 4, -5592406);
+ context.drawTextWithShadow(this.textRenderer, "\u91cd\u8bd5\u6b21\u6570", this.panelX + 116, this.advancedY + 4, -5592406);
+ context.drawTextWithShadow(this.textRenderer, "\u533a\u57df\u4e2d\u5fc3X", this.panelX + 4, this.advancedY + this.advRowGap + 4, -5592406);
+ context.drawTextWithShadow(this.textRenderer, "\u533a\u57df\u4e2d\u5fc3Z", this.panelX + 116, this.advancedY + this.advRowGap + 4, -5592406);
+ context.drawTextWithShadow(this.textRenderer, "\u533a\u57df\u534a\u5f84", this.panelX + 4, this.advancedY + this.advRowGap * 2 + 4, -5592406);
+ context.drawTextWithShadow(this.textRenderer, "\u6269\u5708\u4e0a\u9650", this.panelX + 116, this.advancedY + this.advRowGap * 2 + 4, -5592406);
+ context.drawTextWithShadow(this.textRenderer, "\u5237\u65b0\u95f4\u9694", this.panelX + 4, this.advancedY + this.advRowGap * 3 + 4, -5592406);
+ int belowY = this.mapY + this.mapH + 12;
+ int logWidth = Math.max(120, (int)(this.mapW * 0.52));
+ int statusX = this.mapX + logWidth + 20;
+ int statusWidth = this.mapW - logWidth - 20;
+ List logs = this.controller.logs();
+ for (int i = shown = Math.min(4, logs.size()); i > 0; --i) {
+ String trimmed = this.textRenderer.trimToWidth(logs.get(logs.size() - i), logWidth);
+ context.drawTextWithShadow(this.textRenderer, trimmed, this.mapX, belowY + (shown - i) * 12, -6577233);
+ }
+ int target = this.config.targetCount;
+ String targetText = target > 0 ? Integer.toString(target) : "\u6301\u7eed";
+ context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(("\u72b6\u6001\uff1a" + this.controller.status())), statusX, belowY, statusWidth, -1);
+ context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(("\u8fdb\u5ea6\uff1a" + this.controller.completedClaims() + " / " + targetText + "\uff0c\u7b49\u5f85\uff1a" + this.controller.lastProgress() + "%")), statusX, belowY + 14, statusWidth, -1);
+ context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(("\u961f\u5217\uff1a" + this.controller.queuedTargets() + "\uff0c\u6570\u636e\u9519\u8bef\uff1a" + (this.mod.dataService().lastError().isEmpty() ? "\u65e0" : this.mod.dataService().lastError()))), statusX, belowY + 30, statusWidth, -1);
+ context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(this.refreshStatusText()), statusX, belowY + 46, statusWidth, -5592406);
+ context.drawWrappedTextWithShadow(this.textRenderer, Text.literal((String)"\u5f00\u59cb\u524d\u8bf7\u786e\u8ba4\u5df2\u624b\u52a8\u6267\u884c /u claimmode"), statusX, belowY + 62, statusWidth, -13210);
+ }
+
+ /** 生成地图刷新状态文本(显示上次成功刷新距今的时间) */
+ private String refreshStatusText() {
+ long last = this.mod.dataService().lastSuccessfulFetchMillis();
+ if (last <= 0) {
+ return "\u5730\u56fe\u672a\u5237\u65b0";
+ }
+ long seconds = (System.currentTimeMillis() - last) / 1000L;
+ if (seconds < 60) {
+ return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + seconds + "\u79d2\u524d";
+ }
+ return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + (seconds / 60) + "\u5206\u949f\u524d";
+ }
+
+ /** 自动识别玩家当前所在邦国并填入邦国输入框 */
+ private void autoState() {
+ TerritoryState auto = this.mod.dataService().model().findContainingState(this.playerChunk());
+ if (auto == null) {
+ this.config.stateName = "";
+ this.stateField.setText("");
+ } else {
+ this.config.stateName = auto.name();
+ this.stateField.setText(auto.name());
+ }
+ }
+
+ /** 点击"开始"按钮:解析输入到配置,保存到 mod 配置并启动扩地 */
+ private void start() {
+ this.parseToConfig();
+ this.saveToModConfig();
+ // 应用新的刷新间隔
+ this.mod.dataService().reschedule(this.config.refreshIntervalSeconds);
+ this.controller.start(this.mod.config());
+ }
+
+ /** 将各输入框/复选框的值解析到本地配置副本 */
+ private void parseToConfig() {
+ this.config.stateName = this.stateField.getText().trim();
+ this.config.targetCount = this.parseInt(this.targetCountField.getText(), 20);
+ this.config.waitTimeoutSeconds = this.parseInt(this.waitTimeoutField.getText(), 60);
+ this.config.retries = this.parseInt(this.retriesField.getText(), 2);
+ this.config.regionCenterX = this.parseInt(this.regionXField.getText(), this.playerChunk().x());
+ this.config.regionCenterZ = this.parseInt(this.regionZField.getText(), this.playerChunk().z());
+ this.config.regionRadius = this.parseInt(this.regionRadiusField.getText(), 5);
+ this.config.maxRings = this.parseInt(this.ringsField.getText(), 0);
+ this.config.refreshIntervalSeconds = this.parseInt(this.refreshIntervalField.getText(), 30);
+ this.config.allowSprint = this.sprintBox.isChecked();
+ this.config.allowJump = this.jumpBox.isChecked();
+ this.config.avoidMobs = this.avoidBox.isChecked();
+ this.config.allowBreak = this.breakBox.isChecked();
+ this.config.allowPlace = this.placeBox.isChecked();
+ }
+
+ /** 将本地配置副本同步到 mod 全局配置并保存到磁盘 */
+ private void saveToModConfig() {
+ ExpansionConfig target = this.mod.config();
+ target.strategy = this.config.strategy;
+ target.stateName = this.config.stateName;
+ target.targetCount = this.config.targetCount;
+ target.waitTimeoutSeconds = this.config.waitTimeoutSeconds;
+ target.retries = this.config.retries;
+ target.regionCenterX = this.config.regionCenterX;
+ target.regionCenterZ = this.config.regionCenterZ;
+ target.regionRadius = this.config.regionRadius;
+ target.regionMinX = this.config.regionMinX;
+ target.regionMaxX = this.config.regionMaxX;
+ target.regionMinZ = this.config.regionMinZ;
+ target.regionMaxZ = this.config.regionMaxZ;
+ target.maxRings = this.config.maxRings;
+ target.refreshIntervalSeconds = this.config.refreshIntervalSeconds;
+ target.allowSprint = this.config.allowSprint;
+ target.allowJump = this.config.allowJump;
+ target.avoidMobs = this.config.avoidMobs;
+ target.allowBreak = this.config.allowBreak;
+ target.allowPlace = this.config.allowPlace;
+ this.mod.saveConfig();
+ }
+
+ /** 安全解析整数:解析失败时返回默认值 */
+ private int parseInt(String value, int fallback) {
+ try {
+ return Integer.parseInt(value.trim());
+ }
+ catch (NumberFormatException ignored) {
+ return fallback;
+ }
+ }
+
+ /** 获取玩家当前所在区块(未进入世界时返回 (0,0)) */
+ private ChunkPos playerChunk() {
+ if (this.client.player == null) {
+ return new ChunkPos(0, 0);
+ }
+ BlockPos pos = this.client.player.getBlockPos();
+ return ChunkPos.fromBlock(pos.getX(), pos.getZ());
+ }
+
+ /** 关闭界面:先保存配置再关闭 */
+ public void close() {
+ this.parseToConfig();
+ this.saveToModConfig();
+ super.close();
+ }
+
+ /** 鼠标滚轮:在地图区域内缩放视图(范围 0.5x - 40x) */
+ public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
+ if (this.inMap(mouseX, mouseY)) {
+ this.scale = Math.max(0.5, Math.min(40.0, this.scale + verticalAmount * 1.2));
+ return true;
+ }
+ return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount);
+ }
+
+ /** 鼠标按下:圈选模式下在地图内开始绘制矩形 */
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (this.selectingRegion && button == 0 && this.inMap(mouseX, mouseY)) {
+ this.draggingRect = true;
+ this.rectStartX = this.screenToChunkX(mouseX);
+ this.rectStartZ = this.screenToChunkZ(mouseY);
+ this.rectEndX = this.rectStartX;
+ this.rectEndZ = this.rectStartZ;
+ return true;
+ }
+ return super.mouseClicked(mouseX, mouseY, button);
+ }
+
+ /** 鼠标拖拽:圈选模式下更新矩形终点;否则在地图区域内平移视图 */
+ public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
+ if (this.selectingRegion && this.draggingRect) {
+ this.rectEndX = this.screenToChunkX(mouseX);
+ this.rectEndZ = this.screenToChunkZ(mouseY);
+ return true;
+ }
+ if (button == 0 && this.inMap(mouseX, mouseY)) {
+ this.viewCenterX -= (int)Math.round(deltaX / this.scale);
+ this.viewCenterZ -= (int)Math.round(deltaY / this.scale);
+ return true;
+ }
+ return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
+ }
+
+ /** 鼠标释放:圈选模式下提交矩形到配置 */
+ public boolean mouseReleased(double mouseX, double mouseY, int button) {
+ if (this.selectingRegion && this.draggingRect && button == 0) {
+ this.finalizeRegion();
+ return true;
+ }
+ return super.mouseReleased(mouseX, mouseY, button);
+ }
+
+ /** 判断坐标是否在地图区域内 */
+ private boolean inMap(double x, double y) {
+ return x >= this.mapX && x <= (this.mapX + this.mapW) && y >= this.mapY && y <= (this.mapY + this.mapH);
+ }
+}
diff --git a/src/main/java/autostore/client/monitor/SubtitleChatMonitor.java b/src/main/java/autostore/client/monitor/SubtitleChatMonitor.java
new file mode 100644
index 0000000..0dc48d2
--- /dev/null
+++ b/src/main/java/autostore/client/monitor/SubtitleChatMonitor.java
@@ -0,0 +1,163 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.monitor;
+
+import autostore.client.territory.ChunkPos;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+@Environment(value=EnvType.CLIENT)
+/**
+ * 字幕/聊天监听器:解析游戏内消息,识别开垦进度与结果。
+ *
+ * 两条输入通道:
+ *
+ * - {@link #onSubtitle}:来自 HUD 字幕(如"开垦中 50%"),提取百分比进度
+ * - {@link #onChat}:来自聊天栏(如"疆土(x,z) [bx,bz] 已为你的邦国开拓"),提取成功结果
+ *
+ * 消息匹配采用模糊匹配(Levenshtein 距离),以兼容服务器端文案的细微差异。
+ */
+public final class SubtitleChatMonitor {
+ /** 匹配字幕中的百分比:如 "开垦中 50%" */
+ private static final Pattern PERCENT_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*%");
+ /** 匹配开垦成功的聊天消息:形如 "疆土(区块X,区块Z) [方块X,方块Z] 已为你的邦国开拓" */
+ private static final Pattern CLAIM_MESSAGE_PATTERN = Pattern.compile("\u7586\u571f[\uff08(]\\s*(-?\\d+)\\s*[\uff0c,]\\s*(-?\\d+)\\s*[)\uff09]\\s*\\[\\s*(-?\\d+)\\s*[\uff0c,]\\s*(-?\\d+)\\s*]\\s*\u5df2\u4e3a\u4f60\u7684\u90a6\u56fd\u5f00\u62d3");
+ /** 结果回调监听器 */
+ private final Listener listener;
+
+ public SubtitleChatMonitor(Listener listener) {
+ this.listener = listener;
+ }
+
+ /**
+ * 处理字幕消息:若包含"开垦"相关文本,则提取其中的百分比并回调进度。
+ * 使用模糊匹配容忍服务器文案的细微差异。
+ */
+ public void onSubtitle(String text) {
+ if (text == null || text.isEmpty()) {
+ return;
+ }
+ if (!SubtitleChatMonitor.containsFuzzy(text, "\u5f00\u62d3\u4e2d", 2) && !SubtitleChatMonitor.containsFuzzy(text, "\u5f00\u62d3", 1)) {
+ return;
+ }
+ Matcher matcher = PERCENT_PATTERN.matcher(text);
+ if (matcher.find()) {
+ try {
+ int percent = (int)Math.round(Double.parseDouble(matcher.group(1)));
+ this.listener.onProgress(Math.max(0, Math.min(100, percent)));
+ }
+ catch (NumberFormatException numberFormatException) {
+ // 百分比解析失败时忽略
+ }
+ }
+ }
+
+ /**
+ * 处理聊天消息:优先匹配带坐标的开垦成功消息(回调 onSuccess),
+ * 否则模糊匹配"疆土已开拓/已为你的邦国开拓"等通用成功文案(回调 onGenericSuccess)。
+ */
+ public void onChat(String text) {
+ if (text == null || text.isEmpty()) {
+ return;
+ }
+ Matcher matcher = CLAIM_MESSAGE_PATTERN.matcher(text);
+ if (matcher.find()) {
+ try {
+ ChunkPos chunk = new ChunkPos(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)));
+ int blockX = Integer.parseInt(matcher.group(3));
+ int blockZ = Integer.parseInt(matcher.group(4));
+ this.listener.onSuccess(chunk, blockX, blockZ);
+ return;
+ }
+ catch (NumberFormatException numberFormatException) {
+ // 坐标解析失败时忽略,尝试匹配通用消息
+ }
+ }
+ if (SubtitleChatMonitor.containsFuzzy(text, "\u7586\u571f\u5df2\u5f00\u62d3", 3) || SubtitleChatMonitor.containsFuzzy(text, "\u5df2\u4e3a\u4f60\u7684\u90a6\u56fd\u5f00\u62d3", 4)) {
+ this.listener.onGenericSuccess();
+ }
+ }
+
+ /**
+ * 模糊包含匹配:判断 text 中是否存在与 needle 编辑距离 ≤ maxEdits 的子串。
+ * 采用滑动窗口 + 有界 Levenshtein 距离,用于容忍服务器文案的细微差异。
+ */
+ public static boolean containsFuzzy(String text, String needle, int maxEdits) {
+ if (needle == null || needle.isEmpty()) {
+ return true;
+ }
+ if (text == null) {
+ return false;
+ }
+ int n = text.length();
+ int m = needle.length();
+ if (m > n + maxEdits) {
+ return false;
+ }
+ boolean start = false;
+ int end = Math.min(n, m + maxEdits);
+ int best = maxEdits + 1;
+ for (int i = 0; i <= n; ++i) {
+ int from = Math.max(0, i - maxEdits);
+ int to = Math.min(n, i + m + maxEdits);
+ if (to - from < m) continue;
+ int j = from;
+ while (j + m <= to) {
+ int distance = SubtitleChatMonitor.boundedLevenshtein(text, j, needle, maxEdits + 1);
+ if (distance < best) {
+ best = distance;
+ }
+ if (best == 0) {
+ return true;
+ }
+ ++j;
+ }
+ }
+ return best <= maxEdits;
+ }
+
+ /**
+ * 有界 Levenshtein 距离:计算 text 从 textOffset 起的子串与 needle 的编辑距离。
+ * 当某行最小距离超过 limit 时提前返回(剪枝优化),避免全量计算。
+ */
+ private static int boundedLevenshtein(String text, int textOffset, String needle, int limit) {
+ int m = needle.length();
+ int[] previous = new int[m + 1];
+ int[] current = new int[m + 1];
+ for (int j = 0; j <= m; ++j) {
+ previous[j] = j;
+ }
+ int maxI = Math.min(m + limit, text.length() - textOffset);
+ for (int i = 1; i <= maxI; ++i) {
+ current[0] = i;
+ int rowMin = current[0];
+ for (int j = 1; j <= m; ++j) {
+ int cost = text.charAt(textOffset + i - 1) == needle.charAt(j - 1) ? 0 : 1;
+ current[j] = Math.min(Math.min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);
+ rowMin = Math.min(rowMin, current[j]);
+ }
+ if (rowMin > limit) {
+ return limit + 1;
+ }
+ int[] swap = previous;
+ previous = current;
+ current = swap;
+ }
+ return previous[m];
+ }
+
+ /** 扩地结果回调接口:进度更新、开垦成功(带坐标)、开垦成功(通用) */
+ @Environment(value=EnvType.CLIENT)
+ public static interface Listener {
+ public void onProgress(int var1);
+
+ public void onSuccess(ChunkPos var1, int var2, int var3);
+
+ public void onGenericSuccess();
+ }
+}
+
diff --git a/src/main/java/autostore/client/planner/ExpansionPlanner.java b/src/main/java/autostore/client/planner/ExpansionPlanner.java
new file mode 100644
index 0000000..cf885d4
--- /dev/null
+++ b/src/main/java/autostore/client/planner/ExpansionPlanner.java
@@ -0,0 +1,467 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.planner;
+
+import autostore.client.config.ExpansionConfig;
+import autostore.client.territory.ChunkPos;
+import autostore.client.territory.TerritoryModel;
+import autostore.client.territory.TerritoryState;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+@Environment(value=EnvType.CLIENT)
+/**
+ * 扩地规划器:根据当前邦国领土、配置与玩家位置,计算下一步要占领的区块列表。
+ *
+ * 支持三种扩地策略:
+ *
+ * - 策略 1:方形外扩(优先填内部空洞,再向外一圈圈扩展)
+ * - 策略 4:绕国一圈(沿领土边界按角度均匀向外扩张)
+ * - 其他(2/3):直线推进(沿一个方向连续向外延伸)
+ *
+ */
+public final class ExpansionPlanner {
+ /** 世界边界半径(区块数):Minecraft 1.21.4 默认世界边界为 ±2048 区块,此处取 1280 作为安全上限 */
+ private static final int WORLD_BORDER_CHUNK_RADIUS = 1280;
+
+ /**
+ * 根据配置的策略分发到对应的扩地算法。
+ *
+ * @param model 当前邦国领土模型(含所有邦国已占领区块)
+ * @param config 扩地配置(策略、目标数量、区域范围等)
+ * @param playerChunk 玩家当前所在区块
+ * @return 建议占领的区块列表(按优先级排序)
+ */
+ public List generate(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
+ if (config.strategy == 1) {
+ return this.generateHolePriority(model, config, playerChunk);
+ }
+ if (config.strategy == 4) {
+ return this.generatePerimeter(model, config, playerChunk);
+ }
+ // 策略 2 区域扩地:玩家圈选了矩形时,完全按矩形区域扩地
+ if (config.strategy == 2 && config.regionMaxX >= config.regionMinX) {
+ return this.generateRectangle(model, config, playerChunk);
+ }
+ return this.generateLine(model, config, playerChunk);
+ }
+
+ /**
+ * 策略 1:方形外扩(优先填洞)。
+ *
+ * 以王城为中心,先计算当前领土的覆盖半径,然后从内向外逐圈扫描候选区块。
+ * 候选区块必须:未被任何邦国占领、与已有领土相邻(合法)、且在配置的区域内。
+ * 找到候选后优先排"领土包围圈内部"的空洞,再排外部边界,最后按距离与邻居数排序。
+ */
+ private List generateHolePriority(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
+ Set actualOwn = this.ownChunks(model);
+ Set allClaimed = model.allClaimedChunks();
+ if (actualOwn.isEmpty()) {
+ return List.of();
+ }
+ int limit = config.targetCount > 0 ? config.targetCount : 256;
+ // 以己方领土的外接矩形为基准,逐圈向外扩展搜索,保证候选区块与领土相邻且覆盖完整
+ int[] ownBounds = this.bounds(actualOwn);
+ int maxExpansions = config.maxRings > 0 ? config.maxRings + 1 : 16;
+ for (int expansion = 0; expansion < maxExpansions; ++expansion) {
+ HashSet candidates = new HashSet();
+ int minX = ownBounds[0] - expansion;
+ int maxX = ownBounds[1] + expansion;
+ int minZ = ownBounds[2] - expansion;
+ int maxZ = ownBounds[3] + expansion;
+ for (int x = minX; x <= maxX; ++x) {
+ for (int z = minZ; z <= maxZ; ++z) {
+ ChunkPos chunk = new ChunkPos(x, z);
+ if (actualOwn.contains(chunk) || !this.isLegal(chunk, actualOwn, allClaimed) || !this.isInRegion(chunk, config)) continue;
+ candidates.add(chunk);
+ }
+ }
+ if (!candidates.isEmpty()) {
+ Comparator comparator = Comparator.comparingInt(playerChunk::manhattanDistance).thenComparingInt(chunk -> -this.ownNeighbors(chunk, actualOwn));
+ ArrayList inside = new ArrayList();
+ ArrayList outside = new ArrayList();
+ for (ChunkPos chunk : candidates) {
+ if (chunk.x() >= ownBounds[0] && chunk.x() <= ownBounds[1] && chunk.z() >= ownBounds[2] && chunk.z() <= ownBounds[3]) {
+ inside.add(chunk);
+ continue;
+ }
+ outside.add(chunk);
+ }
+ inside.sort(comparator);
+ outside.sort(comparator);
+ ArrayList result = new ArrayList();
+ result.addAll(inside);
+ result.addAll(outside);
+ return result.stream().limit(limit).toList();
+ }
+ }
+ return List.of();
+ }
+
+ /**
+ * 策略 4:绕国一圈。
+ *
+ * 收集所有与己方领土相邻的合法区块,以领土质心(或王城)为中心,
+ * 按方位角(atan2)排序,使扩张点均匀分布在领土四周,形成一圈向外扩散的效果。
+ */
+ private List generatePerimeter(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
+ Set actualOwn = this.ownChunks(model);
+ Set allClaimed = model.allClaimedChunks();
+ HashSet plannedOwn = new HashSet(actualOwn);
+ HashSet candidates = new HashSet();
+ ChunkPos center = this.capital(model, this.centroid(actualOwn, playerChunk));
+ int limit = config.targetCount > 0 ? config.targetCount : 128;
+ // 支持多圈扩张:每圈取当前领土的合法邻居,按方位角均匀排序后入队
+ int rings = config.maxRings > 0 ? config.maxRings : 1;
+ ArrayList result = new ArrayList();
+ for (int ring = 0; ring < rings && result.size() < limit; ++ring) {
+ this.addNeighbors(plannedOwn, candidates, allClaimed, config);
+ if (candidates.isEmpty()) break;
+ Comparator comparator = Comparator.comparingDouble((ChunkPos chunk) -> this.angle(chunk, center)).thenComparingInt(chunk -> chunk.manhattanDistance(playerChunk));
+ List ringChunks = candidates.stream().sorted(comparator).limit((long)(limit - result.size())).toList();
+ result.addAll(ringChunks);
+ plannedOwn.addAll(ringChunks);
+ candidates.clear();
+ }
+ return result;
+ }
+
+ /**
+ * 策略 2/3:直线推进。
+ *
+ * 从玩家当前位置出发,沿一个方向连续向外延伸占领区块(形成一条"线")。
+ * 每步从候选集中挑选评分最优的区块:距离越近、越贴合当前方向、邻居越多、形状越规整越好。
+ * 当领土形状严重失衡(长宽比超过 2:1)时,会切换方向补短边,避免领土过于细长。
+ */
+ private List generateLine(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
+ Set actualOwn = this.ownChunks(model);
+ Set allClaimed = model.allClaimedChunks();
+ HashSet plannedOwn = new HashSet(actualOwn);
+ HashSet candidates = new HashSet();
+ // 以己方领土中离玩家最近的区块作为推进起点,保证扩张从领土边缘开始
+ ChunkPos cursor = this.nearestOwnToPlayer(actualOwn, playerChunk);
+ ChunkPos direction = null;
+ int limit = config.targetCount > 0 ? config.targetCount : 128;
+ this.addNeighbors(plannedOwn, candidates, allClaimed, config);
+ ArrayList result = new ArrayList();
+ // 本次扩张新增区块的边界(用于形状判断,避免被原有领土形状干扰)
+ int expMinX = Integer.MAX_VALUE;
+ int expMaxX = Integer.MIN_VALUE;
+ int expMinZ = Integer.MAX_VALUE;
+ int expMaxZ = Integer.MIN_VALUE;
+ while (result.size() < limit) {
+ ChunkPos nearest;
+ ChunkPos best;
+ if (candidates.isEmpty()) {
+ this.addNeighbors(plannedOwn, candidates, allClaimed, config);
+ if (candidates.isEmpty()) break;
+ }
+ nearest = this.nearestFrontier(candidates, cursor);
+ // 首次迭代确定推进方向;之后保持方向稳定,避免来回摆动
+ if (direction == null) {
+ direction = this.chooseDirection(cursor, nearest, plannedOwn);
+ }
+ best = this.chooseLineCandidate(candidates, plannedOwn, cursor, direction);
+ if (best == null) {
+ best = nearest;
+ }
+ candidates.remove(best);
+ result.add(best);
+ plannedOwn.add(best);
+ cursor = best;
+ expMinX = Math.min(expMinX, best.x());
+ expMaxX = Math.max(expMaxX, best.x());
+ expMinZ = Math.min(expMinZ, best.z());
+ expMaxZ = Math.max(expMaxZ, best.z());
+ this.addNeighbors(best, plannedOwn, candidates, allClaimed, config);
+ // 本次扩张形状失衡时转向补短边,并保持该方向
+ if (this.expansionImbalanced(expMinX, expMaxX, expMinZ, expMaxZ)) {
+ direction = this.shortSideDirection(expMinX, expMaxX, expMinZ, expMaxZ);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * 策略 2 矩形区域扩地:完全按玩家圈选的矩形区域扩地。
+ *
+ * 先检查矩形区域是否与己方领土接壤(矩形内至少有一个未占领区块与领土相邻),
+ * 若未接壤则返回空列表(不扩地)。接壤后,从接壤边界开始 BFS 逐层向内,
+ * 将矩形内所有未被占领的区块全部纳入计划,保证每个区块都与已占领领土连通。
+ */
+ private List generateRectangle(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
+ Set actualOwn = this.ownChunks(model);
+ Set allClaimed = model.allClaimedChunks();
+ if (actualOwn.isEmpty()) {
+ return List.of();
+ }
+ int minX = config.regionMinX;
+ int maxX = config.regionMaxX;
+ int minZ = config.regionMinZ;
+ int maxZ = config.regionMaxZ;
+ int limit = config.targetCount > 0 ? config.targetCount : Integer.MAX_VALUE;
+ // 检查矩形是否与己方领土接壤:矩形内至少有一个未占领区块与领土相邻
+ boolean bordersOwn = false;
+ for (int x = minX; x <= maxX; ++x) {
+ for (int z = minZ; z <= maxZ; ++z) {
+ ChunkPos chunk = new ChunkPos(x, z);
+ if (allClaimed.contains(chunk)) continue;
+ if (this.ownNeighbors(chunk, actualOwn) > 0) {
+ bordersOwn = true;
+ break;
+ }
+ }
+ if (bordersOwn) break;
+ }
+ if (!bordersOwn) {
+ return List.of();
+ }
+ // 初始边界:矩形内与己方领土(原始领土)直接相邻的未占领区块
+ HashSet plannedOwn = new HashSet(actualOwn);
+ ArrayDeque frontier = new ArrayDeque();
+ ArrayList result = new ArrayList();
+ for (int x = minX; x <= maxX; ++x) {
+ for (int z = minZ; z <= maxZ; ++z) {
+ ChunkPos chunk = new ChunkPos(x, z);
+ if (allClaimed.contains(chunk) || plannedOwn.contains(chunk)) continue;
+ if (this.ownNeighbors(chunk, actualOwn) > 0) {
+ plannedOwn.add(chunk);
+ frontier.add(chunk);
+ }
+ }
+ }
+ // BFS 逐层向内填满整个矩形
+ while (!frontier.isEmpty() && result.size() < limit) {
+ ChunkPos chunk = frontier.removeFirst();
+ result.add(chunk);
+ for (ChunkPos neighbor : this.cardinalNeighbors(chunk)) {
+ if (neighbor.x() < minX || neighbor.x() > maxX || neighbor.z() < minZ || neighbor.z() > maxZ) continue;
+ if (allClaimed.contains(neighbor) || plannedOwn.contains(neighbor)) continue;
+ plannedOwn.add(neighbor);
+ frontier.add(neighbor);
+ }
+ }
+ return result;
+ }
+
+ /** 在候选集中找到离当前游标最近的一个区块(曼哈顿距离),作为推进的前沿 */
+ private ChunkPos nearestFrontier(Set candidates, ChunkPos cursor) {
+ return candidates.stream().min(Comparator.comparingInt(cursor::manhattanDistance)).orElse(null);
+ }
+
+ /**
+ * 计算推进方向:
+ * 若前沿与游标重合,则优先朝一个未占领的"正方向邻居"走;
+ * 否则返回从游标指向前沿的符号方向(-1/0/1)。
+ */
+ private ChunkPos chooseDirection(ChunkPos cursor, ChunkPos nearest, Set own) {
+ if (nearest == null) {
+ return new ChunkPos(0, 0);
+ }
+ if (cursor.equals(nearest)) {
+ for (ChunkPos candidate : this.cardinalNeighbors(nearest)) {
+ if (own.contains(candidate)) continue;
+ return new ChunkPos(candidate.x() - nearest.x(), candidate.z() - nearest.z());
+ }
+ }
+ return new ChunkPos(Integer.signum(nearest.x() - cursor.x()), Integer.signum(nearest.z() - cursor.z()));
+ }
+
+ /**
+ * 直线推进的候选评分:分数越低越优先。
+ * 距离近、方向一致(方向点积大)、邻居多、形状影响小 的区块得分更优。
+ */
+ private ChunkPos chooseLineCandidate(Set candidates, Set plannedOwn, ChunkPos cursor, ChunkPos direction) {
+ if (direction == null || direction.x() == 0 && direction.z() == 0) {
+ return null;
+ }
+ ChunkPos best = null;
+ double bestScore = Double.MAX_VALUE;
+ for (ChunkPos candidate : candidates) {
+ int dx = candidate.x() - cursor.x();
+ int dz = candidate.z() - cursor.z();
+ double directionBonus = dx * direction.x() + dz * direction.z();
+ double distance = cursor.manhattanDistance(candidate);
+ double shape = this.shapePenalty(candidate, plannedOwn);
+ double score = distance * 800.0 - directionBonus * 600.0 - this.ownNeighbors(candidate, plannedOwn) * 260.0 + shape * 200.0;
+ if (!(score < bestScore)) continue;
+ bestScore = score;
+ best = candidate;
+ }
+ return best;
+ }
+
+ /** 判断本次扩张的形状是否失衡:长边超过短边的 2 倍时视为"过瘦",需要转向补短边 */
+ private boolean expansionImbalanced(int minX, int maxX, int minZ, int maxZ) {
+ if (minX == Integer.MAX_VALUE) {
+ return false;
+ }
+ int width = maxX - minX + 1;
+ int height = maxZ - minZ + 1;
+ return Math.max(width, height) > Math.min(width, height) * 2;
+ }
+
+ /** 返回本次扩张短边对应的推进方向:宽 < 高 时向 X 方向补,否则向 Z 方向补 */
+ private ChunkPos shortSideDirection(int minX, int maxX, int minZ, int maxZ) {
+ int width = maxX - minX + 1;
+ int height = maxZ - minZ + 1;
+ return width < height ? new ChunkPos(1, 0) : new ChunkPos(0, 1);
+ }
+
+ /** 返回己方领土中离玩家最近的区块;领土为空时返回玩家所在区块 */
+ private ChunkPos nearestOwnToPlayer(Set own, ChunkPos playerChunk) {
+ if (own.isEmpty()) {
+ return playerChunk;
+ }
+ return own.stream().min(Comparator.comparingInt(playerChunk::manhattanDistance)).orElse(playerChunk);
+ }
+
+ /** 计算一组区块的外接矩形边界:[minX, maxX, minZ, maxZ] */
+ private int[] bounds(Set own) {
+ int minX = Integer.MAX_VALUE;
+ int maxX = Integer.MIN_VALUE;
+ int minZ = Integer.MAX_VALUE;
+ int maxZ = Integer.MIN_VALUE;
+ for (ChunkPos chunk : own) {
+ minX = Math.min(minX, chunk.x());
+ maxX = Math.max(maxX, chunk.x());
+ minZ = Math.min(minZ, chunk.z());
+ maxZ = Math.max(maxZ, chunk.z());
+ }
+ return new int[]{minX, maxX, minZ, maxZ};
+ }
+
+ /** 返回指定区块的四个正方向邻居(上下左右) */
+ private List cardinalNeighbors(ChunkPos chunk) {
+ return List.of(new ChunkPos(chunk.x() + 1, chunk.z()), new ChunkPos(chunk.x() - 1, chunk.z()), new ChunkPos(chunk.x(), chunk.z() + 1), new ChunkPos(chunk.x(), chunk.z() - 1));
+ }
+
+ /** 将某区块的合法邻居加入候选集(单个区块版本) */
+ private void addNeighbors(ChunkPos chunk, Set plannedOwn, Set candidates, Set allClaimed, ExpansionConfig config) {
+ for (ChunkPos candidate : this.cardinalNeighbors(chunk)) {
+ if (!this.isLegal(candidate, plannedOwn, allClaimed) || !this.isInRegion(candidate, config)) continue;
+ candidates.add(candidate);
+ }
+ }
+
+ /** 将一组区块的所有合法邻居加入候选集(批量版本) */
+ private void addNeighbors(Set own, Set candidates, Set allClaimed, ExpansionConfig config) {
+ for (ChunkPos base : own) {
+ this.addNeighbors(base, own, candidates, allClaimed, config);
+ }
+ }
+
+ /**
+ * 判断区块是否"合法"(可被占领):
+ * 必须在世界边界内、未被任何邦国占领、未被本次规划占用,且至少与一个己方区块相邻。
+ */
+ private boolean isLegal(ChunkPos chunk, Set plannedOwn, Set allClaimed) {
+ if (chunk.x() < -1280 || chunk.x() >= 1280 || chunk.z() < -1280 || chunk.z() >= 1280) {
+ return false;
+ }
+ if (allClaimed.contains(chunk) || plannedOwn.contains(chunk)) {
+ return false;
+ }
+ return this.ownNeighbors(chunk, plannedOwn) > 0;
+ }
+
+ /** 判断区块是否在配置的扩地区域内(仅策略 2 区域扩地有限制,其他策略恒为 true) */
+ private boolean isInRegion(ChunkPos chunk, ExpansionConfig config) {
+ int dz;
+ if (config.strategy != 2) {
+ return true;
+ }
+ // 优先使用矩形边界(圈选功能)
+ if (config.regionMaxX >= config.regionMinX) {
+ return chunk.x() >= config.regionMinX && chunk.x() <= config.regionMaxX
+ && chunk.z() >= config.regionMinZ && chunk.z() <= config.regionMaxZ;
+ }
+ // 回退到圆形区域(中心+半径)
+ int dx = Math.abs(chunk.x() - config.regionCenterX);
+ return Math.max(dx, dz = Math.abs(chunk.z() - config.regionCenterZ)) <= Math.max(1, config.regionRadius);
+ }
+
+ /** 统计某区块周围(上下左右)属于给定集合的邻居数量 */
+ private int ownNeighbors(ChunkPos chunk, Set own) {
+ int count = 0;
+ for (ChunkPos neighbor : this.cardinalNeighbors(chunk)) {
+ if (!own.contains(neighbor)) continue;
+ ++count;
+ }
+ return count;
+ }
+
+ /**
+ * 形状惩罚分:评估把候选区块纳入后领土的"规整程度"。
+ * 用外接矩形的面积与已有区块数的比值衡量——比值越大说明扩张越"浪费"(形状越散),惩罚越高。
+ */
+ private double shapePenalty(ChunkPos candidate, Set ownChunks) {
+ if (ownChunks.isEmpty()) {
+ return 1.0;
+ }
+ int minX = Integer.MAX_VALUE;
+ int maxX = Integer.MIN_VALUE;
+ int minZ = Integer.MAX_VALUE;
+ int maxZ = Integer.MIN_VALUE;
+ for (ChunkPos chunk : ownChunks) {
+ minX = Math.min(minX, chunk.x());
+ maxX = Math.max(maxX, chunk.x());
+ minZ = Math.min(minZ, chunk.z());
+ maxZ = Math.max(maxZ, chunk.z());
+ }
+ minX = Math.min(minX, candidate.x());
+ maxX = Math.max(maxX, candidate.x());
+ minZ = Math.min(minZ, candidate.z());
+ maxZ = Math.max(maxZ, candidate.z());
+ long width = (maxX - minX) + 1L;
+ long height = (maxZ - minZ) + 1L;
+ long longSide = Math.max(width, height);
+ double shortSide = Math.max(Math.min(width, height), longSide / 2.0);
+ double normalizedArea = longSide * shortSide;
+ return normalizedArea / (ownChunks.size() + 1.0);
+ }
+
+ /** 计算区块相对中心点的方位角(弧度),用于绕国一圈策略的均匀排序 */
+ private double angle(ChunkPos chunk, ChunkPos center) {
+ return Math.atan2(chunk.z() - center.z(), chunk.x() - center.x());
+ }
+
+ /** 计算一组区块的质心(坐标平均值);集合为空时回退到 fallback */
+ private ChunkPos centroid(Set chunks, ChunkPos fallback) {
+ if (chunks.isEmpty()) {
+ return fallback;
+ }
+ long x = 0L;
+ long z = 0L;
+ for (ChunkPos chunk : chunks) {
+ x += chunk.x();
+ z += chunk.z();
+ }
+ return new ChunkPos((int)(x / chunks.size()), (int)(z / chunks.size()));
+ }
+
+ /** 汇总"己方"区块:本次会话新占领的区块 + 己方邦国已拥有的区块 */
+ private Set ownChunks(TerritoryModel model) {
+ HashSet result = new HashSet(model.sessionClaims());
+ TerritoryState self = model.selfState();
+ if (self != null) {
+ result.addAll(self.chunks());
+ }
+ return result;
+ }
+
+ /** 获取己方邦国的王城位置;未识别邦国时回退到 fallback */
+ private ChunkPos capital(TerritoryModel model, ChunkPos fallback) {
+ TerritoryState self = model.selfState();
+ return self == null ? fallback : self.capital();
+ }
+}
+
diff --git a/src/main/java/autostore/client/territory/ChunkPos.java b/src/main/java/autostore/client/territory/ChunkPos.java
new file mode 100644
index 0000000..c74a88d
--- /dev/null
+++ b/src/main/java/autostore/client/territory/ChunkPos.java
@@ -0,0 +1,52 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.territory;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+/**
+ * 区块坐标(record),以“区块”为单位表示二维坐标。
+ *
+ * Minecraft 中 1 个区块为 16×16 方块,本类提供方块坐标与区块坐标的换算,
+ * 以及曼哈顿距离等常用工具方法。
+ *
+ * @param x 区块 X 坐标(= 方块 X / 16)
+ * @param z 区块 Z 坐标(= 方块 Z / 16)
+ */
+@Environment(EnvType.CLIENT)
+public record ChunkPos(int x, int z) {
+
+ /**
+ * 由方块坐标换算为区块坐标(向下取整,兼容负数坐标)。
+ *
+ * @param blockX 方块 X 坐标
+ * @param blockZ 方块 Z 坐标
+ * @return 对应的区块坐标
+ */
+ public static ChunkPos fromBlock(int blockX, int blockZ) {
+ return new ChunkPos(Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16));
+ }
+
+ /** @return 本区块中心的方块 X 坐标 */
+ public int centerBlockX() {
+ return this.x * 16 + 8;
+ }
+
+ /** @return 本区块中心的方块 Z 坐标 */
+ public int centerBlockZ() {
+ return this.z * 16 + 8;
+ }
+
+ /**
+ * 计算与另一区块的曼哈顿距离(|dx| + |dz|)。
+ *
+ * @param other 另一个区块坐标
+ * @return 曼哈顿距离
+ */
+ public int manhattanDistance(ChunkPos other) {
+ return Math.abs(this.x - other.x) + Math.abs(this.z - other.z);
+ }
+}
diff --git a/src/main/java/autostore/client/territory/TerritoryDataService.java b/src/main/java/autostore/client/territory/TerritoryDataService.java
new file mode 100644
index 0000000..294105e
--- /dev/null
+++ b/src/main/java/autostore/client/territory/TerritoryDataService.java
@@ -0,0 +1,399 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.territory;
+
+import autostore.client.territory.ChunkPos;
+import autostore.client.territory.TerritoryModel;
+import autostore.client.territory.TerritoryState;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+@Environment(value=EnvType.CLIENT)
+/**
+ * 领土数据服务:定时从远程地图服务拉取邦国领土数据(markers.json),
+ * 解析为 {@link TerritoryModel} 供规划器与 GUI 使用。
+ *
+ * 数据来源是 AutoStore 服务器自带的动态地图(Dynmap)接口,包含:
+ *
+ * - union_claims:各邦国的领土多边形(区块归属)
+ * - union_capitals:各邦国的王城坐标
+ *
+ * 解析出的领土数据会与本次会话新占领的区块合并,保证扩地过程中地图实时更新。
+ */
+public final class TerritoryDataService {
+ /** 动态地图的 markers.json 接口地址(AutoStore 服务器) */
+ private static final String MARKERS_URL = "http://autostore.simpfun.cn/tiles/minecraft_overworld/markers.json";
+ /** 从 marker 弹窗文本中提取"疆土归属: xxx"的邦国名 */
+ private static final Pattern CLAIM_PATTERN = Pattern.compile("\u7586\u571f\u5f52\u5c5e:\\s*([^<\\r\\n]+)");
+ /** 从 marker 弹窗文本中提取"「xxx」的王城"的王城名 */
+ private static final Pattern CAPITAL_PATTERN = Pattern.compile("\u300c([^\u300d]+)\u300d\u7684\u738b\u57ce");
+ /** HTTP 客户端:10 秒连接超时,自动跟随重定向 */
+ private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10L)).followRedirects(HttpClient.Redirect.NORMAL).build();
+ /** 单线程调度器,用于周期刷新领土数据(守护线程,不阻塞游戏退出) */
+ private volatile ScheduledExecutorService executor;
+ /** 当前解析出的领土模型(volatile 保证多线程可见性) */
+ private volatile TerritoryModel model = TerritoryModel.empty();
+ /** 当前选中的己方邦国名称 */
+ private volatile String selectedStateName = "";
+ /** 最近一次拉取失败的错误信息(空串表示无错误) */
+ private volatile String lastError = "";
+ /** 最近一次成功拉取的时间戳(毫秒) */
+ private volatile long lastSuccessfulFetchMillis;
+ /** 最近一次尝试拉取的时间戳(毫秒) */
+ private volatile long lastAttemptMillis;
+
+ public TerritoryDataService() {
+ this.executor = TerritoryDataService.newExecutor();
+ }
+
+ /** 创建单线程调度器(守护线程,不阻塞游戏退出) */
+ private static ScheduledExecutorService newExecutor() {
+ return Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread thread = new Thread(r, "AutoStore-TerritoryData");
+ thread.setDaemon(true);
+ return thread;
+ });
+ }
+
+ /**
+ * 启动定时刷新:立即刷新一次,之后按固定间隔(至少 10 秒)循环拉取。
+ *
+ * @param refreshIntervalSeconds 刷新间隔(秒)
+ */
+ public void start(long refreshIntervalSeconds) {
+ this.refreshAsync();
+ this.reschedule(refreshIntervalSeconds);
+ }
+
+ /**
+ * 按新间隔重新调度定时刷新(立即生效)。
+ * 会关闭旧的调度线程并创建一个新线程,用于 GUI 中修改刷新间隔后即时应用。
+ *
+ * @param refreshIntervalSeconds 新的刷新间隔(秒),最小 10 秒
+ */
+ public void reschedule(long refreshIntervalSeconds) {
+ ScheduledExecutorService fresh = TerritoryDataService.newExecutor();
+ ScheduledExecutorService old = this.executor;
+ this.executor = fresh;
+ long interval = Math.max(10L, refreshIntervalSeconds);
+ fresh.scheduleWithFixedDelay(this::refreshAsync, interval, interval, TimeUnit.SECONDS);
+ old.shutdownNow();
+ }
+
+ /** 停止定时刷新,关闭调度线程 */
+ public void stop() {
+ this.executor.shutdownNow();
+ }
+
+ /**
+ * 异步刷新领土数据(在调度线程中执行)。
+ * 拉取成功后解析 JSON,并把上一份模型中的"会话新占领区块"合并进新模型,
+ * 避免刷新覆盖掉本次会话的占领记录。
+ */
+ public void refreshAsync() {
+ this.executor.execute(() -> {
+ this.lastAttemptMillis = System.currentTimeMillis();
+ try {
+ HttpRequest request = HttpRequest.newBuilder(URI.create(MARKERS_URL)).timeout(Duration.ofSeconds(15L)).header("User-Agent", "Mozilla/5.0 autostore/1.0").GET().build();
+ HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ if (response.statusCode() < 200 || response.statusCode() >= 300) {
+ this.lastError = "HTTP " + response.statusCode();
+ return;
+ }
+ TerritoryModel parsed = TerritoryDataService.parse(response.body());
+ TerritoryModel previous = this.model;
+ if (previous != null) {
+ for (ChunkPos sessionClaim : previous.sessionClaims()) {
+ parsed.addSessionClaim(sessionClaim);
+ }
+ }
+ parsed.setSelfStateName(this.selectedStateName);
+ this.model = parsed;
+ this.lastSuccessfulFetchMillis = System.currentTimeMillis();
+ this.lastError = "";
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ this.lastError = "\u62c9\u53d6\u88ab\u4e2d\u65ad";
+ }
+ catch (Exception e) {
+ this.lastError = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
+ }
+ });
+ }
+
+ public TerritoryModel model() {
+ return this.model;
+ }
+
+ public void setSelectedStateName(String name) {
+ this.selectedStateName = name == null ? "" : name;
+ TerritoryModel current = this.model;
+ if (current != null) {
+ current.setSelfStateName(this.selectedStateName);
+ }
+ }
+
+ public String selectedStateName() {
+ return this.selectedStateName;
+ }
+
+ public void addSessionClaim(ChunkPos chunk) {
+ TerritoryModel current = this.model;
+ if (current != null) {
+ current.addSessionClaim(chunk);
+ }
+ }
+
+ public String lastError() {
+ return this.lastError;
+ }
+
+ public long lastSuccessfulFetchMillis() {
+ return this.lastSuccessfulFetchMillis;
+ }
+
+ public long lastAttemptMillis() {
+ return this.lastAttemptMillis;
+ }
+
+ /**
+ * 解析 markers.json 文本为领土模型。
+ *
+ * JSON 结构为数组,每层包含 id 与 markers 列表:
+ *
+ * - union_claims 层:每个 marker 是一个领土多边形,弹窗文本含"疆土归属: 邦国名"
+ * - union_capitals 层:每个 marker 是一个王城坐标点,弹窗文本含"「邦国名」的王城"
+ *
+ * 最终按邦国名聚合为 {@link TerritoryState} 列表。
+ */
+ public static TerritoryModel parse(String json) {
+ JsonElement root = JsonParser.parseString((String)json);
+ if (!root.isJsonArray()) {
+ throw new IllegalArgumentException("markers.json must be an array");
+ }
+ HashMap> claims = new HashMap>();
+ HashMap claimColors = new HashMap();
+ HashMap capitals = new HashMap();
+ for (JsonElement layerElement : root.getAsJsonArray()) {
+ JsonObject layer = layerElement.getAsJsonObject();
+ String id = TerritoryDataService.stringOr(layer, "id", "");
+ JsonArray markers = layer.getAsJsonArray("markers");
+ if (markers == null) continue;
+ if ("union_claims".equals(id)) {
+ for (JsonElement markerElement : markers) {
+ TerritoryDataService.parseClaimMarker(markerElement.getAsJsonObject(), claims, claimColors);
+ }
+ continue;
+ }
+ if (!"union_capitals".equals(id)) continue;
+ for (JsonElement markerElement : markers) {
+ TerritoryDataService.parseCapitalMarker(markerElement.getAsJsonObject(), capitals);
+ }
+ }
+ HashMap states = new HashMap();
+ for (Map.Entry entry : claims.entrySet()) {
+ String name = (String)entry.getKey();
+ ChunkPos capital = capitals.getOrDefault(name, TerritoryDataService.firstChunk((Set)entry.getValue()));
+ int color = claimColors.getOrDefault(name, -9061088);
+ states.put(name, new TerritoryState(name, capital, Set.copyOf((Collection)entry.getValue()), color));
+ }
+ return new TerritoryModel(states);
+ }
+
+ /**
+ * 解析一个领土多边形 marker:从弹窗文本提取邦国名,
+ * 将多边形覆盖的区块加入该邦国的区块集合,并记录颜色。
+ */
+ private static void parseClaimMarker(JsonObject marker, Map> claims, Map colors) {
+ String popup = TerritoryDataService.stringOr(marker, "popup", "");
+ Matcher matcher = CLAIM_PATTERN.matcher(popup);
+ if (!matcher.find()) {
+ return;
+ }
+ String name = matcher.group(1).trim();
+ if (name.isEmpty()) {
+ return;
+ }
+ Set chunks = claims.computeIfAbsent(name, ignored -> new HashSet());
+ for (List polygon : TerritoryDataService.extractPolygons(marker.get("points"))) {
+ TerritoryDataService.fillPolygon(chunks, polygon);
+ }
+ colors.putIfAbsent(name, TerritoryDataService.parseColor(TerritoryDataService.stringOr(marker, "color", "#75bd20")));
+ }
+
+ /** 解析一个王城 marker:从弹窗文本提取邦国名,记录王城的方块坐标(换算为区块坐标) */
+ private static void parseCapitalMarker(JsonObject marker, Map capitals) {
+ String popup = TerritoryDataService.stringOr(marker, "popup", "");
+ Matcher matcher = CAPITAL_PATTERN.matcher(popup);
+ if (!matcher.find()) {
+ return;
+ }
+ JsonObject point = marker.getAsJsonObject("point");
+ if (point == null) {
+ return;
+ }
+ int x = TerritoryDataService.intOr(point, "x", 0);
+ int z = TerritoryDataService.intOr(point, "z", 0);
+ capitals.put(matcher.group(1).trim(), ChunkPos.fromBlock(x, z));
+ }
+
+ /**
+ * 递归提取多边形列表:points 可能是单个多边形(点数组),
+ * 也可能是多边形数组(多层嵌套),统一展平为 List>。
+ */
+ private static List> extractPolygons(JsonElement points) {
+ ArrayList> result = new ArrayList>();
+ if (points == null || !points.isJsonArray()) {
+ return result;
+ }
+ JsonArray array = points.getAsJsonArray();
+ if (array.size() == 0) {
+ return result;
+ }
+ if (TerritoryDataService.isPointObject(array.get(0))) {
+ List polygon = TerritoryDataService.parsePointList((JsonElement)array);
+ if (polygon.size() >= 3) {
+ result.add(polygon);
+ }
+ } else {
+ for (JsonElement element : array) {
+ result.addAll(TerritoryDataService.extractPolygons(element));
+ }
+ }
+ return result;
+ }
+
+ /** 判断 JSON 元素是否是一个"点对象"(同时含 x 与 z 字段) */
+ private static boolean isPointObject(JsonElement element) {
+ return element != null && element.isJsonObject() && element.getAsJsonObject().has("x") && element.getAsJsonObject().has("z");
+ }
+
+ /** 解析一个点数组为 int[] 列表(每个点取 x、z 两个坐标) */
+ private static List parsePointList(JsonElement element) {
+ ArrayList points = new ArrayList();
+ if (!element.isJsonArray()) {
+ return points;
+ }
+ for (JsonElement pointElement : element.getAsJsonArray()) {
+ JsonObject point = pointElement.getAsJsonObject();
+ if (point == null) continue;
+ points.add(new int[]{TerritoryDataService.intOr(point, "x", 0), TerritoryDataService.intOr(point, "z", 0)});
+ }
+ return points;
+ }
+
+ /**
+ * 将多边形覆盖的区块填充进集合:
+ * 先算出多边形外接矩形(换算为区块坐标),再逐区块用"点在多边形内"算法判断,
+ * 以区块中心点(cx*16+8, cz*16+8)是否落在多边形内为准。
+ * 面积超过 600 万区块时直接放弃,防止异常数据导致卡死。
+ */
+ private static void fillPolygon(Set output, List polygon) {
+ int area;
+ int minX = Integer.MAX_VALUE;
+ int maxX = Integer.MIN_VALUE;
+ int minZ = Integer.MAX_VALUE;
+ int maxZ = Integer.MIN_VALUE;
+ for (int[] point : polygon) {
+ minX = Math.min(minX, Math.floorDiv(point[0], 16));
+ maxX = Math.max(maxX, Math.floorDiv(point[0], 16));
+ minZ = Math.min(minZ, Math.floorDiv(point[1], 16));
+ maxZ = Math.max(maxZ, Math.floorDiv(point[1], 16));
+ }
+ int n = area = ((maxX - minX) + 1L) * ((maxZ - minZ) + 1L) > Integer.MAX_VALUE ? Integer.MAX_VALUE : (maxX - minX + 1) * (maxZ - minZ + 1);
+ if (area > 6000000) {
+ return;
+ }
+ for (int cx = minX; cx <= maxX; ++cx) {
+ for (int cz = minZ; cz <= maxZ; ++cz) {
+ if (!TerritoryDataService.pointInPolygon(cx * 16 + 8, cz * 16 + 8, polygon)) continue;
+ output.add(new ChunkPos(cx, cz));
+ }
+ }
+ }
+
+ /** 射线法判断点 (x, z) 是否在多边形内(经典奇偶规则) */
+ private static boolean pointInPolygon(int x, int z, List polygon) {
+ boolean inside = false;
+ int i = 0;
+ int j = polygon.size() - 1;
+ while (i < polygon.size()) {
+ boolean intersects;
+ int xi = polygon.get(i)[0];
+ int zi = polygon.get(i)[1];
+ int xj = polygon.get(j)[0];
+ int zj = polygon.get(j)[1];
+ boolean bl = intersects = zi > z != zj > z && x < (xj - xi) * (z - zi) / (zj - zi) + xi;
+ if (intersects) {
+ inside = !inside;
+ }
+ j = i++;
+ }
+ return inside;
+ }
+
+ /** 取集合中的第一个区块作为默认王城(当该邦国没有标记王城时) */
+ private static ChunkPos firstChunk(Set chunks) {
+ return chunks.stream().findFirst().orElse(new ChunkPos(0, 0));
+ }
+
+ /** 安全读取 JSON 字符串字段,缺失或为 null 时返回默认值 */
+ private static String stringOr(JsonObject object, String key, String fallback) {
+ JsonElement element = object.get(key);
+ return element == null || element.isJsonNull() ? fallback : element.getAsString();
+ }
+
+ /** 安全读取 JSON 整数字段,缺失或为 null 时返回默认值 */
+ private static int intOr(JsonObject object, String key, int fallback) {
+ JsonElement element = object.get(key);
+ return element == null || element.isJsonNull() ? fallback : element.getAsInt();
+ }
+
+ /**
+ * 解析十六进制颜色字符串为 ARGB 整数:
+ * 支持 #RRGGBB(自动补全不透明 alpha)与 #AARRGGBB 两种格式;
+ * 解析失败时返回默认的邦国绿(#75bd20)。
+ */
+ private static int parseColor(String hex) {
+ try {
+ String value;
+ String string = value = hex.startsWith("#") ? hex.substring(1) : hex;
+ if (value.length() == 8) {
+ return (int)Long.parseLong(value, 16);
+ }
+ if (value.length() == 6) {
+ return 0xFF000000 | Integer.parseInt(value, 16);
+ }
+ }
+ catch (NumberFormatException numberFormatException) {
+ // 颜色格式非法时忽略,使用默认颜色
+ }
+ return -9061088;
+ }
+}
+
diff --git a/src/main/java/autostore/client/territory/TerritoryModel.java b/src/main/java/autostore/client/territory/TerritoryModel.java
new file mode 100644
index 0000000..081ca77
--- /dev/null
+++ b/src/main/java/autostore/client/territory/TerritoryModel.java
@@ -0,0 +1,112 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.territory;
+
+import autostore.client.territory.ChunkPos;
+import autostore.client.territory.TerritoryState;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+@Environment(value=EnvType.CLIENT)
+/**
+ * 领土模型:聚合所有邦国的领土状态,并提供全局查询能力。
+ *
+ * 维护三份数据:
+ *
+ * - states:邦国名 → 邦国领土状态(含区块集合、王城、颜色)
+ * - allClaimed:所有邦国已占领区块的并集(用于判断区块是否已被占用)
+ * - sessionClaims:本次会话新占领的区块(刷新地图时不会被覆盖)
+ *
+ */
+public final class TerritoryModel {
+ /** 邦国名 → 邦国领土状态 */
+ private final Map states;
+ /** 所有邦国已占领区块的并集 */
+ private final Set allClaimed;
+ /** 本次会话新占领的区块 */
+ private final Set sessionClaims;
+ /** 当前选中的己方邦国名称 */
+ private String selfStateName = "";
+
+ public TerritoryModel(Map states) {
+ this.states = new HashMap(states);
+ this.allClaimed = new HashSet();
+ for (TerritoryState state : states.values()) {
+ this.allClaimed.addAll(state.chunks());
+ }
+ this.sessionClaims = new HashSet();
+ }
+
+ /** 返回一个空的领土模型(无任何邦国数据) */
+ public static TerritoryModel empty() {
+ return new TerritoryModel(Map.of());
+ }
+
+ /** 返回所有邦国领土状态(只读视图) */
+ public Collection states() {
+ return Collections.unmodifiableCollection(this.states.values());
+ }
+
+ /** 按名称查询邦国领土状态,不存在返回 null */
+ public TerritoryState state(String name) {
+ return this.states.get(name);
+ }
+
+ /** 返回所有邦国已占领区块的并集(只读视图) */
+ public Set allClaimedChunks() {
+ return Collections.unmodifiableSet(this.allClaimed);
+ }
+
+ /** 返回本次会话新占领的区块(只读视图) */
+ public Set sessionClaims() {
+ return Collections.unmodifiableSet(this.sessionClaims);
+ }
+
+ /** 返回当前选中的己方邦国名称 */
+ public String selfStateName() {
+ return this.selfStateName;
+ }
+
+ /** 设置当前选中的己方邦国名称(null 视为空串) */
+ public void setSelfStateName(String name) {
+ this.selfStateName = name == null ? "" : name;
+ }
+
+ /** 返回己方邦国的领土状态;未设置或不存在时返回 null */
+ public TerritoryState selfState() {
+ return this.states.get(this.selfStateName);
+ }
+
+ /** 查找包含指定区块的邦国;没有邦国覆盖该区块时返回 null */
+ public TerritoryState findContainingState(ChunkPos chunk) {
+ for (TerritoryState state : this.states.values()) {
+ if (!state.chunks().contains(chunk)) continue;
+ return state;
+ }
+ return null;
+ }
+
+ /**
+ * 记录一次会话内新占领的区块:
+ * 加入会话集合与全局已占领集合,并同步更新己方邦国的区块集合。
+ */
+ public void addSessionClaim(ChunkPos chunk) {
+ this.sessionClaims.add(chunk);
+ this.allClaimed.add(chunk);
+ TerritoryState self = this.states.get(this.selfStateName);
+ if (self != null) {
+ HashSet updated = new HashSet(self.chunks());
+ updated.add(chunk);
+ this.states.put(this.selfStateName, new TerritoryState(self.name(), self.capital(), updated, self.color()));
+ }
+ }
+}
+
diff --git a/src/main/java/autostore/client/territory/TerritoryState.java b/src/main/java/autostore/client/territory/TerritoryState.java
new file mode 100644
index 0000000..01b70fb
--- /dev/null
+++ b/src/main/java/autostore/client/territory/TerritoryState.java
@@ -0,0 +1,21 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.client.territory;
+
+import java.util.Set;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+/**
+ * 一个国家(邦国)的领土状态(record)。
+ *
+ * @param name 国家名称
+ * @param capital 首都所在的区块坐标
+ * @param chunks 该国占有的全部区块坐标集合
+ * @param color 地图上用于绘制该国的 ARGB 颜色值
+ */
+@Environment(EnvType.CLIENT)
+public record TerritoryState(String name, ChunkPos capital, Set chunks, int color) {
+}
diff --git a/src/main/java/autostore/mixin/client/InGameHudMixin.java b/src/main/java/autostore/mixin/client/InGameHudMixin.java
new file mode 100644
index 0000000..00c125a
--- /dev/null
+++ b/src/main/java/autostore/mixin/client/InGameHudMixin.java
@@ -0,0 +1,40 @@
+/*
+ * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
+ * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
+ */
+package autostore.mixin.client;
+
+import autostore.client.AutoStoreModClient;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.text.Text;
+import net.minecraft.client.gui.hud.InGameHud;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Environment(value=EnvType.CLIENT)
+/**
+ * 游戏内 HUD 的 Mixin:拦截字幕(subtitle)与标题(title)的显示,
+ * 转发给扩地监听器,用于识别开垦进度消息(例如"开垦中 50%")。
+ */
+@Mixin(value={InGameHud.class})
+public class InGameHudMixin {
+ /** 拦截字幕显示(HEAD 注入):把字幕文本转发给扩地监听器 */
+ @Inject(method={"method_34002"}, at={@At(value="HEAD")})
+ private void autostore$onSubtitle(Text subtitle, CallbackInfo ci) {
+ if (AutoStoreModClient.instance() != null) {
+ AutoStoreModClient.instance().monitor().onSubtitle(subtitle.getString());
+ }
+ }
+
+ /** 拦截标题显示(HEAD 注入):把标题文本转发给扩地监听器 */
+ @Inject(method={"method_34004"}, at={@At(value="HEAD")})
+ private void autostore$onTitle(Text title, CallbackInfo ci) {
+ if (AutoStoreModClient.instance() != null) {
+ AutoStoreModClient.instance().monitor().onSubtitle(title.getString());
+ }
+ }
+}
+
diff --git a/src/main/resources/assets/autostore/lang/en_us.json b/src/main/resources/assets/autostore/lang/en_us.json
new file mode 100644
index 0000000..eb3a0db
--- /dev/null
+++ b/src/main/resources/assets/autostore/lang/en_us.json
@@ -0,0 +1,4 @@
+{
+ "key.autostore.open": "Open Auto Claim GUI (Ctrl+R)",
+ "category.autostore": "AutoStore Auto Claim"
+}
diff --git a/src/main/resources/assets/autostore/lang/zh_cn.json b/src/main/resources/assets/autostore/lang/zh_cn.json
new file mode 100644
index 0000000..114730e
--- /dev/null
+++ b/src/main/resources/assets/autostore/lang/zh_cn.json
@@ -0,0 +1,4 @@
+{
+ "key.autostore.open": "打开自动扩地 GUI (Ctrl+R)",
+ "category.autostore": "AutoStore 自动扩地"
+}
diff --git a/src/main/resources/autostore.client.mixins.json b/src/main/resources/autostore.client.mixins.json
new file mode 100644
index 0000000..9caf54c
--- /dev/null
+++ b/src/main/resources/autostore.client.mixins.json
@@ -0,0 +1,15 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "autostore.mixin.client",
+ "compatibilityLevel": "JAVA_21",
+ "client": [
+ "InGameHudMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ },
+ "overwrites": {
+ "requireAnnotations": true
+ }
+}
diff --git a/src/main/resources/autostore.mixins.json b/src/main/resources/autostore.mixins.json
new file mode 100644
index 0000000..d5fafd0
--- /dev/null
+++ b/src/main/resources/autostore.mixins.json
@@ -0,0 +1,14 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "autostore.mixin",
+ "compatibilityLevel": "JAVA_21",
+ "mixins": [
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ },
+ "overwrites": {
+ "requireAnnotations": true
+ }
+}
diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..7026154
--- /dev/null
+++ b/src/main/resources/fabric.mod.json
@@ -0,0 +1 @@
+{"schemaVersion":1,"id":"autostore","version":"1.0-SNAPSHOT","name":"AutoStore","description":"","authors":[],"contact":{},"license":"All-Rights-Reserved","environment":"client","entrypoints":{"fabric-datagen":["autostore.client.AutoStoreModDataGenerator"],"client":["autostore.client.AutoStoreModClient"],"main":["autostore.AutoStoreMod"]},"mixins":["autostore.mixins.json",{"config":"autostore.client.mixins.json","environment":"client"}],"depends":{"fabricloader":">=0.16.14","fabric-api":"*","minecraft":"1.21.4"},"jars":[{"file":"META-INF/jars/baritone-api-fabric-1.13.1.jar"}]}
\ No newline at end of file