From d97137373493beda738356468efc2f0e809bf71f Mon Sep 17 00:00:00 2001 From: lyizai <3945107889@qq.com> Date: Mon, 7 Sep 2026 13:32:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20SimpCore=20?= =?UTF-8?q?=E5=BC=80=E6=94=BE=20API=20=E4=BD=9C=E4=B8=BA=E7=AC=AC=E5=9B=9B?= =?UTF-8?q?=E5=8D=A0=E9=A2=86=E5=88=A4=E5=AE=9A=E6=95=B0=E6=8D=AE=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SimpApiClient:对接 https://open.bgjq.top 查询 API(邦国列表/单国领土/ 全服领土/玩家邦国),响应本地缓存 5 分钟,Key 从配置或 SIMP_API_KEY 环境变量读取 - TerritoryDataService:API 数据独立低频轮询(默认 180s,min 60s),合并入领土模型; 会话占领确认改为 Dynmap/API 任一确认即可;API 数据版本参与变更检测, Dynmap 正文未变但 API 更新时也会重新解析合并 - TerritoryModel.mergeApiData:API 独有区块以紫色新增邦国,王城缺失用 API 兜底 - /autostore auto:Dynmap 未覆盖所在区块时回退 API 按玩家名识别邦国 - 新增 /autostore api 命令组(status/key/on/off/refresh/full/union); GUI 自动识别按钮同样支持 API 兜底,状态栏显示 API 拉取时间 - .env.example 提供环境变量模板(真实 Key 留在 .env,不提交) --- .env.example | 9 + .../autostore/client/AutoStoreModClient.java | 5 +- .../autostore/client/api/SimpApiClient.java | 477 ++++++++++++++++++ .../client/command/AutoStoreCommands.java | 176 ++++++- .../autostore/client/config/ConfigStore.java | 8 + .../client/config/ExpansionConfig.java | 12 + .../autostore/client/gui/AutoStoreGui.java | 47 +- .../territory/TerritoryDataService.java | 263 +++++++++- .../client/territory/TerritoryModel.java | 66 ++- 9 files changed, 1031 insertions(+), 32 deletions(-) create mode 100644 .env.example create mode 100644 src/main/java/autostore/client/api/SimpApiClient.java diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6b6ba42 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# 环境变量示例(复制为 .env 并填入真实值;.env 已被 .gitignore 忽略,请勿提交) + +# Gitea API 令牌 +GITEA_TOKEN= +GITEA_API_BASE=https://git.bgjq.top +GITEA_REPO=Lyizai/AutoStore + +# SimpCore 开放 API 密钥(https://open.bgjq.top 申请) +SIMP_API_KEY= diff --git a/src/main/java/autostore/client/AutoStoreModClient.java b/src/main/java/autostore/client/AutoStoreModClient.java index c30c904..db66aaa 100644 --- a/src/main/java/autostore/client/AutoStoreModClient.java +++ b/src/main/java/autostore/client/AutoStoreModClient.java @@ -68,6 +68,8 @@ implements ClientModInitializer { instance = this; this.config = this.configStore.load(); this.dataService.start(Math.max(10L, this.config.refreshIntervalSeconds)); + // 应用 SimpCore 开放 API 配置:启用且配置了 Key 时,自动轮询己方邦国领土数据 + this.dataService.applyApiConfig(this.config.apiEnabled, this.config.apiKey, this.config.apiRefreshIntervalSeconds); // 注册一键唤出 GUI 的快捷键(默认 Ctrl+R,可在"控制"设置中重新绑定) this.guiKey = KeyBindingHelper.registerKeyBinding(new KeyBinding("key.autostore.open", KEY_R, "category.autostore")); ClientTickEvents.END_CLIENT_TICK.register(client -> { @@ -121,9 +123,10 @@ implements ClientModInitializer { return this.config; } - /** 保存当前配置到磁盘 */ + /** 保存当前配置到磁盘,并同步应用 API 配置(Key/开关变更立即生效) */ public void saveConfig() { this.configStore.save(this.config); + this.dataService.applyApiConfig(this.config.apiEnabled, this.config.apiKey, this.config.apiRefreshIntervalSeconds); } /** 返回扩地消息监听器 */ diff --git a/src/main/java/autostore/client/api/SimpApiClient.java b/src/main/java/autostore/client/api/SimpApiClient.java new file mode 100644 index 0000000..c4f852b --- /dev/null +++ b/src/main/java/autostore/client/api/SimpApiClient.java @@ -0,0 +1,477 @@ +package autostore.client.api; + +import autostore.client.territory.ChunkPos; +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.URLEncoder; +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.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +/** + * SimpCore 开放 API 客户端(文档:https://open.bgjq.top/api-docs.html)。 + * + *

邦国崛起服务器的只读查询服务,本模组用它作为占领判定的第四数据源 + * (游戏字幕、游戏消息、Dynmap JSON 之外的补充),并提供: + *

+ * + *

