feat: 接入 SimpCore 开放 API 作为第四占领判定数据源

- 新增 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,不提交)
这个提交包含在:
lyizai
2026-09-07 13:32:00 +08:00
父节点 d348861598
当前提交 d971373734
共修改 9 个文件,包含 1031 行新增和 32 行删除
+9
查看文件
@@ -0,0 +1,9 @@
# 环境变量示例(复制为 .env 并填入真实值;.env 已被 .gitignore 忽略,请勿提交)
# Gitea API 令牌
GITEA_TOKEN=<your-gitea-token>
GITEA_API_BASE=https://git.bgjq.top
GITEA_REPO=Lyizai/AutoStore
# SimpCore 开放 API 密钥(https://open.bgjq.top 申请)
SIMP_API_KEY=<your-simp-api-key>
@@ -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);
}
/** 返回扩地消息监听器 */
@@ -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)。
*
* <p>邦国崛起服务器的只读查询服务,本模组用它作为占领判定的第四数据源
* (游戏字幕、游戏消息、Dynmap JSON 之外的补充),并提供:
* <ul>
* <li>己方邦国领土区块明细(区块坐标、忠诚度、占领时间)——修正本地领土模型</li>
* <li>全服领土区块(手动触发)——交叉修正他人区块归属</li>
* <li>玩家所属邦国查询——辅助识别玩家邦国</li>
* <li>邦国列表(含王城坐标)——王城数据兜底</li>
* </ul>
*
* <p>鉴权:{@code Authorization: Bearer <KEY>};限流:按 Key × 接口每分钟限流,
* 全服数据类接口(territory/buffs/...)限流较严且数据量大(实测全服 territory 单次请求可超 2 分钟),
* 因此本客户端<b>只适合低频调用</b>:自动轮询仅用单邦国轻接口,全服接口仅手动触发。
* 服务端响应带缓存,数据最多有 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<String, CacheEntry> 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<String> 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<String, UnionSummary> getAllUnions() {
String json = this.getCachedOrFetch("info/unions");
if (json == null) {
return Collections.emptyMap();
}
Map<String, UnionSummary> 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<ChunkPos, TerritoryChunk> getUnionTerritory(String key) {
String json = this.getCachedOrFetch("info/unions/" + encode(key) + "/territory");
Map<ChunkPos, TerritoryChunk> 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/名)。<b>注意:全服数据量大(实测单次请求可超 2 分钟),
* 限流严格,仅手动触发使用,禁止放入自动轮询。</b>
*/
public Map<ChunkPos, TerritoryChunk> getAllTerritory(Integer unionId) {
String path = "info/territory" + (unionId != null ? "?union_id=" + unionId : "");
String json = this.getCachedOrFetch(path);
Map<ChunkPos, TerritoryChunk> 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<JsonElement> 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<String> 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
) {
}
}
@@ -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 <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 子命令组:
* <ul>
* <li>/autostore api status —— 查看 API 连通状态、最近拉取时间与数据量</li>
* <li>/autostore api key &lt;key&gt; —— 设置并启用 API Key</li>
* <li>/autostore api on / off —— 启用/停用 API 数据源</li>
* <li>/autostore api refresh —— 立即拉取己方邦国 API 领土数据</li>
* <li>/autostore api full —— 手动拉取全服领土(重接口,慎用)</li>
* <li>/autostore api union &lt;邦国名&gt; —— 查询某邦国在 API 中的区块数</li>
* </ul>
*/
private static void registerApiCommands(LiteralArgumentBuilder<FabricClientCommandSource> root, AutoStoreModClient mod) {
LiteralArgumentBuilder<FabricClientCommandSource> 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<String, java.util.Set<ChunkPos>> snapshot = mod.dataService().apiTerritorySnapshot(); int total = 0;
for (java.util.Set<ChunkPos> 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 <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 <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 <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 <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);
}
}
@@ -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) {
// 解析失败时回退到默认配置
@@ -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;
}
}
@@ -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 配置并启动扩地 */
@@ -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 使用。
*
* <p>数据来源是 AutoStore 服务器自带的动态地图(Dynmap)接口,包含:
* <ul>
* <li>union_claims:各邦国的领土多边形(区块归属)</li>
* <li>union_capitals:各邦国的王城坐标</li>
* </ul>
* 解析出的领土数据会与本次会话新占领的区块合并,保证扩地过程中地图实时更新。
* <p>占领判定有四个数据源,任意一个显示区块被占领即算占领:
* <ol>
* <li>游戏内字幕("开垦中 XX%")</li>
* <li>游戏消息("疆土(x,z) 已为你的邦国开拓")</li>
* <li>Dynmap 地图数据(本类的 markers.json 主数据源)</li>
* <li>SimpCore 开放 API({@link autostore.client.api.SimpApiClient},补充/交叉验证,
* 含己方邦国区块明细与忠诚度数据)</li>
* </ol>
*
* <p>Dynmap 数据包含 union_claims(各邦国领土多边形)与 union_capitals(王城坐标);
* API 数据独立低频轮询(服务端有缓存且限流),成功后并入模型——API 独有的区块以
* API 颜色补入,Dynmap 缺失的王城用 API 王城兜底。解析出的领土数据会与本次会话
* 新占领的区块合并,保证扩地过程中地图实时更新。</p>
*/
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<String, Set<ChunkPos>> apiTerritory = new ConcurrentHashMap<>();
/** API 最近一次成功拉取的 邦国名 → 王城区块 */
private final ConcurrentHashMap<String, ChunkPos> 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<String, SimpApiClient.UnionSummary> 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<ChunkPos, SimpApiClient.TerritoryChunk> territory = this.apiClient.getUnionTerritory(stateName);
if (!territory.isEmpty()) {
HashSet<ChunkPos> 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<String> callback) {
if (!this.apiClient.hasApiKey()) {
callback.accept("未配置 API Key");
return;
}
this.apiExecutor.execute(() -> {
try {
Map<ChunkPos, SimpApiClient.TerritoryChunk> all = this.apiClient.getAllTerritory(null);
if (all.isEmpty()) {
callback.accept("拉取失败:" + this.apiClient.lastError());
return;
}
// 按邦国名聚合(API 直接返回 union_name,无需二次映射)
HashMap<String, Set<ChunkPos>> 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 的兜底识别)。
*
* <p>回调在 API 线程执行,返回识别到的邦国名(本地模型中必须存在,否则返回 null——
* 避免识别出一个规划器无法使用的邦国)。调用方需自行切换到主线程再操作 GUI/消息。</p>
*
* @param playerName 玩家游戏名
* @param callback 回调:识别结果(null 表示识别失败)
*/
public void detectStateByPlayerAsync(String playerName, Consumer<String> 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<ChunkPos, SimpApiClient.TerritoryChunk> territory = this.apiClient.getUnionTerritory(name);
if (!territory.isEmpty()) {
HashSet<ChunkPos> 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<String, Set<ChunkPos>> 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<String, Set<ChunkPos>> 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<ChunkPos> freshOwn = new HashSet<>();
TerritoryState freshSelf = parsed.state(this.selectedStateName);
if (freshSelf != null) {
freshOwn.addAll(freshSelf.chunks());
}
Set<ChunkPos> 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(从未在本服务记录)也视为未确认:不保留
@@ -27,8 +27,8 @@ import net.fabricmc.api.Environment;
* </ul>
*/
public final class TerritoryModel {
/** 邦国名 → 邦国领土状态 */
private final Map<String, TerritoryState> states;
/** 邦国名 → 邦国领土状态。允许整体替换(mergeApiData 在模型发布前重建 states) */
private Map<String, TerritoryState> states;
/** 所有邦国已占领区块的并集 */
private final Set<ChunkPos> 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 的领土数据(在模型发布前、单线程内调用)。
*
* <p>合并原则("任意一个数据源显示被占领即算占领"):
* <ul>
* <li>API 中出现、Dynmap 中没有的区块 → 新增一个 API 邦国状态(或并入已有同名邦国),地图上以紫色标识 API 来源;</li>
* <li>两边的区块取并集,颜色以 Dynmap 优先(保持地图观感一致);</li>
* <li>王城坐标:Dynmap 优先,API 数据兜底(Dynmap 王城 marker 缺失时用 API 的 capital 坐标补齐)。</li>
* </ul>
* API 数据块本身有服务端缓存(最多延迟 1 小时),因此只做"增量补充",绝不覆盖 Dynmap 已确认的数据。</p>
*
* @param apiChunks API 返回的 邦国名 → 区块集合
* @param apiCapitals API 返回的 邦国名 → 王城区块(可为 null)
* @param apiColors API 邦国(Dynmap 中不存在时新建)使用的颜色,传 0 则用默认紫
*/
public void mergeApiData(Map<String, Set<ChunkPos>> apiChunks, Map<String, ChunkPos> apiCapitals, int apiColor) {
if (apiChunks == null || apiChunks.isEmpty()) {
return;
}
int color = apiColor != 0 ? apiColor : -12345634; // 默认 API 来源紫 #FF43DE
HashMap<String, Set<ChunkPos>> merged = new HashMap<>();
HashMap<String, TerritoryState> rebuilt = new HashMap<>();
for (Map.Entry<String, Set<ChunkPos>> 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<ChunkPos> 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<ChunkPos> 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<String, TerritoryState> 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;
}
}