鉴权:{@code Authorization: Bearer };限流:按 Key × 接口每分钟限流, + * 全服数据类接口(territory/buffs/...)限流较严且数据量大(实测全服 territory 单次请求可超 2 分钟), + * 因此本客户端只适合低频调用:自动轮询仅用单邦国轻接口,全服接口仅手动触发。 + * 服务端响应带缓存,数据最多有 1 小时延迟。 + */ +@Environment(EnvType.CLIENT) +public final class SimpApiClient { + + /** API 服务基础地址(见文档"基础信息") */ + private static final String BASE_URL = "https://bgjq.simpfun.cn/api/"; + + /** HTTP 客户端:10 秒连接超时,自动跟随重定向 */ + private final HttpClient httpClient; + + /** API Key(从配置/环境变量读取,运行时可动态设置) */ + private volatile String apiKey = ""; + + /** 内存缓存:接口路径 → 缓存条目;避免高频调用触发限流(服务端按 Key × 接口限流) */ + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + /** 默认缓存 TTL:5 分钟。服务端数据本身最多延迟 1 小时,更短的 TTL 意义不大 */ + private static final long DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000L; + + /** 最近一次请求的 HTTP 状态码(0 表示未请求过),用于命令/GUI 展示连通状态 */ + private volatile int lastHttpStatus; + + /** 最近一次请求失败的错误描述(空串表示无错误) */ + private volatile String lastError = ""; + + public SimpApiClient() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10L)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + /** 设置 API Key(运行时动态更新,立即生效并清空缓存,避免旧 Key 缓存干扰) */ + public void setApiKey(String key) { + String fresh = key == null ? "" : key.trim(); + if (!fresh.equals(this.apiKey)) { + this.cache.clear(); + } + this.apiKey = fresh; + } + + /** 获取当前 API Key(用于状态展示,打码由调用方处理) */ + public String getApiKey() { + return this.apiKey; + } + + /** 是否已配置 API Key */ + public boolean hasApiKey() { + return !this.apiKey.isBlank(); + } + + /** 最近一次 HTTP 状态码(0 表示未请求过) */ + public int lastHttpStatus() { + return this.lastHttpStatus; + } + + /** 最近一次请求失败的错误描述(空串表示无错误) */ + public String lastError() { + return this.lastError; + } + + /** 清空本地响应缓存(Key 变更或手动刷新时用) */ + public void clearCache() { + this.cache.clear(); + } + + // ==================== 查询接口 ==================== + + /** + * 健康检查(免鉴权):{@code GET /health} → {@code {"status":"ok"}}。 + * + * @return true 表示服务可达 + */ + public boolean healthCheck() { + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "health")) + .timeout(Duration.ofSeconds(5L)) + .header("User-Agent", "AutoStore/1.0") + .GET() + .build(); + HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + this.lastHttpStatus = response.statusCode(); + return response.statusCode() == 200 && response.body() != null && response.body().contains("ok"); + } catch (Exception e) { + this.lastError = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + return false; + } + } + + /** + * 所有邦国列表:{@code GET /info/unions}。 + * 返回 邦国名 → 邦国摘要(id、君主、成员数、区块数、王城坐标、特产等)。 + */ + public Map getAllUnions() { + String json = this.getCachedOrFetch("info/unions"); + if (json == null) { + return Collections.emptyMap(); + } + Map result = new LinkedHashMap<>(); + for (JsonElement element : parseArray(json)) { + UnionSummary summary = parseUnionSummary(element); + if (summary != null) { + result.put(summary.name(), summary); + } + } + return result; + } + + /** + * 单个邦国详情:{@code GET /info/unions/{key}},key 可为数字 id 或邦国名。 + */ + public UnionSummary getUnion(String key) { + String json = this.getCachedOrFetch("info/unions/" + encode(key)); + return json == null ? null : parseUnionSummary(parseObject(json)); + } + + /** + * 邦国领土区块明细:{@code GET /info/unions/{key}/territory}。 + * 返回 区块坐标 → 区块详情(忠诚度、最近占领时间)。响应较快,适合自动轮询。 + */ + public Map getUnionTerritory(String key) { + String json = this.getCachedOrFetch("info/unions/" + encode(key) + "/territory"); + Map result = new HashMap<>(); + if (json == null) { + return result; + } + for (JsonElement element : parseArray(json)) { + TerritoryChunk chunk = parseTerritoryChunk(element); + if (chunk != null) { + result.put(new ChunkPos(chunk.x(), chunk.z()), chunk); + } + } + return result; + } + + /** + * 全服领土区块:{@code GET /info/territory},可按 {@code union_id} 过滤。 + * 返回 区块坐标 → 区块详情(含归属邦国 id/名)。注意:全服数据量大(实测单次请求可超 2 分钟), + * 限流严格,仅手动触发使用,禁止放入自动轮询。 + */ + public Map getAllTerritory(Integer unionId) { + String path = "info/territory" + (unionId != null ? "?union_id=" + unionId : ""); + String json = this.getCachedOrFetch(path); + Map result = new HashMap<>(); + if (json == null) { + return result; + } + for (JsonElement element : parseArray(json)) { + TerritoryChunk chunk = parseTerritoryChunk(element); + if (chunk != null) { + result.put(new ChunkPos(chunk.x(), chunk.z()), chunk); + } + } + return result; + } + + /** + * 玩家所属邦国:{@code GET /info/players/{key}/union},key 可为 UUID 或游戏名。 + * 玩家无邦国时服务端返回 404 → 返回 null。 + */ + public UnionSummary getPlayerUnion(String key) { + String json = this.getCachedOrFetch("info/players/" + encode(key) + "/union"); + return json == null ? null : parseUnionSummary(parseObject(json)); + } + + /** + * 玩家详情:{@code GET /info/players/{key}}(UUID 或游戏名)。 + */ + public PlayerDetail getPlayer(String key) { + String json = this.getCachedOrFetch("info/players/" + encode(key)); + JsonObject object = json == null ? null : parseObject(json); + if (object == null) { + return null; + } + return new PlayerDetail( + stringOr(object, "username", ""), + stringOr(object, "uuid", ""), + intOr(object, "union_id", -1), + stringOr(object, "union_name", ""), + stringOr(object, "role", ""), + stringOr(object, "profession", ""), + stringOr(object, "title", "")); + } + + // ==================== JSON 解析(字段名以实测响应为准) ==================== + + /** + * 解析邦国摘要。实测字段: + * {@code id, name, leader_name, member_count, territory_chunk_count, territory_block_area, + * special_output_material, capital_x, capital_y, capital_z, union_title, ...} + */ + private UnionSummary parseUnionSummary(JsonElement element) { + if (element == null || !element.isJsonObject()) { + return null; + } + JsonObject object = element.getAsJsonObject(); + String name = stringOr(object, "name", ""); + if (name.isEmpty()) { + return null; + } + double capitalX = doubleOr(object, "capital_x", Double.NaN); + double capitalZ = doubleOr(object, "capital_z", Double.NaN); + return new UnionSummary( + intOr(object, "id", -1), + name, + stringOr(object, "leader_name", ""), + intOr(object, "member_count", 0), + intOr(object, "territory_chunk_count", 0), + longOr(object, "territory_block_area", 0L), + stringOr(object, "special_output_material", ""), + Double.isNaN(capitalX) ? null : ChunkPos.fromBlock((int) Math.floor(capitalX), (int) Math.floor(capitalZ))); + } + + /** + * 解析领土区块。实测字段: + * {@code id, x, z, union_id, union_name, last_claimed_at, loyalty} + */ + private TerritoryChunk parseTerritoryChunk(JsonElement element) { + if (element == null || !element.isJsonObject()) { + return null; + } + JsonObject object = element.getAsJsonObject(); + JsonElement x = object.get("x"); + JsonElement z = object.get("z"); + if (x == null || z == null || x.isJsonNull() || z.isJsonNull()) { + return null; + } + try { + int unionId = intOr(object, "union_id", -1); + int loyalty = intOr(object, "loyalty", -1); + String claimedAt = object.has("last_claimed_at") && !object.get("last_claimed_at").isJsonNull() + ? object.get("last_claimed_at").getAsString() + : ""; + return new TerritoryChunk(x.getAsInt(), z.getAsInt(), unionId, stringOr(object, "union_name", ""), loyalty, claimedAt); + } catch (Exception e) { + return null; + } + } + + /** 解析 JSON 数组文本;非法输入返回空列表 */ + private static List parseArray(String json) { + try { + JsonElement root = JsonParser.parseString(json); + if (!root.isJsonArray()) { + return List.of(); + } + return root.getAsJsonArray().asList(); + } catch (Exception e) { + return List.of(); + } + } + + /** 解析 JSON 对象文本;非法输入返回 null */ + private static JsonObject parseObject(String json) { + try { + JsonElement root = JsonParser.parseString(json); + return root.isJsonObject() ? root.getAsJsonObject() : null; + } catch (Exception e) { + return null; + } + } + + // ==================== 网络与缓存底层 ==================== + + /** + * 优先取未过期缓存,否则发起带鉴权的 GET 请求。 + * 401(Key 无效)时清空缓存;429(限流)时保留旧缓存兜底。 + * + * @return 响应体;失败返回 null + */ + private String getCachedOrFetch(String path) { + CacheEntry entry = this.cache.get(path); + long now = System.currentTimeMillis(); + if (entry != null && entry.expiresAt > now) { + return entry.body; + } + if (!this.hasApiKey()) { + this.lastError = "未配置 API Key"; + return null; + } + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + path)) + .timeout(Duration.ofSeconds(30L)) + .header("Authorization", "Bearer " + this.apiKey) + .header("User-Agent", "AutoStore/1.0") + .GET() + .build(); + HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + this.lastHttpStatus = response.statusCode(); + if (response.statusCode() == 200) { + String body = response.body(); + this.cache.put(path, new CacheEntry(body, now + DEFAULT_CACHE_TTL_MS)); + this.lastError = ""; + return body; + } + if (response.statusCode() == 401) { + this.lastError = "API Key 无效(401)"; + this.cache.clear(); + } else if (response.statusCode() == 429) { + this.lastError = "请求触发限流(429),稍后重试"; + } else { + this.lastError = "HTTP " + response.statusCode(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + this.lastError = "请求被中断"; + } catch (Exception e) { + this.lastError = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + } + return null; + } + + /** URL 路径参数编码 */ + private static String encode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + /** 安全读取 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); + if (element == null || element.isJsonNull()) { + return fallback; + } + try { + return element.getAsInt(); + } catch (Exception e) { + return fallback; + } + } + + /** 安全读取 JSON 长整数字段,缺失或为 null 时返回默认值 */ + private static long longOr(JsonObject object, String key, long fallback) { + JsonElement element = object.get(key); + if (element == null || element.isJsonNull()) { + return fallback; + } + try { + return element.getAsLong(); + } catch (Exception e) { + return fallback; + } + } + + /** 安全读取 JSON 浮点字段,缺失或为 null 时返回 NaN */ + private static double doubleOr(JsonObject object, String key, double fallback) { + JsonElement element = object.get(key); + if (element == null || element.isJsonNull()) { + return fallback; + } + try { + return element.getAsDouble(); + } catch (Exception e) { + return fallback; + } + } + + /** 缓存条目:响应体 + 过期时间戳 */ + private record CacheEntry(String body, long expiresAt) { + } + + // ==================== 数据模型(record,不可变) ==================== + + /** + * 邦国摘要。 + * + * @param id 邦国数字 id + * @param name 邦国名 + * @param leader 君主玩家名 + * @param memberCount 成员数 + * @param chunkCount 领土区块数(权威口径) + * @param blockArea 领土面积(区块数 × 256,仅供参考) + * @param specialOutput 特产材料 id + * @param capital 王城所在区块(由 capital_x/z 方块坐标换算);缺失为 null + */ + public record UnionSummary( + int id, + String name, + String leader, + int memberCount, + int chunkCount, + long blockArea, + String specialOutput, + ChunkPos capital + ) { + } + + /** + * 领土区块详情。 + * + * @param x 区块 X 坐标 + * @param z 区块 Z 坐标 + * @param unionId 归属邦国 id + * @param unionName 归属邦国名 + * @param loyalty 忠诚度(0-100,未知为 -1) + * @param lastClaimedAt 最近占领时间(ISO 8601,可能为空串) + */ + public record TerritoryChunk( + int x, + int z, + int unionId, + String unionName, + int loyalty, + String lastClaimedAt + ) { + /** 换算为区块坐标对象 */ + public ChunkPos asChunkPos() { + return new ChunkPos(this.x, this.z); + } + } + + /** + * 玩家详情。 + * + * @param name 玩家名 + * @param uuid 玩家 UUID + * @param unionId 所属邦国 id(无邦国为 -1) + * @param unionName 所属邦国名(无邦国为空串) + * @param role 邦国职位(OWNER/ADMIN/MEMBER) + * @param job 职业 + * @param title 称号 + */ + public record PlayerDetail( + String name, + String uuid, + int unionId, + String unionName, + String role, + String job, + String title + ) { + } +} diff --git a/src/main/java/autostore/client/command/AutoStoreCommands.java b/src/main/java/autostore/client/command/AutoStoreCommands.java index 0aa0046..44eb9e0 100644 --- a/src/main/java/autostore/client/command/AutoStoreCommands.java +++ b/src/main/java/autostore/client/command/AutoStoreCommands.java @@ -12,6 +12,7 @@ import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import java.util.Map; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; @@ -75,19 +76,39 @@ public final class AutoStoreCommands { return 1; })); - // 自动识别邦国 + // 自动识别邦国:先用 Dynmap 数据按所在区块判断,失败时回退 SimpCore API 按玩家名查询 root.then(ClientCommandManager.literal("auto").executes(ctx -> { - TerritoryState state = mod.dataService().model().findContainingState(playerChunk(ctx.getSource())); - if (state == null) { - ctx.getSource().sendError(Text.literal("无法识别当前邦国")); - } else { + ChunkPos chunk = playerChunk(ctx.getSource()); + TerritoryState state = mod.dataService().model().findContainingState(chunk); + if (state != null) { mod.config().stateName = state.name(); mod.saveConfig(); ctx.getSource().sendFeedback(Text.literal("已自动识别邦国:" + state.name())); + return 1; } + // Dynmap 未覆盖(所在区块不在任何邦国领土上):尝试 API 按玩家名识别 + net.minecraft.client.MinecraftClient client = net.minecraft.client.MinecraftClient.getInstance(); + if (client.player == null) { + ctx.getSource().sendError(Text.literal("无法识别当前邦国:不在世界中")); + return 0; + } + String playerName = client.player.getGameProfile().getName(); + ctx.getSource().sendFeedback(Text.literal("地图数据未覆盖所在区块,正在通过 API 识别玩家邦国…")); + mod.dataService().detectStateByPlayerAsync(playerName, detected -> client.execute(() -> { + if (detected != null) { + mod.config().stateName = detected; + mod.saveConfig(); + ctx.getSource().sendFeedback(Text.literal("已通过 API 识别邦国:" + detected + "(玩家:" + playerName + ")")); + } else { + ctx.getSource().sendError(Text.literal("无法识别当前邦国(地图与 API 均未匹配,请确认已加入邦国)")); + } + })); return 1; })); + // ===== SimpCore 开放 API 命令组 ===== + registerApiCommands(root, mod); + // 设置邦国 root.then(ClientCommandManager.literal("state").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> { String name = StringArgumentType.getString(ctx, "name"); @@ -129,6 +150,12 @@ public final class AutoStoreCommands { "/autostore state <邦国名> 设置邦国\n" + "/autostore strategy <1|2|3|4> 设置策略\n" + "/autostore rings <层数> 设置圈数上限\n" + + "/autostore api status 查看 API 状态\n" + + "/autostore api key 设置 API Key\n" + + "/autostore api on|off 启用/停用 API 数据源\n" + + "/autostore api refresh 立即拉取 API 领土数据\n" + + "/autostore api full 拉取全服领土(重接口)\n" + + "/autostore api union <邦国名> 查询邦国信息\n" + "/autostore log 导出日志")); return 1; })); @@ -148,8 +175,7 @@ public final class AutoStoreCommands { } /** /autostore start 命令处理:复制当前配置并覆盖目标数量与策略,然后启动扩地 */ - private static int start(FabricClientCommandSource source, AutoStoreModClient mod, int targetCount, int strategy) { - ExpansionConfig config = mod.config().copy(); + 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; @@ -178,4 +204,140 @@ public final class AutoStoreCommands { BlockPos pos = source.getPlayer().getBlockPos(); return ChunkPos.fromBlock(pos.getX(), pos.getZ()); } + + /** + * 注册 /autostore api 子命令组: + *

+ */ + private static void registerApiCommands(LiteralArgumentBuilder root, AutoStoreModClient mod) { + LiteralArgumentBuilder api = ClientCommandManager.literal("api"); + + // api status + api.then(ClientCommandManager.literal("status").executes(ctx -> { + var client = mod.dataService().apiClient(); + StringBuilder sb = new StringBuilder("API 状态:"); + sb.append(mod.dataService().isApiEnabled() ? "已启用" : "未启用"); + sb.append(" | Key:").append(client.hasApiKey() ? "已配置(" + maskKey(client.getApiKey()) + ")" : "未配置"); + if (mod.dataService().lastApiSuccessMillis() > 0) { + long seconds = (System.currentTimeMillis() - mod.dataService().lastApiSuccessMillis()) / 1000L; + sb.append("\n最近成功拉取:").append(seconds >= 60 ? seconds / 60 + " 分钟前" : seconds + " 秒前"); + } else { + sb.append("\n最近成功拉取:从未"); + } + Map> snapshot = mod.dataService().apiTerritorySnapshot(); int total = 0; + for (java.util.Set chunks : snapshot.values()) { + total += chunks.size(); + } + sb.append(" | API 邦国数:").append(snapshot.size()).append(" | API 区块数:").append(total); + if (!client.lastError().isEmpty()) { + sb.append("\n最近错误:").append(client.lastError()); + } + ctx.getSource().sendFeedback(Text.literal(sb.toString())); + return 1; + })); + + // api key + api.then(ClientCommandManager.literal("key").then(ClientCommandManager.argument("key", StringArgumentType.greedyString()).executes(ctx -> { + String key = StringArgumentType.getString(ctx, "key").trim(); + if (key.isEmpty()) { + ctx.getSource().sendError(Text.literal("Key 不能为空")); + return 0; + } + mod.config().apiKey = key; + mod.config().apiEnabled = true; + mod.saveConfig(); + ctx.getSource().sendFeedback(Text.literal("API Key 已设置并启用(" + maskKey(key) + ")")); + return 1; + }))); + + // api on / off + api.then(ClientCommandManager.literal("on").executes(ctx -> { + if (mod.config().apiKey.isBlank()) { + ctx.getSource().sendError(Text.literal("请先用 /autostore api key 设置 API Key")); + return 0; + } + mod.config().apiEnabled = true; + mod.saveConfig(); + ctx.getSource().sendFeedback(Text.literal("API 数据源已启用,轮询间隔 " + mod.config().apiRefreshIntervalSeconds + " 秒")); + return 1; + })); + api.then(ClientCommandManager.literal("off").executes(ctx -> { + mod.config().apiEnabled = false; + mod.saveConfig(); + ctx.getSource().sendFeedback(Text.literal("API 数据源已停用")); + return 1; + })); + + // api refresh + api.then(ClientCommandManager.literal("refresh").executes(ctx -> { + if (!mod.dataService().apiClient().hasApiKey()) { + ctx.getSource().sendError(Text.literal("未配置 API Key,请先用 /autostore api key 设置")); + return 0; + } + ctx.getSource().sendFeedback(Text.literal("正在拉取 API 领土数据(邦国:" + (mod.dataService().selectedStateName().isEmpty() ? "未选择" : mod.dataService().selectedStateName()) + ")")); + mod.dataService().refreshApiAsync(); + return 1; + })); + + // api full(重接口,手动触发) + api.then(ClientCommandManager.literal("full").executes(ctx -> { + ctx.getSource().sendFeedback(Text.literal("正在拉取全服领土数据(数据量大,可能需要较长时间)…")); + mod.dataService().fetchAllTerritoryOnce(error -> net.minecraft.client.MinecraftClient.getInstance().execute(() -> { + if (error == null) { + ctx.getSource().sendFeedback(Text.literal("全服领土数据拉取完成,已合并入本地模型")); + } else { + ctx.getSource().sendError(Text.literal("全服领土拉取失败:" + error)); + } + })); + return 1; + })); + + // api union <邦国名> + api.then(ClientCommandManager.literal("union").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> { + String name = StringArgumentType.getString(ctx, "name").trim(); + var client = mod.dataService().apiClient(); + if (!client.hasApiKey()) { + ctx.getSource().sendError(Text.literal("未配置 API Key,请先用 /autostore api key 设置")); + return 0; + } + ctx.getSource().sendFeedback(Text.literal("正在查询邦国:" + name)); + // 网络请求不能阻塞主线程:在 API 调度线程执行,结果回切主线程反馈 + net.minecraft.client.MinecraftClient.getInstance().execute(() -> + java.util.concurrent.CompletableFuture.runAsync(() -> { + var union = client.getUnion(name); + net.minecraft.client.MinecraftClient.getInstance().execute(() -> { + if (union == null) { + ctx.getSource().sendError(Text.literal("API 未找到该邦国:" + name + + (client.lastError().isEmpty() ? "" : "(" + client.lastError() + ")"))); + } else { + ctx.getSource().sendFeedback(Text.literal( + "邦国:" + union.name() + "(id=" + union.id() + ")" + + " | 君主:" + union.leader() + + " | 成员:" + union.memberCount() + + " | 区块:" + union.chunkCount() + + " | 面积:" + union.blockArea() + + " | 特产:" + union.specialOutput())); + } + }); + })); + return 1; + }))); + + root.then(api); + } + + /** API Key 打码显示:保留前 8 位与后 4 位,中间以 … 代替 */ + private static String maskKey(String key) { + if (key == null || key.length() <= 12) { + return "***"; + } + return key.substring(0, 8) + "…" + key.substring(key.length() - 4); + } } diff --git a/src/main/java/autostore/client/config/ConfigStore.java b/src/main/java/autostore/client/config/ConfigStore.java index 005ddbd..80a8675 100644 --- a/src/main/java/autostore/client/config/ConfigStore.java +++ b/src/main/java/autostore/client/config/ConfigStore.java @@ -53,6 +53,14 @@ public final class ConfigStore { if (loaded.durabilityThreshold < 1 || loaded.durabilityThreshold > 99) { loaded.durabilityThreshold = 15; } + // API Key 兜底:配置文件未填写时从环境变量 SIMP_API_KEY 读取 + //(密钥不进代码仓库,开发环境由 .env 提供,运行环境可设置系统环境变量) + if (loaded.apiKey == null || loaded.apiKey.isBlank()) { + String envKey = System.getenv("SIMP_API_KEY"); + if (envKey != null && !envKey.isBlank()) { + loaded.apiKey = envKey.trim(); + } + } return loaded; } catch (Exception e) { // 解析失败时回退到默认配置 diff --git a/src/main/java/autostore/client/config/ExpansionConfig.java b/src/main/java/autostore/client/config/ExpansionConfig.java index cf75741..055aaf8 100644 --- a/src/main/java/autostore/client/config/ExpansionConfig.java +++ b/src/main/java/autostore/client/config/ExpansionConfig.java @@ -121,6 +121,15 @@ public class ExpansionConfig { /** 地图自动刷新间隔(秒),最小 10 秒 */ public int refreshIntervalSeconds = 30; + /** 是否启用 SimpCore 开放 API 作为占领判定补充来源 */ + public boolean apiEnabled = false; + + /** SimpCore 开放 API Key(在 https://open.bgjq.top 申请;留空时回退读环境变量 SIMP_API_KEY) */ + public String apiKey = ""; + + /** API 领土数据轮询间隔(秒),最小 60;服务端有缓存且限流,不宜过快 */ + public int apiRefreshIntervalSeconds = 180; + /** * 深拷贝当前配置,返回一个独立的副本。 * @@ -161,6 +170,9 @@ public class ExpansionConfig { c.autoBoatMinLength = this.autoBoatMinLength; c.maxRings = this.maxRings; c.refreshIntervalSeconds = this.refreshIntervalSeconds; + c.apiEnabled = this.apiEnabled; + c.apiKey = this.apiKey; + c.apiRefreshIntervalSeconds = this.apiRefreshIntervalSeconds; return c; } } diff --git a/src/main/java/autostore/client/gui/AutoStoreGui.java b/src/main/java/autostore/client/gui/AutoStoreGui.java index a491c31..073c15d 100644 --- a/src/main/java/autostore/client/gui/AutoStoreGui.java +++ b/src/main/java/autostore/client/gui/AutoStoreGui.java @@ -563,26 +563,53 @@ extends Screen { /** 生成地图刷新状态文本(显示上次成功刷新距今的时间) */ private String refreshStatusText() { long last = this.mod.dataService().lastSuccessfulFetchMillis(); + String mapText; if (last <= 0) { - return "\u5730\u56fe\u672a\u5237\u65b0"; + mapText = "\u5730\u56fe\u672a\u5237\u65b0"; + } else { + long seconds = (System.currentTimeMillis() - last) / 1000L; + mapText = seconds < 60 ? "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + seconds + "\u79d2\u524d" : "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + (seconds / 60) + "\u5206\u949f\u524d"; } - long seconds = (System.currentTimeMillis() - last) / 1000L; - if (seconds < 60) { - return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + seconds + "\u79d2\u524d"; + // API 数据源状态:启用时显示最近一次成功拉取距今的时间 + if (this.mod.dataService().isApiEnabled()) { + long apiLast = this.mod.dataService().lastApiSuccessMillis(); + String apiText; + if (apiLast <= 0) { + apiText = "API \u672a\u62c9\u53d6\u5230\u6570\u636e"; + } else { + long s = (System.currentTimeMillis() - apiLast) / 1000L; + apiText = s < 60 ? "API \u5df2\u62c9\u53d6\uff1a" + s + "\u79d2\u524d" : "API \u5df2\u62c9\u53d6\uff1a" + (s / 60) + "\u5206\u949f\u524d"; + } + return mapText + "\uff0c" + apiText; } - return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + (seconds / 60) + "\u5206\u949f\u524d"; + return mapText; } - /** 自动识别玩家当前所在邦国并填入邦国输入框 */ + /** 自动识别玩家当前所在邦国并填入邦国输入框(Dynmap 未覆盖时回退 API 按玩家名识别) */ private void autoState() { TerritoryState auto = this.mod.dataService().model().findContainingState(this.playerChunk()); - if (auto == null) { - this.config.stateName = ""; - this.stateField.setText(""); - } else { + if (auto != null) { this.config.stateName = auto.name(); this.stateField.setText(auto.name()); + return; } + // Dynmap 未覆盖(所在区块不属于任何邦国):尝试通过 API 按玩家名识别所属邦国 + if (this.client == null || this.client.player == null || !this.mod.dataService().apiClient().hasApiKey()) { + this.config.stateName = ""; + this.stateField.setText(""); + return; + } + String playerName = this.client.player.getGameProfile().getName(); + this.mod.dataService().detectStateByPlayerAsync(playerName, detected -> this.client.execute(() -> { + // 回调经 client.execute 切回主线程后再更新输入框 + if (detected != null) { + this.config.stateName = detected; + this.stateField.setText(detected); + } else { + this.config.stateName = ""; + this.stateField.setText(""); + } + })); } /** 点击"开始"按钮:解析输入到配置,保存到 mod 配置并启动扩地 */ diff --git a/src/main/java/autostore/client/territory/TerritoryDataService.java b/src/main/java/autostore/client/territory/TerritoryDataService.java index e1dac21..0f06cdd 100644 --- a/src/main/java/autostore/client/territory/TerritoryDataService.java +++ b/src/main/java/autostore/client/territory/TerritoryDataService.java @@ -4,6 +4,7 @@ */ package autostore.client.territory; +import autostore.client.api.SimpApiClient; import autostore.client.territory.ChunkPos; import autostore.client.territory.TerritoryModel; import autostore.client.territory.TerritoryState; @@ -28,6 +29,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import net.fabricmc.api.EnvType; @@ -38,12 +40,19 @@ import net.fabricmc.api.Environment; * 领土数据服务:定时从远程地图服务拉取邦国领土数据(markers.json), * 解析为 {@link TerritoryModel} 供规划器与 GUI 使用。 * - *

数据来源是 AutoStore 服务器自带的动态地图(Dynmap)接口,包含: - *

    - *
  • union_claims:各邦国的领土多边形(区块归属)
  • - *
  • union_capitals:各邦国的王城坐标
  • - *
- * 解析出的领土数据会与本次会话新占领的区块合并,保证扩地过程中地图实时更新。 + *

占领判定有四个数据源,任意一个显示区块被占领即算占领: + *

    + *
  1. 游戏内字幕("开垦中 XX%")
  2. + *
  3. 游戏消息("疆土(x,z) 已为你的邦国开拓")
  4. + *
  5. Dynmap 地图数据(本类的 markers.json 主数据源)
  6. + *
  7. SimpCore 开放 API({@link autostore.client.api.SimpApiClient},补充/交叉验证, + * 含己方邦国区块明细与忠诚度数据)
  8. + *
+ * + *

Dynmap 数据包含 union_claims(各邦国领土多边形)与 union_capitals(王城坐标); + * API 数据独立低频轮询(服务端有缓存且限流),成功后并入模型——API 独有的区块以 + * API 颜色补入,Dynmap 缺失的王城用 API 王城兜底。解析出的领土数据会与本次会话 + * 新占领的区块合并,保证扩地过程中地图实时更新。

*/ public final class TerritoryDataService { /** 动态地图的 markers.json 接口地址(AutoStore 服务器) */ @@ -88,9 +97,35 @@ public final class TerritoryDataService { /** 上次成功响应的正文哈希与长度,用于识别"网络缓存返回了相同旧数据"并跳过重复解析 */ private volatile int lastBodyHash; private volatile int lastBodyLength = -1; + /** + * API 数据版本号:apiTerritory/apiCapitals 每次成功更新后自增。 + * 参与 refreshAsync 的"内容未变化跳过解析"判定——Dynmap 正文未变但 API 数据更新时, + * 也必须重新解析合并,否则 API 新确认的区块/占领要等到 Dynmap 下次变化才生效。 + */ + private volatile long apiDataVersion; + /** 上次解析合并模型时使用的 API 数据版本(与 apiDataVersion 配合做变更检测) */ + private volatile long lastMergedApiVersion = -1; + + // ==================== SimpCore 开放 API 集成 ==================== + + /** SimpCore 开放 API 客户端(第四占领判定数据源:字幕/聊天/Dynmap 之外) */ + private final SimpApiClient apiClient = new SimpApiClient(); + /** API 轮询调度器(独立于 Dynmap 刷新线程,避免慢请求拖垮地图刷新) */ + private volatile ScheduledExecutorService apiExecutor; + /** API 轮询间隔(秒);0 表示未启用 */ + private volatile long apiPollIntervalSeconds; + /** API 最近一次成功拉取的 邦国名 → 区块集合(用于展示/调试) */ + private final ConcurrentHashMap> apiTerritory = new ConcurrentHashMap<>(); + /** API 最近一次成功拉取的 邦国名 → 王城区块 */ + private final ConcurrentHashMap apiCapitals = new ConcurrentHashMap<>(); + /** API 最近一次成功拉取的时间戳(毫秒),0 表示从未成功 */ + private volatile long lastApiSuccessMillis; + /** API 邦国在地图上的显示颜色(紫 #FF43DE),与 Dynmap 数据区分来源 */ + private static final int API_STATE_COLOR = 0xFFFF43DE; public TerritoryDataService() { this.executor = TerritoryDataService.newExecutor(); + this.apiExecutor = TerritoryDataService.newApiExecutor(); } /** 创建单线程调度器(守护线程,不阻塞游戏退出) */ @@ -102,6 +137,193 @@ public final class TerritoryDataService { }); } + /** 创建 API 轮询单线程调度器(守护线程) */ + private static ScheduledExecutorService newApiExecutor() { + return Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "AutoStore-TerritoryApi"); + thread.setDaemon(true); + return thread; + }); + } + + /** + * 配置 SimpCore 开放 API(启动时或配置变更时调用)。 + * + * @param enabled 是否启用 API 数据源 + * @param apiKey API Key(空串则视为未启用) + * @param pollIntervalSeconds 己方邦国领土轮询间隔(秒),最小 60 + */ + public void applyApiConfig(boolean enabled, String apiKey, long pollIntervalSeconds) { + this.apiClient.setApiKey(apiKey == null ? "" : apiKey.trim()); + boolean effective = enabled && this.apiClient.hasApiKey(); + long interval = effective ? Math.max(60L, pollIntervalSeconds) : 0L; + boolean needReschedule = interval != this.apiPollIntervalSeconds; + this.apiPollIntervalSeconds = interval; + if (needReschedule) { + this.rescheduleApi(); + } + } + + /** 按 apiPollIntervalSeconds 重新调度 API 轮询(间隔 ≤0 表示停用轮询) */ + private void rescheduleApi() { + ScheduledExecutorService fresh = TerritoryDataService.newApiExecutor(); + ScheduledExecutorService old = this.apiExecutor; + this.apiExecutor = fresh; + long interval = this.apiPollIntervalSeconds; + if (interval > 0) { + fresh.scheduleWithFixedDelay(this::refreshApiAsync, 15L, interval, TimeUnit.SECONDS); + } + old.shutdownNow(); + } + + /** + * 异步拉取 API 数据(在 API 调度线程执行): + * 拉取所有邦国列表(王城兜底)+ 当前选中邦国的领土区块明细。 + * 拉取成功后触发一次 Dynmap 刷新,让新数据在下一次模型重建时合并进去。 + */ + public void refreshApiAsync() { + if (this.apiPollIntervalSeconds <= 0 || !this.apiClient.hasApiKey()) { + return; + } + try { + Map unions = this.apiClient.getAllUnions(); + if (!unions.isEmpty()) { + boolean capitalChanged = false; + for (SimpApiClient.UnionSummary union : unions.values()) { + if (union.capital() != null) { + ChunkPos old = this.apiCapitals.put(union.name(), union.capital()); + if (old == null || !old.equals(union.capital())) { + capitalChanged = true; + } + } + } + if (capitalChanged) { + this.apiDataVersion++; + } + } + String stateName = this.selectedStateName == null ? "" : this.selectedStateName; + if (!stateName.isEmpty()) { + Map territory = this.apiClient.getUnionTerritory(stateName); + if (!territory.isEmpty()) { + HashSet chunks = new HashSet<>(); + for (SimpApiClient.TerritoryChunk chunk : territory.values()) { + chunks.add(TerritoryDataService.internChunk(chunk.x(), chunk.z())); + } + this.apiTerritory.put(stateName, chunks); + this.apiDataVersion++; + } else if (unions.isEmpty()) { + // 两个接口都失败(网络/限流/Key 问题):保留旧数据,只记录时间不做合并变更 + return; + } + } + this.lastApiSuccessMillis = System.currentTimeMillis(); + // API 数据更新后立即触发一次 Dynmap 刷新:让新数据尽快参与模型合并与会话占领确认。 + // (若 Dynmap 内容未变化会直接跳过,模型保持不变,不会产生额外开销) + this.refreshAsync(); + } catch (Exception e) { + // API 拉取失败不影响 Dynmap 主数据源,静默保留旧数据 + } + } + + /** + * 手动拉取全服领土数据(仅命令触发,重接口限流严格): + * 用于对比 API 与本地模型的差异,并把缺失区块合并进模型(辅助修正)。 + * + * @param callback 在后台线程回调,参数为错误信息(成功为 null) + */ + public void fetchAllTerritoryOnce(Consumer callback) { + if (!this.apiClient.hasApiKey()) { + callback.accept("未配置 API Key"); + return; + } + this.apiExecutor.execute(() -> { + try { + Map all = this.apiClient.getAllTerritory(null); + if (all.isEmpty()) { + callback.accept("拉取失败:" + this.apiClient.lastError()); + return; + } + // 按邦国名聚合(API 直接返回 union_name,无需二次映射) + HashMap> byUnion = new HashMap<>(); + for (SimpApiClient.TerritoryChunk chunk : all.values()) { + if (chunk.unionName() == null || chunk.unionName().isEmpty()) { + continue; + } + byUnion.computeIfAbsent(chunk.unionName(), k -> new HashSet<>()) + .add(TerritoryDataService.internChunk(chunk.x(), chunk.z())); + } + this.apiTerritory.putAll(byUnion); + this.apiDataVersion++; + this.lastApiSuccessMillis = System.currentTimeMillis(); + this.refreshAsync(); + callback.accept(null); + } catch (Exception e) { + callback.accept(e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); + } + }); + } + + /** + * 通过 API 异步识别玩家所属邦国(用于 /autostore auto 的兜底识别)。 + * + *

回调在 API 线程执行,返回识别到的邦国名(本地模型中必须存在,否则返回 null—— + * 避免识别出一个规划器无法使用的邦国)。调用方需自行切换到主线程再操作 GUI/消息。

+ * + * @param playerName 玩家游戏名 + * @param callback 回调:识别结果(null 表示识别失败) + */ + public void detectStateByPlayerAsync(String playerName, Consumer callback) { + if (playerName == null || playerName.isBlank() || !this.apiClient.hasApiKey()) { + callback.accept(null); + return; + } + this.apiExecutor.execute(() -> { + try { + SimpApiClient.UnionSummary union = this.apiClient.getPlayerUnion(playerName.trim()); + String name = union == null ? null : union.name(); + if (name != null && this.model().state(name) == null) { + // API 有该邦国但本地模型没有(Dynmap 尚未同步):先拉一次该邦国领土,合并后再试 + Map territory = this.apiClient.getUnionTerritory(name); + if (!territory.isEmpty()) { + HashSet chunks = new HashSet<>(); + for (SimpApiClient.TerritoryChunk chunk : territory.values()) { + chunks.add(TerritoryDataService.internChunk(chunk.x(), chunk.z())); + } + this.apiTerritory.put(name, chunks); + this.apiDataVersion++; + this.refreshAsync(); + } + if (this.model().state(name) == null) { + name = null; + } + } + callback.accept(name); + } catch (Exception e) { + callback.accept(null); + } + }); + } + + /** SimpCore API 客户端(供命令/GUI 查询状态与手动刷新) */ + public SimpApiClient apiClient() { + return this.apiClient; + } + + /** API 数据源是否已启用(已配置 Key 且轮询间隔 > 0) */ + public boolean isApiEnabled() { + return this.apiPollIntervalSeconds > 0 && this.apiClient.hasApiKey(); + } + + /** API 最近一次成功拉取的时间戳(毫秒),0 表示从未成功 */ + public long lastApiSuccessMillis() { + return this.lastApiSuccessMillis; + } + + /** API 最近一次拉取到的 邦国名 → 区块集合(只读快照) */ + public Map> apiTerritorySnapshot() { + return Map.copyOf(this.apiTerritory); + } + /** * 启动定时刷新:立即刷新一次,之后按固定间隔(至少 10 秒)循环拉取。 * @@ -125,11 +347,14 @@ public final class TerritoryDataService { long interval = Math.max(10L, refreshIntervalSeconds); fresh.scheduleWithFixedDelay(this::refreshAsync, interval, interval, TimeUnit.SECONDS); old.shutdownNow(); + // 刷新间隔变化不影响 API 轮询,但保持调度状态一致 + this.rescheduleApi(); } /** 停止定时刷新,关闭调度线程 */ public void stop() { this.executor.shutdownNow(); + this.apiExecutor.shutdownNow(); } /** @@ -138,6 +363,7 @@ public final class TerritoryDataService { * 只保留"已被地图确认"或"刚占领未超宽限期"的占领记录,避免刷新时把假占领永久留在模型里。 */ public void refreshAsync() { + long apiVersionAtStart = this.apiDataVersion; this.executor.execute(() -> { this.lastAttemptMillis = System.currentTimeMillis(); try { @@ -154,34 +380,47 @@ public final class TerritoryDataService { return; } String body = response.body(); - // 内容未变化(服务器确实无更新,或缓存仍命中相同旧文):跳过重复解析, + // 内容未变化(服务器确实无更新,或缓存仍命中相同旧文)且 API 数据也未更新:跳过重复解析, // 避免"每次刷新都重建 8 万区块模型"的 CPU/GC 开销;模型与飞地缓存保持原样。 - if (body.length() == this.lastBodyLength && body.hashCode() == this.lastBodyHash) { + // 注意:API 数据版本必须参与比较——Dynmap 正文未变但 API 拉到新数据时仍需重新解析合并。 + if (body.length() == this.lastBodyLength && body.hashCode() == this.lastBodyHash + && apiVersionAtStart == this.lastMergedApiVersion) { this.lastSuccessfulFetchMillis = System.currentTimeMillis(); this.lastError = ""; return; } this.lastBodyHash = body.hashCode(); this.lastBodyLength = body.length(); + this.lastMergedApiVersion = apiVersionAtStart; TerritoryModel parsed = TerritoryDataService.parse(response.body()); + // ===== API 数据源合并(第四判定来源) ===== + // SimpCore API 的邦国领土并入模型:任意一个数据源(字幕/聊天/Dynmap/API) + // 显示区块被占领即算占领。API 独有的区块会以 API 颜色新增邦国或并入同名邦国, + // 王城缺失时用 API 的王城坐标兜底。 + Map> apiSnapshot = Map.copyOf(this.apiTerritory); + if (this.apiPollIntervalSeconds > 0 && !apiSnapshot.isEmpty()) { + parsed.mergeApiData(apiSnapshot, this.apiCapitals, API_STATE_COLOR); + } TerritoryModel previous = this.model; if (previous != null) { long now = System.currentTimeMillis(); - // 本次拉取到的己方领土区块(地图数据即最终依据,无需缓存旧数据) + // 本次拉取到的己方领土区块(Dynmap 与 API 任一确认即算已确认) Set freshOwn = new HashSet<>(); TerritoryState freshSelf = parsed.state(this.selectedStateName); if (freshSelf != null) { freshOwn.addAll(freshSelf.chunks()); } + Set apiOwn = apiSnapshot.getOrDefault(this.selectedStateName, Set.of()); for (ChunkPos sessionClaim : previous.sessionClaims()) { Long claimTime = this.sessionClaimTimes.get(sessionClaim); - boolean confirmed = freshOwn.contains(sessionClaim); + // Dynmap 已确认,或 API 已确认(任一来源确认即可),或尚在宽限期 + boolean confirmed = freshOwn.contains(sessionClaim) || apiOwn.contains(sessionClaim); boolean withinGrace = claimTime != null && now - claimTime < CLAIM_CONFIRM_GRACE_MS; if (confirmed || withinGrace) { - // 地图已确认,或尚在宽限期(Dynmap 可能还没同步):保留会话占领标记 + // 地图/API 已确认,或尚在宽限期(服务端可能还没同步):保留会话占领标记 parsed.addSessionClaim(sessionClaim); } else if (claimTime != null) { - // 地图始终未确认且超时:判定为假占领并移除,使空洞区块重新进入规划 + // 所有数据源始终未确认且超时:判定为假占领并移除,使空洞区块重新进入规划 this.sessionClaimTimes.remove(sessionClaim); } // claimTime 为 null(从未在本服务记录)也视为未确认:不保留 diff --git a/src/main/java/autostore/client/territory/TerritoryModel.java b/src/main/java/autostore/client/territory/TerritoryModel.java index 523caf4..15306c8 100644 --- a/src/main/java/autostore/client/territory/TerritoryModel.java +++ b/src/main/java/autostore/client/territory/TerritoryModel.java @@ -27,8 +27,8 @@ import net.fabricmc.api.Environment; * */ public final class TerritoryModel { - /** 邦国名 → 邦国领土状态 */ - private final Map states; + /** 邦国名 → 邦国领土状态。允许整体替换(mergeApiData 在模型发布前重建 states) */ + private Map states; /** 所有邦国已占领区块的并集 */ private final Set allClaimed; /** 本次会话新占领的区块 */ @@ -211,5 +211,67 @@ public final class TerritoryModel { this.states.put(this.selfStateName, new TerritoryState(self.name(), self.capital(), updated, self.color())); } } + + /** + * 合并 SimpCore 开放 API 的领土数据(在模型发布前、单线程内调用)。 + * + *

合并原则("任意一个数据源显示被占领即算占领"): + *

    + *
  • API 中出现、Dynmap 中没有的区块 → 新增一个 API 邦国状态(或并入已有同名邦国),地图上以紫色标识 API 来源;
  • + *
  • 两边的区块取并集,颜色以 Dynmap 优先(保持地图观感一致);
  • + *
  • 王城坐标:Dynmap 优先,API 数据兜底(Dynmap 王城 marker 缺失时用 API 的 capital 坐标补齐)。
  • + *
+ * API 数据块本身有服务端缓存(最多延迟 1 小时),因此只做"增量补充",绝不覆盖 Dynmap 已确认的数据。

+ * + * @param apiChunks API 返回的 邦国名 → 区块集合 + * @param apiCapitals API 返回的 邦国名 → 王城区块(可为 null) + * @param apiColors API 邦国(Dynmap 中不存在时新建)使用的颜色,传 0 则用默认紫 + */ + public void mergeApiData(Map> apiChunks, Map apiCapitals, int apiColor) { + if (apiChunks == null || apiChunks.isEmpty()) { + return; + } + int color = apiColor != 0 ? apiColor : -12345634; // 默认 API 来源紫 #FF43DE + HashMap> merged = new HashMap<>(); + HashMap rebuilt = new HashMap<>(); + for (Map.Entry> entry : apiChunks.entrySet()) { + String name = entry.getKey(); + if (name == null || name.isEmpty()) { + continue; + } + ChunkPos apiCapital = apiCapitals == null ? null : apiCapitals.get(name); + TerritoryState existing = this.states.get(name); + if (existing == null) { + // API 独有的邦国:全部区块均为新增 + HashSet chunks = new HashSet<>(entry.getValue()); + ChunkPos capital = apiCapital != null ? apiCapital + : (chunks.isEmpty() ? new ChunkPos(0, 0) : chunks.iterator().next()); + rebuilt.put(name, new TerritoryState(name, capital, chunks, color)); + } else { + // 两侧共有:区块取并集;王城以 Dynmap 为准(非默认值时),API 兜底 + HashSet chunks = new HashSet<>(existing.chunks()); + chunks.addAll(entry.getValue()); + ChunkPos capital = existing.capital(); + if (apiCapital != null && (capital == null || new ChunkPos(0, 0).equals(capital))) { + capital = apiCapital; + } + rebuilt.put(name, new TerritoryState(name, capital, chunks, existing.color())); + } + merged.put(name, entry.getValue()); + } + // 未被 API 覆盖的邦国原样保留 + for (Map.Entry entry : this.states.entrySet()) { + if (!rebuilt.containsKey(entry.getKey())) { + rebuilt.put(entry.getKey(), entry.getValue()); + } + } + this.states = rebuilt; + this.allClaimed.clear(); + for (TerritoryState state : rebuilt.values()) { + this.allClaimed.addAll(state.chunks()); + } + this.cachedFlyingChunks = null; + this.cachedAllFlyingChunks = null; + } }