比较提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
d971373734 | ||
|
|
d348861598 | ||
|
|
555b96d3ba | ||
|
|
3669442d64 | ||
|
|
70ab6cc698 |
+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>
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
- 目标有效性校验:目标若已被其他邦国占领则立即跳过
|
- 目标有效性校验:目标若已被其他邦国占领则立即跳过
|
||||||
- 现实时间限制:仅 11:00-22:00(本地现实时间)允许开拓,其余时段启动会被拒绝、运行中到点自动停止
|
- 现实时间限制:仅 11:00-22:00(本地现实时间)允许开拓,其余时段启动会被拒绝、运行中到点自动停止
|
||||||
- 自动进食(饥饿时自动吃背包食物)
|
- 自动进食(饥饿时自动吃背包食物)
|
||||||
|
- 自动攻击敌对生物:扩地途中自动面向并攻击索敌范围内的敌对生物(满蓄力出手、不追击、不打断寻路);严格豁免不会主动攻击玩家的中立生物——铁傀儡、僵尸猪灵永不攻击,末影人(仅被激怒)、蜘蛛(仅黑暗中)、猪灵(仅玩家未穿金甲)按原版敌对条件反击,狼、蜜蜂、羊驼等其它中立生物一律不打
|
||||||
|
- 耐久保护回城:每 2 秒检查主手/副手/四件盔甲耐久,任意一件剩余耐久低于阈值(默认 15%,可在 GUI 调整)时自动发送 `/u return` 回城并停止扩地,防止装备被怪物磨没
|
||||||
|
- 自动用船过水:扩地途中若路径前方出现开阔水域(默认宽度 ≥ 4 格、长度 ≥ 6 格),自动切到主手放船并登船;前方出现岸边时自动蹲伏下船并攻击船实体回收船(仅背包有任意 `*_boat`/`*_chest_boat` 时生效;不打断 Baritone 寻路)
|
||||||
- 实时地图 GUI(通过 Ctrl+R 打开)
|
- 实时地图 GUI(通过 Ctrl+R 打开)
|
||||||
- 监听聊天/字幕消息判断开垦结果
|
- 监听聊天/字幕消息判断开垦结果
|
||||||
- 刷新地图时与最新 `markers.json` 核对已占领区块,丢弃地图未确认的超期假占领,避免高地扩地残留空洞
|
- 刷新地图时与最新 `markers.json` 核对已占领区块,丢弃地图未确认的超期假占领,避免高地扩地残留空洞
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
1. 安装 Fabric Loader 0.16.14+ 和 Fabric API
|
1. 安装 Fabric Loader 0.16.14+ 和 Fabric API
|
||||||
2. 将 `autostore-1.2.5.jar` 放入 `.minecraft/mods/` 目录
|
2. 将 `autostore-1.3.0.jar` 放入 `.minecraft/mods/` 目录
|
||||||
3. 启动游戏,按 `Ctrl+R` 打开 GUI
|
3. 启动游戏,按 `Ctrl+R` 打开 GUI
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
./build_mod.sh
|
./build_mod.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
输出文件:`autostore-1.2.5.jar`
|
输出文件:`autostore-1.3.0.jar`
|
||||||
|
|
||||||
## 赞助支持
|
## 赞助支持
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ yarn_mappings=1.21.4+build.8
|
|||||||
loader_version=0.19.3
|
loader_version=0.19.3
|
||||||
|
|
||||||
# Mod Properties
|
# Mod Properties
|
||||||
mod_version=1.2.5
|
mod_version=1.3.0
|
||||||
maven_group=autostore
|
maven_group=autostore
|
||||||
archives_base_name=autostore
|
archives_base_name=autostore
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ implements ClientModInitializer {
|
|||||||
instance = this;
|
instance = this;
|
||||||
this.config = this.configStore.load();
|
this.config = this.configStore.load();
|
||||||
this.dataService.start(Math.max(10L, this.config.refreshIntervalSeconds));
|
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,可在"控制"设置中重新绑定)
|
// 注册一键唤出 GUI 的快捷键(默认 Ctrl+R,可在"控制"设置中重新绑定)
|
||||||
this.guiKey = KeyBindingHelper.registerKeyBinding(new KeyBinding("key.autostore.open", KEY_R, "category.autostore"));
|
this.guiKey = KeyBindingHelper.registerKeyBinding(new KeyBinding("key.autostore.open", KEY_R, "category.autostore"));
|
||||||
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||||
@@ -121,9 +123,10 @@ implements ClientModInitializer {
|
|||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存当前配置到磁盘 */
|
/** 保存当前配置到磁盘,并同步应用 API 配置(Key/开关变更立即生效) */
|
||||||
public void saveConfig() {
|
public void saveConfig() {
|
||||||
this.configStore.save(this.config);
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/*
|
||||||
|
* AutoStore 模组战斗辅助:自动攻击敌对生物与装备耐久保护。
|
||||||
|
*/
|
||||||
|
package autostore.client.combat;
|
||||||
|
|
||||||
|
import net.fabricmc.api.EnvType;
|
||||||
|
import net.fabricmc.api.Environment;
|
||||||
|
import net.minecraft.client.MinecraftClient;
|
||||||
|
import net.minecraft.entity.Entity;
|
||||||
|
import net.minecraft.entity.EquipmentSlot;
|
||||||
|
import net.minecraft.entity.LivingEntity;
|
||||||
|
import net.minecraft.entity.mob.EndermanEntity;
|
||||||
|
import net.minecraft.entity.mob.Monster;
|
||||||
|
import net.minecraft.entity.mob.PiglinEntity;
|
||||||
|
import net.minecraft.entity.mob.SpiderEntity;
|
||||||
|
import net.minecraft.entity.mob.ZombifiedPiglinEntity;
|
||||||
|
import net.minecraft.entity.passive.IronGolemEntity;
|
||||||
|
import net.minecraft.entity.passive.TameableEntity;
|
||||||
|
import net.minecraft.entity.player.PlayerEntity;
|
||||||
|
import net.minecraft.item.ItemStack;
|
||||||
|
import net.minecraft.registry.Registries;
|
||||||
|
import net.minecraft.util.Hand;
|
||||||
|
import net.minecraft.util.math.Box;
|
||||||
|
import net.minecraft.util.math.MathHelper;
|
||||||
|
import net.minecraft.util.math.Vec3d;
|
||||||
|
|
||||||
|
@Environment(EnvType.CLIENT)
|
||||||
|
/**
|
||||||
|
* 战斗辅助工具类:自动攻击与耐久检查的纯函数集合。
|
||||||
|
*
|
||||||
|
* <p>目标筛选遵循一条核心原则:只攻击<b>会主动攻击玩家</b>的生物。
|
||||||
|
* 铁傀儡、僵尸猪灵等中立生物永远不在目标内;末影人/蜘蛛/猪灵等
|
||||||
|
* "条件敌对"生物只在确实处于主动敌对状态时才被攻击,判定依据
|
||||||
|
* 与原版 AI 一致且均可客户端验证(激怒标志/眼睛亮度/金盔甲)。</p>
|
||||||
|
*/
|
||||||
|
public final class AutoCombat {
|
||||||
|
|
||||||
|
/** 原版实体交互距离(格):视线到目标碰撞箱最近点超过该距离无法命中 */
|
||||||
|
private static final double ATTACK_RANGE_SQUARE = 3.0 * 3.0;
|
||||||
|
|
||||||
|
/** 耐久保护检查的装备槽位:主手、副手与四件盔甲 */
|
||||||
|
private static final EquipmentSlot[] GUARD_SLOTS = {
|
||||||
|
EquipmentSlot.MAINHAND, EquipmentSlot.OFFHAND,
|
||||||
|
EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET
|
||||||
|
};
|
||||||
|
|
||||||
|
private AutoCombat() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断实体是否为合法攻击目标。
|
||||||
|
*
|
||||||
|
* <p>判定规则(按优先级):
|
||||||
|
* <ul>
|
||||||
|
* <li>玩家、已驯服宠物:绝不攻击</li>
|
||||||
|
* <li>铁傀儡:不会主动攻击玩家的中立生物,绝不攻击</li>
|
||||||
|
* <li>僵尸猪灵:群体报复机制,绝不攻击</li>
|
||||||
|
* <li>末影人:仅被激怒(瞪视)时主动攻击,此时才反击</li>
|
||||||
|
* <li>蜘蛛:仅黑暗中(眼睛亮度 < 0.5,与原版索敌条件一致)主动攻击</li>
|
||||||
|
* <li>猪灵:玩家穿戴金盔甲时保持中立,未穿戴时才会攻击玩家</li>
|
||||||
|
* <li>其余敌对生物(僵尸/骷髅/苦力怕/卫道士/疣猪兽等):主动攻击</li>
|
||||||
|
* </ul></p>
|
||||||
|
*
|
||||||
|
* @param entity 待判定实体
|
||||||
|
* @param player 玩家自身
|
||||||
|
* @return 是否可作为攻击目标
|
||||||
|
*/
|
||||||
|
public static boolean isAttackTarget(Entity entity, PlayerEntity player) {
|
||||||
|
if (!(entity instanceof LivingEntity living)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (living == player || !living.isAlive() || living.isSpectator()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 绝不攻击玩家(含其他真实玩家)
|
||||||
|
if (living instanceof PlayerEntity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 已驯服的宠物不攻击
|
||||||
|
if (living instanceof TameableEntity tameable && tameable.isTamed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 铁傀儡:不会主动攻击玩家的中立生物,绝不攻击
|
||||||
|
if (living instanceof IronGolemEntity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 僵尸猪灵:激怒一只引来一群,绝不攻击
|
||||||
|
if (living instanceof ZombifiedPiglinEntity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 末影人:仅被激怒(瞪视/先手攻击)时才主动敌对
|
||||||
|
if (living instanceof EndermanEntity enderman) {
|
||||||
|
return enderman.isAngry();
|
||||||
|
}
|
||||||
|
// 蜘蛛:与原版一致,仅黑暗中(眼睛亮度 < 0.5)才主动索敌
|
||||||
|
if (living instanceof SpiderEntity) {
|
||||||
|
return living.getBrightnessAtEyes() < 0.5f;
|
||||||
|
}
|
||||||
|
// 猪灵:玩家穿戴金盔甲时保持中立
|
||||||
|
if (living instanceof PiglinEntity) {
|
||||||
|
return !wearsGoldArmor(player);
|
||||||
|
}
|
||||||
|
// 其余敌对生物:直接攻击
|
||||||
|
return living instanceof Monster;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在索敌范围内查找最近的合法攻击目标(不追击,仅就地战斗)。
|
||||||
|
*
|
||||||
|
* @param client 客户端实例
|
||||||
|
* @param player 玩家
|
||||||
|
* @param scanRange 索敌半径(格)
|
||||||
|
* @return 最近的攻击目标,范围内没有时返回 null
|
||||||
|
*/
|
||||||
|
public static LivingEntity findTarget(MinecraftClient client, PlayerEntity player, double scanRange) {
|
||||||
|
if (client == null || client.world == null || player == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Box box = player.getBoundingBox().expand(scanRange);
|
||||||
|
LivingEntity best = null;
|
||||||
|
double bestDistance = Double.MAX_VALUE;
|
||||||
|
for (LivingEntity entity : client.world.getEntitiesByClass(LivingEntity.class, box,
|
||||||
|
candidate -> isAttackTarget(candidate, player))) {
|
||||||
|
double distance = eyeToBoxSquared(player.getEyePos(), entity.getBoundingBox());
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
best = entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行一帧攻击:面向目标,在攻击距离内且攻击冷却充满时出手。
|
||||||
|
* 距离不够只面向不挥击(等 Baritone 寻路或目标靠近),不空挥不追击。
|
||||||
|
*
|
||||||
|
* @param client 客户端实例
|
||||||
|
* @param target 攻击目标
|
||||||
|
* @return 本帧是否实际发起了攻击
|
||||||
|
*/
|
||||||
|
public static boolean tickAttack(MinecraftClient client, LivingEntity target) {
|
||||||
|
PlayerEntity player = client == null ? null : client.player;
|
||||||
|
if (player == null || client.interactionManager == null || target == null || !target.isAlive()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 使用物品(如进食/举盾)时不攻击,避免打断
|
||||||
|
if (player.isSpectator() || player.isUsingItem()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 面向目标:原版服务端不校验朝向,但部分服务器反作弊会校验
|
||||||
|
faceEntity(player, target);
|
||||||
|
// 攻击距离校验:视线到目标碰撞箱最近点 ≤ 3 格(原版实体交互距离),够不到不空挥
|
||||||
|
if (eyeToBoxSquared(player.getEyePos(), target.getBoundingBox()) > ATTACK_RANGE_SQUARE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 攻击冷却充满才出手,保证每次都满蓄力
|
||||||
|
if (player.getAttackCooldownProgress(0.0f) < 1.0f) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
client.interactionManager.attackEntity(player, target);
|
||||||
|
player.swingHand(Hand.MAIN_HAND);
|
||||||
|
player.resetLastAttackedTicks();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查已装备物品(主手/副手/四件盔甲)中是否有耐久低于阈值的。
|
||||||
|
*
|
||||||
|
* @param player 玩家
|
||||||
|
* @param thresholdPercent 耐久阈值(剩余耐久百分比 ≤ 该值视为过低)
|
||||||
|
* @return 第一个耐久过低的装备槽位,全部正常时返回 null
|
||||||
|
*/
|
||||||
|
public static EquipmentSlot findLowDurabilitySlot(PlayerEntity player, int thresholdPercent) {
|
||||||
|
if (player == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int threshold = Math.max(1, thresholdPercent);
|
||||||
|
for (EquipmentSlot slot : GUARD_SLOTS) {
|
||||||
|
ItemStack stack = player.getEquippedStack(slot);
|
||||||
|
if (stack.isEmpty() || !stack.isDamageable()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int maxDamage = stack.getMaxDamage();
|
||||||
|
int remaining = maxDamage - stack.getDamage();
|
||||||
|
// 用整数乘法避免浮点误差:剩余百分比 ≤ 阈值
|
||||||
|
if (remaining * 100 <= threshold * maxDamage) {
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回装备槽位的中文名称(用于日志与聊天提示) */
|
||||||
|
public static String slotName(EquipmentSlot slot) {
|
||||||
|
return switch (slot) {
|
||||||
|
case MAINHAND -> "主手";
|
||||||
|
case OFFHAND -> "副手";
|
||||||
|
case HEAD -> "头盔";
|
||||||
|
case CHEST -> "胸甲";
|
||||||
|
case LEGS -> "护腿";
|
||||||
|
case FEET -> "靴子";
|
||||||
|
case BODY -> "动物盔甲";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 让玩家面向目标(转向目标中躯位置) */
|
||||||
|
private static void faceEntity(PlayerEntity player, LivingEntity target) {
|
||||||
|
Vec3d eye = player.getEyePos();
|
||||||
|
Vec3d aim = target.getPos().add(0.0, target.getHeight() * 0.5, 0.0);
|
||||||
|
double dx = aim.x - eye.x;
|
||||||
|
double dy = aim.y - eye.y;
|
||||||
|
double dz = aim.z - eye.z;
|
||||||
|
double horizontal = Math.sqrt(dx * dx + dz * dz);
|
||||||
|
float yaw = (float) (Math.toDegrees(Math.atan2(dz, dx)) - 90.0);
|
||||||
|
float pitch = (float) (-Math.toDegrees(Math.atan2(dy, horizontal)));
|
||||||
|
player.setYaw(yaw);
|
||||||
|
player.setPitch(MathHelper.clamp(pitch, -90.0f, 90.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算视线点到包围盒最近点的距离平方(用于原版攻击距离判定) */
|
||||||
|
private static double eyeToBoxSquared(Vec3d eye, Box box) {
|
||||||
|
double dx = Math.max(Math.max(box.minX - eye.x, eye.x - box.maxX), 0.0);
|
||||||
|
double dy = Math.max(Math.max(box.minY - eye.y, eye.y - box.maxY), 0.0);
|
||||||
|
double dz = Math.max(Math.max(box.minZ - eye.z, eye.z - box.maxZ), 0.0);
|
||||||
|
return dx * dx + dy * dy + dz * dz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断玩家四件盔甲中是否至少一件为金质(金盔甲可使猪灵保持中立) */
|
||||||
|
private static boolean wearsGoldArmor(PlayerEntity player) {
|
||||||
|
for (EquipmentSlot slot : new EquipmentSlot[]{EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET}) {
|
||||||
|
ItemStack stack = player.getEquippedStack(slot);
|
||||||
|
if (!stack.isEmpty() && Registries.ITEM.getId(stack.getItem()).getPath().contains("golden")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import com.mojang.brigadier.CommandDispatcher;
|
|||||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||||
|
import java.util.Map;
|
||||||
import net.fabricmc.api.EnvType;
|
import net.fabricmc.api.EnvType;
|
||||||
import net.fabricmc.api.Environment;
|
import net.fabricmc.api.Environment;
|
||||||
import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager;
|
import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager;
|
||||||
@@ -75,19 +76,39 @@ public final class AutoStoreCommands {
|
|||||||
return 1;
|
return 1;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 自动识别邦国
|
// 自动识别邦国:先用 Dynmap 数据按所在区块判断,失败时回退 SimpCore API 按玩家名查询
|
||||||
root.then(ClientCommandManager.literal("auto").executes(ctx -> {
|
root.then(ClientCommandManager.literal("auto").executes(ctx -> {
|
||||||
TerritoryState state = mod.dataService().model().findContainingState(playerChunk(ctx.getSource()));
|
ChunkPos chunk = playerChunk(ctx.getSource());
|
||||||
if (state == null) {
|
TerritoryState state = mod.dataService().model().findContainingState(chunk);
|
||||||
ctx.getSource().sendError(Text.literal("无法识别当前邦国"));
|
if (state != null) {
|
||||||
} else {
|
|
||||||
mod.config().stateName = state.name();
|
mod.config().stateName = state.name();
|
||||||
mod.saveConfig();
|
mod.saveConfig();
|
||||||
ctx.getSource().sendFeedback(Text.literal("已自动识别邦国:" + state.name()));
|
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;
|
return 1;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ===== SimpCore 开放 API 命令组 =====
|
||||||
|
registerApiCommands(root, mod);
|
||||||
|
|
||||||
// 设置邦国
|
// 设置邦国
|
||||||
root.then(ClientCommandManager.literal("state").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> {
|
root.then(ClientCommandManager.literal("state").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> {
|
||||||
String name = StringArgumentType.getString(ctx, "name");
|
String name = StringArgumentType.getString(ctx, "name");
|
||||||
@@ -129,6 +150,12 @@ public final class AutoStoreCommands {
|
|||||||
"/autostore state <邦国名> 设置邦国\n" +
|
"/autostore state <邦国名> 设置邦国\n" +
|
||||||
"/autostore strategy <1|2|3|4> 设置策略\n" +
|
"/autostore strategy <1|2|3|4> 设置策略\n" +
|
||||||
"/autostore rings <层数> 设置圈数上限\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 导出日志"));
|
"/autostore log 导出日志"));
|
||||||
return 1;
|
return 1;
|
||||||
}));
|
}));
|
||||||
@@ -148,8 +175,7 @@ public final class AutoStoreCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** /autostore start 命令处理:复制当前配置并覆盖目标数量与策略,然后启动扩地 */
|
/** /autostore start 命令处理:复制当前配置并覆盖目标数量与策略,然后启动扩地 */
|
||||||
private static int start(FabricClientCommandSource source, AutoStoreModClient mod, int targetCount, int strategy) {
|
private static int start(FabricClientCommandSource source, AutoStoreModClient mod, int targetCount, int strategy) { ExpansionConfig config = mod.config().copy();
|
||||||
ExpansionConfig config = mod.config().copy();
|
|
||||||
config.targetCount = targetCount;
|
config.targetCount = targetCount;
|
||||||
config.strategy = strategy;
|
config.strategy = strategy;
|
||||||
mod.config().targetCount = targetCount;
|
mod.config().targetCount = targetCount;
|
||||||
@@ -178,4 +204,140 @@ public final class AutoStoreCommands {
|
|||||||
BlockPos pos = source.getPlayer().getBlockPos();
|
BlockPos pos = source.getPlayer().getBlockPos();
|
||||||
return ChunkPos.fromBlock(pos.getX(), pos.getZ());
|
return ChunkPos.fromBlock(pos.getX(), pos.getZ());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册 /autostore api 子命令组:
|
||||||
|
* <ul>
|
||||||
|
* <li>/autostore api status —— 查看 API 连通状态、最近拉取时间与数据量</li>
|
||||||
|
* <li>/autostore api key <key> —— 设置并启用 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 <邦国名> —— 查询某邦国在 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,22 @@ public final class ConfigStore {
|
|||||||
if (loaded == null) {
|
if (loaded == null) {
|
||||||
return new ExpansionConfig();
|
return new ExpansionConfig();
|
||||||
}
|
}
|
||||||
// 以下字段为固定默认值,防止旧配置文件中的异常值影响运行
|
// autoEat/eatThreshold 字段为固定默认值,防止旧配置文件中的异常值影响运行;
|
||||||
|
// 注意:maxRings 由 /autostore rings 命令持久化,不得在此覆盖,否则命令设置会在重启后失效
|
||||||
loaded.autoEat = true;
|
loaded.autoEat = true;
|
||||||
loaded.eatThreshold = 14;
|
loaded.eatThreshold = 14;
|
||||||
loaded.maxRings = 0;
|
// 耐久保护阈值钳制到合理区间,防止异常值导致永不回城或刚出门就回城
|
||||||
|
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;
|
return loaded;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 解析失败时回退到默认配置
|
// 解析失败时回退到默认配置
|
||||||
|
|||||||
@@ -91,12 +91,45 @@ public class ExpansionConfig {
|
|||||||
/** 触发自动进食的饥饿值阈值(0-20) */
|
/** 触发自动进食的饥饿值阈值(0-20) */
|
||||||
public int eatThreshold = 14;
|
public int eatThreshold = 14;
|
||||||
|
|
||||||
|
/** 是否启用自动攻击:扩地途中自动面向并攻击范围内的敌对生物(铁傀儡/僵尸猪灵等中立生物永不攻击) */
|
||||||
|
public boolean autoAttack = true;
|
||||||
|
|
||||||
|
/** 是否启用耐久保护:装备耐久过低时发送 /u return 回城并停止扩地,防止装备被怪物磨没 */
|
||||||
|
public boolean durabilityGuard = true;
|
||||||
|
|
||||||
|
/** 耐久保护阈值:主手/副手/盔甲任意一件剩余耐久百分比 ≤ 该值时触发回城(1-99,默认 15) */
|
||||||
|
public int durabilityThreshold = 15;
|
||||||
|
|
||||||
|
/** 是否启用自动用船过水:路径前方出现开阔水域时自动放船登船,上岸前自动下船回收(默认 true) */
|
||||||
|
public boolean autoBoat = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发自动用船的水面最小宽度(格):玩家前进方向上连续水方块数 ≥ 该值时放船登船。
|
||||||
|
* 避免小水洼/小溪浪费船只。默认 4。
|
||||||
|
*/
|
||||||
|
public int autoBoatMinWidth = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动用船最短水域长度(格):玩家前进方向上从当前位置到首个非水方块的距离 ≥ 该值才放船登船。
|
||||||
|
* 比宽度更稳健(防止横跨窄长水道时还在岸上就提前登船)。默认 6。
|
||||||
|
*/
|
||||||
|
public int autoBoatMinLength = 6;
|
||||||
|
|
||||||
/** 扩圈上限(0 表示不限制) */
|
/** 扩圈上限(0 表示不限制) */
|
||||||
public int maxRings;
|
public int maxRings;
|
||||||
|
|
||||||
/** 地图自动刷新间隔(秒),最小 10 秒 */
|
/** 地图自动刷新间隔(秒),最小 10 秒 */
|
||||||
public int refreshIntervalSeconds = 30;
|
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 深拷贝当前配置,返回一个独立的副本。
|
* 深拷贝当前配置,返回一个独立的副本。
|
||||||
*
|
*
|
||||||
@@ -129,8 +162,17 @@ public class ExpansionConfig {
|
|||||||
c.avoidMobs = this.avoidMobs;
|
c.avoidMobs = this.avoidMobs;
|
||||||
c.autoEat = this.autoEat;
|
c.autoEat = this.autoEat;
|
||||||
c.eatThreshold = this.eatThreshold;
|
c.eatThreshold = this.eatThreshold;
|
||||||
|
c.autoAttack = this.autoAttack;
|
||||||
|
c.durabilityGuard = this.durabilityGuard;
|
||||||
|
c.durabilityThreshold = this.durabilityThreshold;
|
||||||
|
c.autoBoat = this.autoBoat;
|
||||||
|
c.autoBoatMinWidth = this.autoBoatMinWidth;
|
||||||
|
c.autoBoatMinLength = this.autoBoatMinLength;
|
||||||
c.maxRings = this.maxRings;
|
c.maxRings = this.maxRings;
|
||||||
c.refreshIntervalSeconds = this.refreshIntervalSeconds;
|
c.refreshIntervalSeconds = this.refreshIntervalSeconds;
|
||||||
|
c.apiEnabled = this.apiEnabled;
|
||||||
|
c.apiKey = this.apiKey;
|
||||||
|
c.apiRefreshIntervalSeconds = this.apiRefreshIntervalSeconds;
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
package autostore.client.controller;
|
package autostore.client.controller;
|
||||||
|
|
||||||
import autostore.client.bridge.BaritoneBridge;
|
import autostore.client.bridge.BaritoneBridge;
|
||||||
|
import autostore.client.combat.AutoCombat;
|
||||||
import autostore.client.config.ExpansionConfig;
|
import autostore.client.config.ExpansionConfig;
|
||||||
import autostore.client.monitor.SubtitleChatMonitor;
|
import autostore.client.monitor.SubtitleChatMonitor;
|
||||||
|
import autostore.client.navigation.AutoBoatController;
|
||||||
import autostore.client.planner.ExpansionPlanner;
|
import autostore.client.planner.ExpansionPlanner;
|
||||||
import autostore.client.territory.ChunkPos;
|
import autostore.client.territory.ChunkPos;
|
||||||
import autostore.client.territory.TerritoryDataService;
|
import autostore.client.territory.TerritoryDataService;
|
||||||
@@ -29,8 +31,11 @@ import net.fabricmc.api.EnvType;
|
|||||||
import net.fabricmc.api.Environment;
|
import net.fabricmc.api.Environment;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.util.Hand;
|
import net.minecraft.util.Hand;
|
||||||
|
import net.minecraft.entity.EquipmentSlot;
|
||||||
|
import net.minecraft.entity.LivingEntity;
|
||||||
import net.minecraft.entity.player.PlayerEntity;
|
import net.minecraft.entity.player.PlayerEntity;
|
||||||
import net.minecraft.entity.player.PlayerInventory;
|
import net.minecraft.entity.player.PlayerInventory;
|
||||||
|
import net.minecraft.entity.vehicle.AbstractBoatEntity;
|
||||||
import net.minecraft.item.ItemStack;
|
import net.minecraft.item.ItemStack;
|
||||||
import net.minecraft.util.math.BlockPos;
|
import net.minecraft.util.math.BlockPos;
|
||||||
import net.minecraft.util.math.Vec3d;
|
import net.minecraft.util.math.Vec3d;
|
||||||
@@ -44,7 +49,8 @@ import net.minecraft.text.Text;
|
|||||||
*
|
*
|
||||||
* <p>工作流程:规划(PLANNING)→ 寻路(PATHING)→ 等待开垦(WAITING)→ 成功/失败(SUCCESS/FAIL)→ 下一个目标(NEXT)→ 完成(COMPLETE)。
|
* <p>工作流程:规划(PLANNING)→ 寻路(PATHING)→ 等待开垦(WAITING)→ 成功/失败(SUCCESS/FAIL)→ 下一个目标(NEXT)→ 完成(COMPLETE)。
|
||||||
* 每帧由 {@link #tick()} 驱动,通过 Baritone 自动寻路到目标区块,
|
* 每帧由 {@link #tick()} 驱动,通过 Baritone 自动寻路到目标区块,
|
||||||
* 并监听聊天/字幕消息判断开垦是否成功。支持自动进食、超时重试、手动停止等功能。
|
* 并监听聊天/字幕消息判断开垦是否成功。支持自动进食、自动攻击敌对生物、
|
||||||
|
* 低耐久自动回城、超时重试、手动停止等功能。
|
||||||
*/
|
*/
|
||||||
public final class ExpansionController
|
public final class ExpansionController
|
||||||
implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
||||||
@@ -56,10 +62,16 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
|||||||
private static final int CLAIM_START_HOUR = 11;
|
private static final int CLAIM_START_HOUR = 11;
|
||||||
/** 允许开拓的现实时间结束小时(不含,本地时区),即 22:00 起停止开拓 */
|
/** 允许开拓的现实时间结束小时(不含,本地时区),即 22:00 起停止开拓 */
|
||||||
private static final int CLAIM_END_HOUR = 22;
|
private static final int CLAIM_END_HOUR = 22;
|
||||||
|
/** 自动攻击索敌半径(格):只攻击进入该范围的敌对生物,不追击,避免打断扩地寻路 */
|
||||||
|
private static final double AUTO_ATTACK_SCAN_RANGE = 4.5;
|
||||||
|
/** 耐久保护检查间隔(tick),40 tick ≈ 2 秒 */
|
||||||
|
private static final int DURABILITY_CHECK_INTERVAL_TICKS = 40;
|
||||||
/** Baritone 寻路桥接(负责自动移动) */
|
/** Baritone 寻路桥接(负责自动移动) */
|
||||||
private final BaritoneBridge baritoneBridge = new BaritoneBridge();
|
private final BaritoneBridge baritoneBridge = new BaritoneBridge();
|
||||||
/** 扩地规划器(决定下一个目标区块) */
|
/** 扩地规划器(决定下一个目标区块) */
|
||||||
private final ExpansionPlanner planner = new ExpansionPlanner();
|
private final ExpansionPlanner planner = new ExpansionPlanner();
|
||||||
|
/** 自动用船过水辅助(开阔水域自动放船登船、上岸前自动下船回收) */
|
||||||
|
private final AutoBoatController autoBoat = new AutoBoatController();
|
||||||
/** 聊天/字幕监听器(识别开垦进度与结果) */
|
/** 聊天/字幕监听器(识别开垦进度与结果) */
|
||||||
private final SubtitleChatMonitor monitor = new SubtitleChatMonitor(this);
|
private final SubtitleChatMonitor monitor = new SubtitleChatMonitor(this);
|
||||||
/** 领土数据服务(提供实时领土模型) */
|
/** 领土数据服务(提供实时领土模型) */
|
||||||
@@ -112,6 +124,14 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
|||||||
private long sideStepSinceMillis;
|
private long sideStepSinceMillis;
|
||||||
/** 边走两步开始时的玩家方块坐标(用于判断是否已走开约 2.5 格) */
|
/** 边走两步开始时的玩家方块坐标(用于判断是否已走开约 2.5 格) */
|
||||||
private BlockPos sideStepStartPos;
|
private BlockPos sideStepStartPos;
|
||||||
|
/** 耐久保护检查计数器:每 {@link #DURABILITY_CHECK_INTERVAL_TICKS} 帧检查一次装备耐久 */
|
||||||
|
private int durabilityCheckCounter;
|
||||||
|
/** 本次回城是否由耐久保护触发:RETURNING 等待结束后停止任务(等待玩家修复装备),而非重新规划 */
|
||||||
|
private boolean returnThenStop;
|
||||||
|
/** 自动攻击当前目标的实体 ID(-1 为无目标),仅用于避免重复记录战斗日志 */
|
||||||
|
private int combatTargetEntityId = -1;
|
||||||
|
/** 当前是否正持续按住空格键(自动游泳用),用于 stop() 与离开条件时精准释放 */
|
||||||
|
private boolean holdingJump;
|
||||||
|
|
||||||
public ExpansionController(TerritoryDataService dataService) {
|
public ExpansionController(TerritoryDataService dataService) {
|
||||||
this.dataService = dataService;
|
this.dataService = dataService;
|
||||||
@@ -231,6 +251,9 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
|||||||
this.consecutiveTimeoutMillis = 0L;
|
this.consecutiveTimeoutMillis = 0L;
|
||||||
this.sideStepCount = 0;
|
this.sideStepCount = 0;
|
||||||
this.sideStepping = false;
|
this.sideStepping = false;
|
||||||
|
this.returnThenStop = false;
|
||||||
|
this.durabilityCheckCounter = 0;
|
||||||
|
this.combatTargetEntityId = -1;
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.claimedSet.clear();
|
this.claimedSet.clear();
|
||||||
this.target = null;
|
this.target = null;
|
||||||
@@ -258,6 +281,10 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
|||||||
if (this.sideStepping) {
|
if (this.sideStepping) {
|
||||||
this.stopSideStep(false);
|
this.stopSideStep(false);
|
||||||
}
|
}
|
||||||
|
// 若处于自动游泳持续按空格阶段,先释放空格键
|
||||||
|
this.releaseJump(MinecraftClient.getInstance());
|
||||||
|
// 若正处于自动用船动作流程,先释放蹲伏键与暂存的主手槽位
|
||||||
|
this.autoBoat.reset(MinecraftClient.getInstance(), true);
|
||||||
this.eating = false;
|
this.eating = false;
|
||||||
this.setState(State.STOPPED, "\u5df2\u624b\u52a8\u505c\u6b62");
|
this.setState(State.STOPPED, "\u5df2\u624b\u52a8\u505c\u6b62");
|
||||||
this.log("\u5df2\u624b\u52a8\u505c\u6b62");
|
this.log("\u5df2\u624b\u52a8\u505c\u6b62");
|
||||||
@@ -265,8 +292,8 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 每帧驱动逻辑(由客户端 tick 事件调用):
|
* 每帧驱动逻辑(由客户端 tick 事件调用):
|
||||||
* 更新玩家区块,优先处理自动进食,再按当前状态分发到对应的 tick 方法。
|
* 更新玩家区块,依次处理自动进食、耐久保护、自动游泳、自动攻击,
|
||||||
* 若已退出世界则自动停止任务。
|
* 再按当前状态分发到对应的 tick 方法。若已退出世界则自动停止任务。
|
||||||
*/
|
*/
|
||||||
public void tick() {
|
public void tick() {
|
||||||
MinecraftClient client = MinecraftClient.getInstance();
|
MinecraftClient client = MinecraftClient.getInstance();
|
||||||
@@ -296,41 +323,50 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
|||||||
if (this.tickAutoFood(client)) {
|
if (this.tickAutoFood(client)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 耐久保护优先于一切寻路/战斗:装备耐久过低时先回城,防止装备被怪物磨没
|
||||||
|
if (this.tickDurabilityGuard(client)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.tickAutoSwim(client);
|
this.tickAutoSwim(client);
|
||||||
// 处于"往边上走两步"阶段:每帧驱动手动横向行走,走开后自动重试原目标
|
// 自动用船过水:开阔水域自动放船登船、上岸前自动下船回收
|
||||||
|
this.tickAutoBoat(client);
|
||||||
|
// 处于"往边上走两步"阶段:每帧驱动手动横向行走,走开后自动重试原目标(该阶段不自动攻击,避免抢方向键)
|
||||||
if (this.sideStepping) {
|
if (this.sideStepping) {
|
||||||
this.tickSideStep();
|
this.tickSideStep();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (this.state.ordinal()) {
|
// 自动攻击:面向并攻击范围内的敌对生物(不追击,不打断寻路状态机)
|
||||||
case 1: {
|
this.tickAutoAttack(client);
|
||||||
|
switch (this.state) {
|
||||||
|
case PLANNING: {
|
||||||
this.plan();
|
this.plan();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 2: {
|
case PATHING: {
|
||||||
this.tickPathing();
|
this.tickPathing();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 3: {
|
case WAITING: {
|
||||||
this.tickWaiting();
|
this.tickWaiting();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 4: {
|
case SUCCESS: {
|
||||||
this.tickSuccess();
|
this.tickSuccess();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 5: {
|
case FAIL: {
|
||||||
this.tickFail();
|
this.tickFail();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 6: {
|
case NEXT: {
|
||||||
this.tickNext();
|
this.tickNext();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 7: {
|
case RETURNING: {
|
||||||
this.tickReturning();
|
this.tickReturning();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// IDLE / COMPLETE / STOPPED 无需每帧驱动
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,36 +487,48 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连续寻路超时达到阈值时触发回城:
|
* 发送 /u return 回城:取消当前寻路,向服务器发送回城指令(约 10 秒后自动传送回安全位置),
|
||||||
* 取消当前寻路,向服务器发送 /u return 指令(约 10 秒后自动传送回可寻路的安全位置),
|
* 随后进入 RETURNING 状态等待传送完成。
|
||||||
* 随后进入 RETURNING 状态等待传送完成,传送后由 {@link #tickReturning()} 重新规划并从头开始。
|
*
|
||||||
|
* <p>传送完成后的走向由 {@link #returnThenStop} 决定:
|
||||||
|
* 寻路超时回城会重新规划继续扩地;耐久保护回城则停止任务,等待玩家修复装备后手动重新开始。</p>
|
||||||
|
*
|
||||||
|
* @param reason 触发回城的原因(写入日志与聊天提示)
|
||||||
*/
|
*/
|
||||||
private void sendReturnCommand() {
|
private void sendReturnCommand(String reason) {
|
||||||
this.baritoneBridge.cancel();
|
this.baritoneBridge.cancel();
|
||||||
MinecraftClient client = MinecraftClient.getInstance();
|
MinecraftClient client = MinecraftClient.getInstance();
|
||||||
if (client != null && client.player != null) {
|
if (client != null && client.player != null) {
|
||||||
this.log("\u8fde\u7eed\u5bfb\u8def\u8d85\u65f6\u8d85\u8fc7 " + this.config.returnThresholdSeconds + " \u79d2\uff0c\u53d1\u9001 /u return \u56de\u57ce\uff0c10 \u79d2\u540e\u81ea\u52a8\u4f20\u9001");
|
this.log(reason + ",发送 /u return 回城,10 秒后自动传送");
|
||||||
// 通过玩家网络处理器发送聊天指令 /u return(Yarn 映射:sendChatCommand 自动带上前缀斜杠)
|
// 通过玩家网络处理器发送聊天指令 /u return(Yarn 映射:sendChatCommand 自动带上前缀斜杠)
|
||||||
client.player.networkHandler.sendChatCommand("u return");
|
client.player.networkHandler.sendChatCommand("u return");
|
||||||
client.player.sendMessage(Text.literal("[AutoStore] \u8fde\u7eed\u5bfb\u8def\u8d85\u65f6\uff0c\u5df2\u53d1\u9001 /u return\uff0c\u7b49\u5f85\u4f20\u9001\u540e\u91cd\u65b0\u6269\u5730"), false);
|
client.player.sendMessage(Text.literal("[AutoStore] " + reason + ",已发送 /u return,传送后"
|
||||||
|
+ (this.returnThenStop ? "停止扩地" : "重新扩地")), false);
|
||||||
} else {
|
} else {
|
||||||
this.log("\u8fde\u7eed\u5bfb\u8def\u8d85\u65f6\uff0c\u4f46\u73a9\u5bb6\u4e0d\u5728\u4e16\u754c\uff0c\u8df3\u8fc7");
|
this.log(reason + ",但玩家不在世界,跳过");
|
||||||
}
|
}
|
||||||
this.returnStartMillis = System.currentTimeMillis();
|
this.returnStartMillis = System.currentTimeMillis();
|
||||||
this.setState(State.RETURNING, "\u7b49\u5f85\u56de\u57ce\u4f20\u9001\uff0810 \u79d2\u540e\u81ea\u52a8\u4f20\u9001\uff09");
|
this.setState(State.RETURNING, "等待回城传送(10 秒后自动传送)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回城传送等待阶段每帧逻辑:
|
* 回城传送等待阶段每帧逻辑:
|
||||||
* 发送 /u return 后服务器约 10 秒把玩家传送回安全位置。等待 {@code returnWaitSeconds}
|
* 发送 /u return 后服务器约 10 秒把玩家传送回安全位置。
|
||||||
* 秒(覆盖传送延迟并留余量)后,清零连续超时计数并重新规划扩地计划,
|
* 等待 {@code returnWaitSeconds} 秒(覆盖传送延迟并留余量)后:
|
||||||
* 从最高优先级区块(队首)重新开始寻路。
|
* 耐久保护触发的回城 → 停止任务并提示修复装备;
|
||||||
|
* 寻路超时触发的回城 → 清零连续超时计数并重新规划扩地计划,从最高优先级区块重新开始。
|
||||||
*/
|
*/
|
||||||
private void tickReturning() {
|
private void tickReturning() {
|
||||||
long elapsedMillis = System.currentTimeMillis() - this.returnStartMillis;
|
long elapsedMillis = System.currentTimeMillis() - this.returnStartMillis;
|
||||||
if (elapsedMillis < (long)this.config.returnWaitSeconds * 1000L) {
|
if (elapsedMillis < (long)this.config.returnWaitSeconds * 1000L) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.returnThenStop) {
|
||||||
|
this.returnThenStop = false;
|
||||||
|
this.log("耐久保护回城完成,任务已停止;请修复/更换装备后重新开始扩地");
|
||||||
|
this.setState(State.STOPPED, "装备耐久过低已回城,请修复装备后重新开始");
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.consecutiveTimeoutMillis = 0L;
|
this.consecutiveTimeoutMillis = 0L;
|
||||||
this.log("\u56de\u57ce\u4f20\u9001\u5b8c\u6210\uff0c\u91cd\u65b0\u89c4\u5212\u5e76\u4ece\u6700\u9ad8\u4f18\u5148\u7ea7\u533a\u5757\u5f00\u59cb");
|
this.log("\u56de\u57ce\u4f20\u9001\u5b8c\u6210\uff0c\u91cd\u65b0\u89c4\u5212\u5e76\u4ece\u6700\u9ad8\u4f18\u5148\u7ea7\u533a\u5757\u5f00\u59cb");
|
||||||
// 重新规划:清空队列重新生成计划,从队首(最高优先级)区块开始寻路
|
// 重新规划:清空队列重新生成计划,从队首(最高优先级)区块开始寻路
|
||||||
@@ -563,7 +611,7 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
|||||||
*/
|
*/
|
||||||
private void giveUpTarget(String reason) {
|
private void giveUpTarget(String reason) {
|
||||||
if (this.consecutiveTimeoutMillis >= (long)this.config.returnThresholdSeconds * 1000L) {
|
if (this.consecutiveTimeoutMillis >= (long)this.config.returnThresholdSeconds * 1000L) {
|
||||||
this.sendReturnCommand();
|
this.sendReturnCommand("连续寻路超时超过 " + this.config.returnThresholdSeconds + " 秒");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.target != null) {
|
if (this.target != null) {
|
||||||
@@ -850,21 +898,114 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动游泳逻辑(每帧调用):
|
* 自动游泳逻辑(每帧调用):
|
||||||
* 仅在玩家完全没入水中(有溺水风险)时自动跳跃,保持浮在水面。
|
* 仅在玩家处于水中(含没入 / 水面 / 浅水)、Baritone 未接管寻路、玩家不在船中时,
|
||||||
* 玩家在水面上正常游泳时不做任何干预,避免干扰 Baritone 的水平寻路。
|
* 持续按住跳跃键,使玩家不断浮到水面,避免溺水。
|
||||||
|
* 任务停止、离开水、Baritone 开始寻路、坐上船等条件触发时立即释放跳跃键,
|
||||||
|
* 避免干扰 Baritone 与其它流程。
|
||||||
*/
|
*/
|
||||||
private void tickAutoSwim(MinecraftClient client) {
|
private void tickAutoSwim(MinecraftClient client) {
|
||||||
if (!this.isRunning()) {
|
if (!this.isRunning() || client.player == null) {
|
||||||
|
this.releaseJump(client);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
PlayerEntity player = client.player;
|
PlayerEntity player = client.player;
|
||||||
if (player == null) {
|
// 水中 + Baritone 未接管 + 玩家不在船中(船里前进靠船自身,不需要自动跳)→ 持续按住空格
|
||||||
|
boolean inWater = player.isTouchingWater();
|
||||||
|
boolean baritoneControlling = this.baritoneBridge.isPathing();
|
||||||
|
boolean inBoat = player.getVehicle() instanceof AbstractBoatEntity;
|
||||||
|
if (inWater && !baritoneControlling && !inBoat) {
|
||||||
|
this.pressJump(client);
|
||||||
|
} else {
|
||||||
|
this.releaseJump(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按住跳跃键(仅在当前未按时生效,避免重复赋值) */
|
||||||
|
private void pressJump(MinecraftClient client) {
|
||||||
|
if (!this.holdingJump && client.options != null) {
|
||||||
|
client.options.jumpKey.setPressed(true);
|
||||||
|
this.holdingJump = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 释放跳跃键(仅在当前按住时生效,避免误清玩家手动按下的状态——但本模组自己设置的就该清掉) */
|
||||||
|
private void releaseJump(MinecraftClient client) {
|
||||||
|
if (this.holdingJump && client.options != null) {
|
||||||
|
client.options.jumpKey.setPressed(false);
|
||||||
|
this.holdingJump = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动攻击逻辑(每帧调用):
|
||||||
|
* 扩地途中索敌半径内出现敌对生物时,面向该生物并在攻击距离内自动出手(满蓄力),
|
||||||
|
* 不追击、不打断 Baritone 寻路;目标筛选由 {@link AutoCombat#isAttackTarget} 保证
|
||||||
|
* 绝不攻击铁傀儡、僵尸猪灵等不会主动攻击玩家的中立生物。
|
||||||
|
*/
|
||||||
|
private void tickAutoAttack(MinecraftClient client) {
|
||||||
|
if (!this.config.autoAttack || !this.isRunning()) {
|
||||||
|
this.combatTargetEntityId = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 只有完全没入水中(头部在水面以下)才有溺水风险,此时才需要跳跃上浮
|
LivingEntity target = AutoCombat.findTarget(client, client.player, AUTO_ATTACK_SCAN_RANGE);
|
||||||
if (player.isSubmergedInWater()) {
|
if (target == null) {
|
||||||
player.jump();
|
this.combatTargetEntityId = -1;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 仅在新目标出现时记录一次日志,避免每帧刷屏
|
||||||
|
if (target.getId() != this.combatTargetEntityId) {
|
||||||
|
this.combatTargetEntityId = target.getId();
|
||||||
|
this.log("发现敌对生物 " + target.getDisplayName().getString() + ",自动攻击中");
|
||||||
|
}
|
||||||
|
AutoCombat.tickAttack(client, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动用船过水(每帧调用):
|
||||||
|
* 在岸上且朝向方向前方出现开阔水域时,自动放船并登船;
|
||||||
|
* 在船中且前方出现岸边时,自动下船并攻击船实体回收船。
|
||||||
|
* 仅在扩地任务运行中、配置 autoBoat 开启时生效;不打断 Baritone 寻路。
|
||||||
|
*/
|
||||||
|
private void tickAutoBoat(MinecraftClient client) {
|
||||||
|
this.autoBoat.tick(client, this.config, this.isRunning());
|
||||||
|
if (this.autoBoat.isActive()) {
|
||||||
|
String desc = this.autoBoat.describe();
|
||||||
|
if (desc != null) {
|
||||||
|
this.status = desc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 当前是否处于自动用船动作流程(用于状态栏 / 日志显示) */
|
||||||
|
public boolean isAutoBoatActive() {
|
||||||
|
return this.autoBoat.isActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 耐久保护逻辑(每 {@link #DURABILITY_CHECK_INTERVAL_TICKS} 帧调用):
|
||||||
|
* 主手/副手/四件盔甲任意一件剩余耐久低于阈值时,发送 /u return 回城并停止扩地,
|
||||||
|
* 防止装备在继续战斗中被怪物磨没。回城传送完成后由 {@link #tickReturning()} 停止任务。
|
||||||
|
*
|
||||||
|
* @return true 表示本帧已触发回城(进入 RETURNING 状态)
|
||||||
|
*/
|
||||||
|
private boolean tickDurabilityGuard(MinecraftClient client) {
|
||||||
|
if (!this.config.durabilityGuard || !this.isRunning() || this.state == State.RETURNING) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (++this.durabilityCheckCounter < DURABILITY_CHECK_INTERVAL_TICKS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.durabilityCheckCounter = 0;
|
||||||
|
EquipmentSlot low = AutoCombat.findLowDurabilitySlot(client.player, this.config.durabilityThreshold);
|
||||||
|
if (low == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String reason = "装备耐久过低(" + AutoCombat.slotName(low)
|
||||||
|
+ " 剩余耐久≤" + this.config.durabilityThreshold + "%)";
|
||||||
|
this.returnThenStop = true;
|
||||||
|
this.log(reason + ",发送 /u return 回城并停止扩地,防止装备被怪物磨没");
|
||||||
|
this.sendReturnCommand(reason);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,11 +50,15 @@ extends Screen {
|
|||||||
// ---- 输入控件 ----
|
// ---- 输入控件 ----
|
||||||
private TextFieldWidget stateField;
|
private TextFieldWidget stateField;
|
||||||
private TextFieldWidget targetCountField;
|
private TextFieldWidget targetCountField;
|
||||||
|
private TextFieldWidget durabilityField;
|
||||||
private CheckboxWidget sprintBox;
|
private CheckboxWidget sprintBox;
|
||||||
private CheckboxWidget jumpBox;
|
private CheckboxWidget jumpBox;
|
||||||
private CheckboxWidget avoidBox;
|
private CheckboxWidget avoidBox;
|
||||||
|
private CheckboxWidget attackBox;
|
||||||
|
private CheckboxWidget durabilityGuardBox;
|
||||||
private CheckboxWidget breakBox;
|
private CheckboxWidget breakBox;
|
||||||
private CheckboxWidget placeBox;
|
private CheckboxWidget placeBox;
|
||||||
|
private CheckboxWidget autoBoatBox;
|
||||||
private ButtonWidget refreshButton;
|
private ButtonWidget refreshButton;
|
||||||
// ---- 区域圈选状态 ----
|
// ---- 区域圈选状态 ----
|
||||||
private boolean selectingRegion;
|
private boolean selectingRegion;
|
||||||
@@ -142,9 +146,11 @@ extends Screen {
|
|||||||
this.addDrawableChild(this.strategyButton("\u7ed5\u56fd\u4e00\u5708", 4, left + 96, strategyY + 24, 90));
|
this.addDrawableChild(this.strategyButton("\u7ed5\u56fd\u4e00\u5708", 4, left + 96, strategyY + 24, 90));
|
||||||
|
|
||||||
this.advancedY = strategyY + 24 + 20 + 8;
|
this.advancedY = strategyY + 24 + 20 + 8;
|
||||||
// ===== 只保留五个权限选择框:疾跑、跳跃、避怪、破坏、放置 =====
|
// ===== 权限/辅助选择框:疾跑、跳跃、避怪、攻击、破坏、放置、耐久回城、用船过水 =====
|
||||||
int checkY1 = this.advancedY + 14;
|
int checkY1 = this.advancedY + 14;
|
||||||
int checkY2 = checkY1 + advCheckGap;
|
int checkY2 = checkY1 + advCheckGap;
|
||||||
|
int checkY3 = checkY2 + advCheckGap;
|
||||||
|
int checkY4 = checkY3 + advCheckGap;
|
||||||
int checkX = left;
|
int checkX = left;
|
||||||
this.sprintBox = this.checkbox("\u75be\u8dd1", checkX, checkY1, this.config.allowSprint, value -> {
|
this.sprintBox = this.checkbox("\u75be\u8dd1", checkX, checkY1, this.config.allowSprint, value -> {
|
||||||
this.config.allowSprint = value;
|
this.config.allowSprint = value;
|
||||||
@@ -155,17 +161,34 @@ extends Screen {
|
|||||||
this.avoidBox = this.checkbox("\u907f\u602a", checkX + 160, checkY1, this.config.avoidMobs, value -> {
|
this.avoidBox = this.checkbox("\u907f\u602a", checkX + 160, checkY1, this.config.avoidMobs, value -> {
|
||||||
this.config.avoidMobs = value;
|
this.config.avoidMobs = value;
|
||||||
});
|
});
|
||||||
this.breakBox = this.checkbox("\u7834\u574f", checkX, checkY2, this.config.allowBreak, value -> {
|
this.attackBox = this.checkbox("\u653b\u51fb", checkX, checkY2, this.config.autoAttack, value -> {
|
||||||
|
this.config.autoAttack = value;
|
||||||
|
});
|
||||||
|
this.breakBox = this.checkbox("\u7834\u574f", checkX + 88, checkY2, this.config.allowBreak, value -> {
|
||||||
this.config.allowBreak = value;
|
this.config.allowBreak = value;
|
||||||
});
|
});
|
||||||
this.placeBox = this.checkbox("\u653e\u7f6e", checkX + 88, checkY2, this.config.allowPlace, value -> {
|
this.placeBox = this.checkbox("\u653e\u7f6e", checkX + 160, checkY2, this.config.allowPlace, value -> {
|
||||||
this.config.allowPlace = value;
|
this.config.allowPlace = value;
|
||||||
});
|
});
|
||||||
|
// 第三行:耐久保护开关 + 回城阈值输入框(剩余耐久百分比,范围 1-99)
|
||||||
|
this.durabilityGuardBox = this.checkbox("\u8010\u4e45\u56de\u57ce", checkX, checkY3, this.config.durabilityGuard, value -> {
|
||||||
|
this.config.durabilityGuard = value;
|
||||||
|
});
|
||||||
|
this.durabilityField = this.numberField(this.config.durabilityThreshold, checkX + 96, checkY3, 34);
|
||||||
|
this.durabilityField.setMaxLength(2);
|
||||||
|
// 第四行:自动用船过水(开阔水域自动放船登船,上岸前自动下船回收)
|
||||||
|
this.autoBoatBox = this.checkbox("\u7528\u8239\u8fc7\u6c34", checkX, checkY4, this.config.autoBoat, value -> {
|
||||||
|
this.config.autoBoat = value;
|
||||||
|
});
|
||||||
this.addDrawableChild(this.sprintBox);
|
this.addDrawableChild(this.sprintBox);
|
||||||
this.addDrawableChild(this.jumpBox);
|
this.addDrawableChild(this.jumpBox);
|
||||||
this.addDrawableChild(this.avoidBox);
|
this.addDrawableChild(this.avoidBox);
|
||||||
|
this.addDrawableChild(this.attackBox);
|
||||||
this.addDrawableChild(this.breakBox);
|
this.addDrawableChild(this.breakBox);
|
||||||
this.addDrawableChild(this.placeBox);
|
this.addDrawableChild(this.placeBox);
|
||||||
|
this.addDrawableChild(this.durabilityGuardBox);
|
||||||
|
this.addDrawableChild(this.durabilityField);
|
||||||
|
this.addDrawableChild(this.autoBoatBox);
|
||||||
this.initializeMapView();
|
this.initializeMapView();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +499,12 @@ extends Screen {
|
|||||||
context.drawTextWithShadow(this.textRenderer, "\u90a6\u56fd", this.panelX + 4, this.stateFieldY + 4, -5592406);
|
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, "\u76ee\u6807\u6570\u91cf", this.panelX + 4, this.targetFieldY + 4, -5592406);
|
||||||
context.drawTextWithShadow(this.textRenderer, "\u6743\u9650\u8bbe\u7f6e", this.panelX + 4, this.advancedY, -5592406);
|
context.drawTextWithShadow(this.textRenderer, "\u6743\u9650\u8bbe\u7f6e", this.panelX + 4, this.advancedY, -5592406);
|
||||||
|
// 耐久回城阈值输入框的单位标签(位于输入框右侧)
|
||||||
|
if (this.durabilityField != null) {
|
||||||
|
context.drawTextWithShadow(this.textRenderer, "\u8010\u4e45%",
|
||||||
|
this.durabilityField.getX() + this.durabilityField.getWidth() + 4,
|
||||||
|
this.durabilityField.getY() + 6, -5592406);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 底部信息面板:日志(左)+ 状态(右),均单行截断,避免换行导致的重叠 =====
|
// ===== 底部信息面板:日志(左)+ 状态(右),均单行截断,避免换行导致的重叠 =====
|
||||||
int infoTop = this.mapY + this.mapH + 10;
|
int infoTop = this.mapY + this.mapH + 10;
|
||||||
@@ -534,26 +563,53 @@ extends Screen {
|
|||||||
/** 生成地图刷新状态文本(显示上次成功刷新距今的时间) */
|
/** 生成地图刷新状态文本(显示上次成功刷新距今的时间) */
|
||||||
private String refreshStatusText() {
|
private String refreshStatusText() {
|
||||||
long last = this.mod.dataService().lastSuccessfulFetchMillis();
|
long last = this.mod.dataService().lastSuccessfulFetchMillis();
|
||||||
|
String mapText;
|
||||||
if (last <= 0) {
|
if (last <= 0) {
|
||||||
return "\u5730\u56fe\u672a\u5237\u65b0";
|
mapText = "\u5730\u56fe\u672a\u5237\u65b0";
|
||||||
}
|
} else {
|
||||||
long seconds = (System.currentTimeMillis() - last) / 1000L;
|
long seconds = (System.currentTimeMillis() - last) / 1000L;
|
||||||
if (seconds < 60) {
|
mapText = seconds < 60 ? "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + seconds + "\u79d2\u524d" : "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + (seconds / 60) + "\u5206\u949f\u524d";
|
||||||
return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + seconds + "\u79d2\u524d";
|
|
||||||
}
|
}
|
||||||
return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + (seconds / 60) + "\u5206\u949f\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 mapText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自动识别玩家当前所在邦国并填入邦国输入框 */
|
/** 自动识别玩家当前所在邦国并填入邦国输入框(Dynmap 未覆盖时回退 API 按玩家名识别) */
|
||||||
private void autoState() {
|
private void autoState() {
|
||||||
TerritoryState auto = this.mod.dataService().model().findContainingState(this.playerChunk());
|
TerritoryState auto = this.mod.dataService().model().findContainingState(this.playerChunk());
|
||||||
if (auto == null) {
|
if (auto != null) {
|
||||||
this.config.stateName = "";
|
|
||||||
this.stateField.setText("");
|
|
||||||
} else {
|
|
||||||
this.config.stateName = auto.name();
|
this.config.stateName = auto.name();
|
||||||
this.stateField.setText(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 配置并启动扩地 */
|
/** 点击"开始"按钮:解析输入到配置,保存到 mod 配置并启动扩地 */
|
||||||
@@ -572,8 +628,14 @@ extends Screen {
|
|||||||
this.config.allowSprint = this.sprintBox.isChecked();
|
this.config.allowSprint = this.sprintBox.isChecked();
|
||||||
this.config.allowJump = this.jumpBox.isChecked();
|
this.config.allowJump = this.jumpBox.isChecked();
|
||||||
this.config.avoidMobs = this.avoidBox.isChecked();
|
this.config.avoidMobs = this.avoidBox.isChecked();
|
||||||
|
this.config.autoAttack = this.attackBox.isChecked();
|
||||||
this.config.allowBreak = this.breakBox.isChecked();
|
this.config.allowBreak = this.breakBox.isChecked();
|
||||||
this.config.allowPlace = this.placeBox.isChecked();
|
this.config.allowPlace = this.placeBox.isChecked();
|
||||||
|
this.config.durabilityGuard = this.durabilityGuardBox.isChecked();
|
||||||
|
this.config.autoBoat = this.autoBoatBox.isChecked();
|
||||||
|
int threshold = this.parseInt(this.durabilityField.getText(), 15);
|
||||||
|
// 钳制阈值到 1-99,避免 0(永不触发)或 100(刚装备就回城)
|
||||||
|
this.config.durabilityThreshold = Math.max(1, Math.min(99, threshold));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将本地配置副本同步到 mod 全局配置并保存到磁盘 */
|
/** 将本地配置副本同步到 mod 全局配置并保存到磁盘 */
|
||||||
@@ -585,8 +647,12 @@ extends Screen {
|
|||||||
target.allowSprint = this.config.allowSprint;
|
target.allowSprint = this.config.allowSprint;
|
||||||
target.allowJump = this.config.allowJump;
|
target.allowJump = this.config.allowJump;
|
||||||
target.avoidMobs = this.config.avoidMobs;
|
target.avoidMobs = this.config.avoidMobs;
|
||||||
|
target.autoAttack = this.config.autoAttack;
|
||||||
target.allowBreak = this.config.allowBreak;
|
target.allowBreak = this.config.allowBreak;
|
||||||
target.allowPlace = this.config.allowPlace;
|
target.allowPlace = this.config.allowPlace;
|
||||||
|
target.durabilityGuard = this.config.durabilityGuard;
|
||||||
|
target.durabilityThreshold = this.config.durabilityThreshold;
|
||||||
|
target.autoBoat = this.config.autoBoat;
|
||||||
// 同步矩形圈选坐标到全局配置(修复:之前忘记同步,导致区域扩地总是为空)
|
// 同步矩形圈选坐标到全局配置(修复:之前忘记同步,导致区域扩地总是为空)
|
||||||
target.regionMinX = this.config.regionMinX;
|
target.regionMinX = this.config.regionMinX;
|
||||||
target.regionMaxX = this.config.regionMaxX;
|
target.regionMaxX = this.config.regionMaxX;
|
||||||
|
|||||||
@@ -0,0 +1,652 @@
|
|||||||
|
/*
|
||||||
|
* AutoStore 模组自动用船过水辅助:检测路径前方水域自动放船登船、上岸前自动下船回收。
|
||||||
|
*
|
||||||
|
* <p>仅在扩地任务运行中({@link ExpansionController#isRunning()} == true),
|
||||||
|
* 且配置开启 {@link ExpansionConfig#autoBoat} 时生效。
|
||||||
|
* 设计上以"最小侵入"为原则:不主动取消/重排 Baritone 寻路,不改变状态机,
|
||||||
|
* 只在合适时机调用 Minecraft 交互接口({@code interactBlock} / {@code interactEntity}),
|
||||||
|
* 以及按下/释放蹲伏键完成上下船。</p>
|
||||||
|
*
|
||||||
|
* <p>关键时序:
|
||||||
|
* <ul>
|
||||||
|
* <li>登船(每帧):玩家在岸上 → 朝向方向前方出现连续开阔水域(宽度 ≥
|
||||||
|
* {@link ExpansionConfig#autoBoatMinWidth}、长度 ≥
|
||||||
|
* {@link ExpansionConfig#autoBoatMinLength})→ 背包有任意船 → 切到主手放船并登船;</li>
|
||||||
|
* <li>上岸(每帧):玩家在船中且朝向上前方出现可站立方块 → 蹲伏下船并攻击船实体回收船。</li>
|
||||||
|
* </ul>
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
package autostore.client.navigation;
|
||||||
|
|
||||||
|
import autostore.client.config.ExpansionConfig;
|
||||||
|
import net.fabricmc.api.EnvType;
|
||||||
|
import net.fabricmc.api.Environment;
|
||||||
|
import net.minecraft.block.Blocks;
|
||||||
|
import net.minecraft.client.MinecraftClient;
|
||||||
|
import net.minecraft.client.option.KeyBinding;
|
||||||
|
import net.minecraft.entity.Entity;
|
||||||
|
import net.minecraft.entity.player.PlayerEntity;
|
||||||
|
import net.minecraft.entity.vehicle.AbstractBoatEntity;
|
||||||
|
import net.minecraft.item.ItemStack;
|
||||||
|
import net.minecraft.registry.Registries;
|
||||||
|
import net.minecraft.util.Hand;
|
||||||
|
import net.minecraft.util.Identifier;
|
||||||
|
import net.minecraft.util.hit.BlockHitResult;
|
||||||
|
import net.minecraft.util.math.BlockPos;
|
||||||
|
import net.minecraft.util.math.Direction;
|
||||||
|
import net.minecraft.util.math.MathHelper;
|
||||||
|
import net.minecraft.util.math.Vec3d;
|
||||||
|
import net.minecraft.world.World;
|
||||||
|
|
||||||
|
@Environment(EnvType.CLIENT)
|
||||||
|
/**
|
||||||
|
* 自动用船过水辅助(与 Baritone 寻路配合)。
|
||||||
|
*
|
||||||
|
* <p>本类只负责在合适时机插桩"放船/登船/下船/回收船"四个动作,
|
||||||
|
* 路径规划与开垦逻辑仍由 {@link ExpansionController} + Baritone 完成。
|
||||||
|
* 进出船流程会临时按住蹲伏键,阻止 Baritone 把玩家推进水里或撞上船,
|
||||||
|
* 流程结束后释放蹲伏键,Baritone 自动恢复正常寻路。</p>
|
||||||
|
*/
|
||||||
|
public final class AutoBoatController {
|
||||||
|
|
||||||
|
/** 进入船阶段:等待玩家进入水面时调用一次交互(在玩家脚下的水面方块上放船) */
|
||||||
|
private static final int PHASE_PLACE = 0;
|
||||||
|
/** 放船完成后等待若干 tick 再尝试登船(给服务器时间确认实体生成) */
|
||||||
|
private static final int PHASE_PLACE_WAIT = 1;
|
||||||
|
/** 登船阶段:寻找最近的可登船实体并调用 interactEntity */
|
||||||
|
private static final int PHASE_BOARD = 2;
|
||||||
|
/** 登船后等待若干 tick,让 Baritone 与服务器同步位置 */
|
||||||
|
private static final int PHASE_BOARD_WAIT = 3;
|
||||||
|
|
||||||
|
/** 上岸阶段:先按一次蹲伏键下船(持续按住一段时间,确保服务器收到下船) */
|
||||||
|
private static final int PHASE_DISMOUNT_PRESS = 4;
|
||||||
|
/** 下船后等待若干 tick,让服务器同步状态 */
|
||||||
|
private static final int PHASE_DISMOUNT_WAIT = 5;
|
||||||
|
/** 攻击船实体回收船(直到船从世界中消失) */
|
||||||
|
private static final int PHASE_BREAK_BOAT = 6;
|
||||||
|
/** 完成后等待若干 tick 再恢复 Baritone 寻路 */
|
||||||
|
private static final int PHASE_BREAK_WAIT = 7;
|
||||||
|
|
||||||
|
/** 当前阶段(详见常量定义) */
|
||||||
|
private int phase = PHASE_PLACE;
|
||||||
|
/** 当前阶段已等待的 tick 数 */
|
||||||
|
private int phaseTicks;
|
||||||
|
/** 上次保存的玩家主手槽位(完成后恢复) */
|
||||||
|
private int savedSelectedSlot = -1;
|
||||||
|
/** 进入船阶段:玩家脚下水面方块(用于重新计算登船点) */
|
||||||
|
private BlockPos placeTarget;
|
||||||
|
/** 进入船阶段:登船目标实体 ID(用于在 BOARD 阶段精确定位) */
|
||||||
|
private int boardTargetEntityId = -1;
|
||||||
|
/** 下船阶段:船实体 ID(用于 BREAK 阶段精确定位) */
|
||||||
|
private int boatEntityId = -1;
|
||||||
|
/** 当前阶段最近一次日志(用于去重,避免每帧刷屏) */
|
||||||
|
private String lastLoggedPhase;
|
||||||
|
/** 登船流程是否正在按住蹲伏键以阻止 Baritone 把玩家推进水里 */
|
||||||
|
private boolean holdingSneak;
|
||||||
|
|
||||||
|
/** 状态机:当前未做任何事 */
|
||||||
|
private static final int IDLE = -1;
|
||||||
|
/** 当前是进入船流程 */
|
||||||
|
private static final int FLOW_ENTER = 1;
|
||||||
|
/** 当前是上岸流程 */
|
||||||
|
private static final int FLOW_EXIT = 2;
|
||||||
|
private int flow = IDLE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每帧调用入口:根据当前玩家状态决定是否触发登船或上岸流程;
|
||||||
|
* 已经处于某个流程中时推进阶段。
|
||||||
|
*
|
||||||
|
* @param client 客户端
|
||||||
|
* @param config 扩地配置(用于读取 autoBoat/最小宽度/最短长度)
|
||||||
|
* @param isRunning 当前扩地任务是否在运行(PAUSE/COMPLETE/STOPPED 时跳过)
|
||||||
|
*/
|
||||||
|
public void tick(MinecraftClient client, ExpansionConfig config, boolean isRunning) {
|
||||||
|
if (client == null || client.player == null || client.world == null || client.interactionManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 配置未启用或任务未运行:清理状态(如有进行中的流程,强行取消并释放蹲伏键)
|
||||||
|
if (!isRunning || !config.autoBoat) {
|
||||||
|
this.reset(client, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 自动攻击、自动进食、自动游泳、走两步期间:让位(避免按键冲突)
|
||||||
|
if (this.flow == IDLE) {
|
||||||
|
// 无进行中流程:根据玩家状态决定是否需要登船 / 上岸
|
||||||
|
Entity vehicle = client.player.getVehicle();
|
||||||
|
if (vehicle instanceof AbstractBoatEntity) {
|
||||||
|
// 已在船中:检测前方是否即将到达岸边
|
||||||
|
if (this.shouldDisembark(client, config)) {
|
||||||
|
this.beginDisembark(client, (AbstractBoatEntity) vehicle);
|
||||||
|
}
|
||||||
|
} else if (this.shouldBoardBoat(client, config)) {
|
||||||
|
// 在岸上且前方有开阔水域:尝试登船
|
||||||
|
this.beginBoard(client);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 有进行中流程:推进阶段
|
||||||
|
switch (this.flow) {
|
||||||
|
case FLOW_ENTER -> this.tickEnter(client);
|
||||||
|
case FLOW_EXIT -> this.tickExit(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制停止所有动作(如任务被手动停止时) */
|
||||||
|
public void reset(MinecraftClient client, boolean silent) {
|
||||||
|
if (this.flow == IDLE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 释放蹲伏键(避免卡住"按住"状态)
|
||||||
|
if (this.holdingSneak) {
|
||||||
|
this.releaseSneak(client);
|
||||||
|
this.holdingSneak = false;
|
||||||
|
}
|
||||||
|
// 还原主手槽位(如有暂存)
|
||||||
|
this.restoreSelectedSlot(client);
|
||||||
|
this.flow = IDLE;
|
||||||
|
this.phase = PHASE_PLACE;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
this.placeTarget = null;
|
||||||
|
this.boardTargetEntityId = -1;
|
||||||
|
this.boatEntityId = -1;
|
||||||
|
this.lastLoggedPhase = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 当前是否处于进行中流程(用于状态栏显示) */
|
||||||
|
public boolean isActive() {
|
||||||
|
return this.flow != IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 当前流程的可读描述(用于状态/日志) */
|
||||||
|
public String describe() {
|
||||||
|
return switch (this.flow) {
|
||||||
|
case FLOW_ENTER -> "正在自动登船";
|
||||||
|
case FLOW_EXIT -> "正在上岸回收船";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================== 登船判断 ===========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否应该登船:
|
||||||
|
* 玩家在岸上或浅水中(脚下为可站立方块、或脚下为水/睡莲方块且头部未浸水)、
|
||||||
|
* 朝向方向前方出现连续开阔水域(宽度 ≥ 配置阈值、长度 ≥ 配置阈值)、
|
||||||
|
* 背包里有任意船物品。
|
||||||
|
*/
|
||||||
|
private boolean shouldBoardBoat(MinecraftClient client, ExpansionConfig config) {
|
||||||
|
PlayerEntity player = client.player;
|
||||||
|
// 玩家完全没入水中:暂不处理,等 Baritone 处理上岸(或玩家主动下船)
|
||||||
|
if (player.isSubmergedInWater()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 玩家脚下为水方块(浅水行走)或睡莲:也允许尝试放船
|
||||||
|
BlockPos playerPos = player.getBlockPos();
|
||||||
|
if (!isStandable(client.world, playerPos) && !this.isWaterAt(client.world, playerPos)
|
||||||
|
&& !client.world.getBlockState(playerPos).isOf(Blocks.LILY_PAD)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 背包里没有船
|
||||||
|
if (this.findBoatSlot(player) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 朝向方向前方水域扫描:从玩家脚部位置按 yaw 方向扫描
|
||||||
|
Vec3d dir = this.forwardHorizontal(player);
|
||||||
|
if (dir == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int minWidth = Math.max(2, config.autoBoatMinWidth);
|
||||||
|
int minLength = Math.max(minWidth, config.autoBoatMinLength);
|
||||||
|
// 在垂直方向(-1/0/1 格)共采样三条平行线,取宽度最窄者作为判定值——避免窄水渠被误判
|
||||||
|
int width = this.measureWaterWidth(client, playerPos, dir, minWidth);
|
||||||
|
if (width < minWidth) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 沿朝向方向连续水方块长度
|
||||||
|
int length = this.measureWaterLength(client, playerPos, dir, minLength);
|
||||||
|
return length >= minLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断是否应该下船:朝向上前方出现可站立方块(岸边) */
|
||||||
|
private boolean shouldDisembark(MinecraftClient client, ExpansionConfig config) {
|
||||||
|
PlayerEntity player = client.player;
|
||||||
|
Vec3d dir = this.forwardHorizontal(player);
|
||||||
|
if (dir == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 沿玩家朝向方向逐格扫描,遇到第一个可站立方块即视为即将上岸
|
||||||
|
BlockPos start = player.getBlockPos();
|
||||||
|
for (int step = 1; step <= 8; ++step) {
|
||||||
|
int bx = (int)Math.round(start.getX() + dir.x * step);
|
||||||
|
int bz = (int)Math.round(start.getZ() + dir.z * step);
|
||||||
|
// 玩家身高 1.8,需要 y/y+1 都不被实心方块占据
|
||||||
|
BlockPos feet = new BlockPos(bx, start.getY(), bz);
|
||||||
|
BlockPos head = feet.up();
|
||||||
|
if (isStandable(client.world, feet) && isAirOrPassable(client.world, head)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 玩家在水平面上的朝向单位向量(忽略 Y) */
|
||||||
|
private Vec3d forwardHorizontal(PlayerEntity player) {
|
||||||
|
float yaw = player.getYaw();
|
||||||
|
// 与 ExpansionController#tickSideStep 一致:yaw 是水平角,转为方向向量
|
||||||
|
double rad = Math.toRadians(yaw);
|
||||||
|
double fx = -Math.sin(rad);
|
||||||
|
double fz = Math.cos(rad);
|
||||||
|
double len = Math.sqrt(fx * fx + fz * fz);
|
||||||
|
if (len < 1.0e-3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Vec3d(fx / len, 0.0, fz / len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测量沿指定方向在玩家脚下 y 平面上"连续水方块的最大宽度"。
|
||||||
|
* 从玩家前方第一格开始,沿主方向前进,每步同时检查主方向左右两侧是否也是水,
|
||||||
|
* 直到左右均不再是水为止。返回"至少左右各有 (count-1) 格水"的最大 width。
|
||||||
|
*/
|
||||||
|
private int measureWaterWidth(MinecraftClient client, BlockPos playerPos, Vec3d dir, int maxSample) {
|
||||||
|
// 取主方向的左右向量(顺时针 90°)
|
||||||
|
double lx = -dir.z;
|
||||||
|
double lz = dir.x;
|
||||||
|
int best = 0;
|
||||||
|
for (int step = 1; step <= maxSample; ++step) {
|
||||||
|
int cx = (int)Math.round(playerPos.getX() + dir.x * step);
|
||||||
|
int cz = (int)Math.round(playerPos.getZ() + dir.z * step);
|
||||||
|
// 沿左右两侧各扩展最多 8 格
|
||||||
|
int leftWidth = 0;
|
||||||
|
for (int side = 1; side <= 8; ++side) {
|
||||||
|
int sx = (int)Math.round(cx + lx * side);
|
||||||
|
int sz = (int)Math.round(cz + lz * side);
|
||||||
|
BlockPos p = new BlockPos(sx, playerPos.getY(), sz);
|
||||||
|
if (this.isWaterAt(client.world, p)) {
|
||||||
|
leftWidth = side;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int rightWidth = 0;
|
||||||
|
for (int side = 1; side <= 8; ++side) {
|
||||||
|
int sx = (int)Math.round(cx - lx * side);
|
||||||
|
int sz = (int)Math.round(cz - lz * side);
|
||||||
|
BlockPos p = new BlockPos(sx, playerPos.getY(), sz);
|
||||||
|
if (this.isWaterAt(client.world, p)) {
|
||||||
|
rightWidth = side;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int width = 1 + leftWidth + rightWidth;
|
||||||
|
if (width > best) {
|
||||||
|
best = width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 沿主方向连续水方块的长度(玩家高度 ±1 的可站立格也算"岸边") */
|
||||||
|
private int measureWaterLength(MinecraftClient client, BlockPos playerPos, Vec3d dir, int maxSample) {
|
||||||
|
int length = 0;
|
||||||
|
for (int step = 1; step <= 32; ++step) {
|
||||||
|
int cx = (int)Math.round(playerPos.getX() + dir.x * step);
|
||||||
|
int cz = (int)Math.round(playerPos.getZ() + dir.z * step);
|
||||||
|
BlockPos p = new BlockPos(cx, playerPos.getY(), cz);
|
||||||
|
if (this.isWaterAt(client.world, p)) {
|
||||||
|
length = step;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 玩家脚下 y 不是水,但同 x/z 在 ±1 y 出现可站立方块 → 也视为水域末端即将上岸
|
||||||
|
if (isStandable(client.world, p) || isStandable(client.world, p.up()) || isStandable(client.world, p.down())) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 都不满足则中断
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定坐标方块是否为水(含含水方块/植物/含水流体的方块) */
|
||||||
|
private boolean isWaterAt(World world, BlockPos pos) {
|
||||||
|
var state = world.getBlockState(pos);
|
||||||
|
return state.isOf(Blocks.WATER)
|
||||||
|
|| state.isOf(Blocks.BUBBLE_COLUMN)
|
||||||
|
|| state.isOf(Blocks.KELP)
|
||||||
|
|| state.isOf(Blocks.KELP_PLANT)
|
||||||
|
|| state.isOf(Blocks.SEAGRASS)
|
||||||
|
|| state.isOf(Blocks.TALL_SEAGRASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 玩家脚下"可站立"方块:实心方块且上方是空气/可穿过 */
|
||||||
|
private boolean isStandable(World world, BlockPos pos) {
|
||||||
|
var state = world.getBlockState(pos);
|
||||||
|
if (state.isAir()) return false;
|
||||||
|
if (state.getCollisionShape(world, pos).isEmpty()) return false;
|
||||||
|
return isAirOrPassable(world, pos.up());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 玩家头部位置可穿过(空气/草丛等无碰撞) */
|
||||||
|
private boolean isAirOrPassable(World world, BlockPos pos) {
|
||||||
|
var state = world.getBlockState(pos);
|
||||||
|
if (state.isAir()) return true;
|
||||||
|
return state.getCollisionShape(world, pos).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================== 登船流程 ===========================
|
||||||
|
|
||||||
|
private void beginBoard(MinecraftClient client) {
|
||||||
|
this.flow = FLOW_ENTER;
|
||||||
|
this.phase = PHASE_PLACE;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
this.placeTarget = this.findWaterInFront(client);
|
||||||
|
this.boardTargetEntityId = -1;
|
||||||
|
this.lastLoggedPhase = null;
|
||||||
|
// 暂存当前主手槽位,结束后还原
|
||||||
|
this.savedSelectedSlot = client.player.getInventory().selectedSlot;
|
||||||
|
// 按住蹲伏键以阻止 Baritone 把玩家推进水里(也避免登船后立即被推动离开船)
|
||||||
|
this.pressSneak(client);
|
||||||
|
this.holdingSneak = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tickEnter(MinecraftClient client) {
|
||||||
|
switch (this.phase) {
|
||||||
|
case PHASE_PLACE -> this.enterPlace(client);
|
||||||
|
case PHASE_PLACE_WAIT -> this.enterPlaceWait(client);
|
||||||
|
case PHASE_BOARD -> this.enterBoard(client);
|
||||||
|
case PHASE_BOARD_WAIT -> this.enterBoardWait(client);
|
||||||
|
default -> this.reset(client, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PHASE_PLACE:玩家脚下方块必须是水(站在岸边浅水处),在脚下方块上放船 */
|
||||||
|
private void enterPlace(MinecraftClient client) {
|
||||||
|
PlayerEntity player = client.player;
|
||||||
|
if (this.placeTarget == null) {
|
||||||
|
this.placeTarget = this.findWaterInFront(client);
|
||||||
|
}
|
||||||
|
// 若玩家当前站在陆地:仍允许(船会被放在脚前的水方块上)
|
||||||
|
BlockPos waterPos = this.placeTarget != null ? this.placeTarget : player.getBlockPos();
|
||||||
|
if (!this.isWaterAt(client.world, waterPos)) {
|
||||||
|
// 没有合适的水方块 → 取消
|
||||||
|
this.reset(client, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int slot = this.findBoatSlot(player);
|
||||||
|
if (slot < 0) {
|
||||||
|
this.reset(client, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 切换主手到船物品并放置
|
||||||
|
player.getInventory().selectedSlot = slot;
|
||||||
|
BlockHitResult hit = new BlockHitResult(
|
||||||
|
Vec3d.ofCenter(waterPos).add(0, 0.5, 0),
|
||||||
|
Direction.UP,
|
||||||
|
waterPos,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
client.interactionManager.interactBlock(client.player, Hand.MAIN_HAND, hit);
|
||||||
|
this.logPhaseOnce(client, "已放船于 " + waterPos.getX() + "," + waterPos.getY() + "," + waterPos.getZ());
|
||||||
|
this.phase = PHASE_PLACE_WAIT;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PHASE_PLACE_WAIT:等待 3 tick 让服务器确认船实体生成 */
|
||||||
|
private void enterPlaceWait(MinecraftClient client) {
|
||||||
|
this.phaseTicks++;
|
||||||
|
if (this.phaseTicks < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 在附近寻找新放置的船
|
||||||
|
AbstractBoatEntity boat = this.findNearbyBoat(client, 6.0);
|
||||||
|
if (boat == null) {
|
||||||
|
// 没找到:可能服务器拒收 / 反作弊拦截 → 取消
|
||||||
|
this.logPhaseOnce(client, "放船后未找到船实体,取消登船");
|
||||||
|
this.reset(client, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.boardTargetEntityId = boat.getId();
|
||||||
|
this.phase = PHASE_BOARD;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PHASE_BOARD:找到目标船实体并 right-click 上船 */
|
||||||
|
private void enterBoard(MinecraftClient client) {
|
||||||
|
AbstractBoatEntity boat = this.findBoatById(client, this.boardTargetEntityId);
|
||||||
|
if (boat == null) {
|
||||||
|
// 实体可能已被销毁:再扫一次附近
|
||||||
|
boat = this.findNearbyBoat(client, 4.0);
|
||||||
|
if (boat == null) {
|
||||||
|
this.reset(client, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.boardTargetEntityId = boat.getId();
|
||||||
|
}
|
||||||
|
// 上船:调用 interactEntity(玩家 main hand)
|
||||||
|
client.interactionManager.interactEntity(client.player, boat, Hand.MAIN_HAND);
|
||||||
|
this.logPhaseOnce(client, "已登船");
|
||||||
|
this.phase = PHASE_BOARD_WAIT;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PHASE_BOARD_WAIT:等待服务器确认玩家已在船中 */
|
||||||
|
private void enterBoardWait(MinecraftClient client) {
|
||||||
|
this.phaseTicks++;
|
||||||
|
if (this.phaseTicks < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(client.player.getVehicle() instanceof AbstractBoatEntity)) {
|
||||||
|
// 登船失败 → 取消
|
||||||
|
this.reset(client, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 释放蹲伏键,Baritone 恢复自动寻路(此时玩家在船中,按 W 即可让船前进)
|
||||||
|
this.releaseSneak(client);
|
||||||
|
this.holdingSneak = false;
|
||||||
|
this.logPhaseOnce(client, "自动登船完成,开始过水");
|
||||||
|
this.flow = IDLE;
|
||||||
|
this.phase = PHASE_PLACE;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
this.placeTarget = null;
|
||||||
|
this.boardTargetEntityId = -1;
|
||||||
|
// 还原主手槽位(让 Baritone 自由切回所需工具)
|
||||||
|
this.restoreSelectedSlot(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 玩家正前方 1 格内的水面方块(脚下优先);找不到返回 null */
|
||||||
|
private BlockPos findWaterInFront(MinecraftClient client) {
|
||||||
|
Vec3d dir = this.forwardHorizontal(client.player);
|
||||||
|
if (dir == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
BlockPos playerPos = client.player.getBlockPos();
|
||||||
|
for (int step = 0; step <= 2; ++step) {
|
||||||
|
int cx = (int)Math.round(playerPos.getX() + dir.x * step);
|
||||||
|
int cz = (int)Math.round(playerPos.getZ() + dir.z * step);
|
||||||
|
BlockPos p = new BlockPos(cx, playerPos.getY(), cz);
|
||||||
|
if (this.isWaterAt(client.world, p)) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
// 退一步:玩家脚下方块向下找水
|
||||||
|
BlockPos below = p.down();
|
||||||
|
if (this.isWaterAt(client.world, below)) {
|
||||||
|
return below;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查找背包里第一个"船类"物品(含 chest_boat)的槽位;找不到返回 -1 */
|
||||||
|
private int findBoatSlot(PlayerEntity player) {
|
||||||
|
var inventory = player.getInventory();
|
||||||
|
for (int slot = 0; slot < inventory.size(); ++slot) {
|
||||||
|
ItemStack stack = inventory.getStack(slot);
|
||||||
|
if (stack.isEmpty()) continue;
|
||||||
|
Identifier id = Registries.ITEM.getId(stack.getItem());
|
||||||
|
if (id == null) continue;
|
||||||
|
String path = id.getPath();
|
||||||
|
if (path.endsWith("_boat") || path.endsWith("_chest_boat") || path.equals("boat") || path.equals("chest_boat")) {
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 还原玩家主手槽位(进入船前保存的) */
|
||||||
|
private void restoreSelectedSlot(MinecraftClient client) {
|
||||||
|
if (this.savedSelectedSlot >= 0 && client.player != null) {
|
||||||
|
client.player.getInventory().selectedSlot = this.savedSelectedSlot;
|
||||||
|
}
|
||||||
|
this.savedSelectedSlot = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================== 上岸流程 ===========================
|
||||||
|
|
||||||
|
private void beginDisembark(MinecraftClient client, AbstractBoatEntity boat) {
|
||||||
|
this.flow = FLOW_EXIT;
|
||||||
|
this.phase = PHASE_DISMOUNT_PRESS;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
this.boatEntityId = boat.getId();
|
||||||
|
this.lastLoggedPhase = null;
|
||||||
|
// 暂存主手槽位(攻击船后还原)
|
||||||
|
this.savedSelectedSlot = client.player.getInventory().selectedSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tickExit(MinecraftClient client) {
|
||||||
|
switch (this.phase) {
|
||||||
|
case PHASE_DISMOUNT_PRESS -> this.exitPressSneak(client);
|
||||||
|
case PHASE_DISMOUNT_WAIT -> this.exitWaitDismount(client);
|
||||||
|
case PHASE_BREAK_BOAT -> this.exitBreakBoat(client);
|
||||||
|
case PHASE_BREAK_WAIT -> this.exitBreakWait(client);
|
||||||
|
default -> this.reset(client, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按住蹲伏键一段时间(≥3 tick),触发玩家从船中下船 */
|
||||||
|
private void exitPressSneak(MinecraftClient client) {
|
||||||
|
this.pressSneak(client);
|
||||||
|
this.holdingSneak = true;
|
||||||
|
this.phaseTicks++;
|
||||||
|
if (this.phaseTicks < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 玩家下船后再释放蹲伏键也会被 BREAK_BOAT/BREAK_WAIT 阶段重新按住
|
||||||
|
this.releaseSneak(client);
|
||||||
|
this.holdingSneak = false;
|
||||||
|
this.phase = PHASE_DISMOUNT_WAIT;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等待玩家从船中分离出来 */
|
||||||
|
private void exitWaitDismount(MinecraftClient client) {
|
||||||
|
this.phaseTicks++;
|
||||||
|
if (this.phaseTicks < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 玩家已下船:进入攻击船阶段
|
||||||
|
this.phase = PHASE_BREAK_BOAT;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 攻击船实体回收船(最多 20 tick;持续到船被销毁或超时);
|
||||||
|
* 整个阶段按住蹲伏键以阻止 Baritone 在破坏船时把玩家带离船边。 */
|
||||||
|
private void exitBreakBoat(MinecraftClient client) {
|
||||||
|
if (!this.holdingSneak) {
|
||||||
|
this.pressSneak(client);
|
||||||
|
this.holdingSneak = true;
|
||||||
|
}
|
||||||
|
AbstractBoatEntity boat = this.findBoatById(client, this.boatEntityId);
|
||||||
|
if (boat == null || boat.isRemoved()) {
|
||||||
|
// 船已销毁(可能玩家已捡起 / 服务器回收)
|
||||||
|
this.phase = PHASE_BREAK_WAIT;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 必须面朝船才能命中(服务端通常校验朝向)
|
||||||
|
Vec3d playerEye = client.player.getEyePos();
|
||||||
|
Vec3d toBoat = boat.getPos().add(0.0, boat.getHeight() * 0.5, 0.0).subtract(playerEye);
|
||||||
|
double horizontal = Math.sqrt(toBoat.x * toBoat.x + toBoat.z * toBoat.z);
|
||||||
|
float yaw = (float) (Math.toDegrees(Math.atan2(toBoat.z, toBoat.x)) - 90.0);
|
||||||
|
float pitch = (float) (-Math.toDegrees(Math.atan2(toBoat.y, horizontal)));
|
||||||
|
client.player.setYaw(yaw);
|
||||||
|
client.player.setPitch(MathHelper.clamp(pitch, -90.0f, 90.0f));
|
||||||
|
client.interactionManager.attackEntity(client.player, boat);
|
||||||
|
this.phaseTicks++;
|
||||||
|
if (this.phaseTicks > 20) {
|
||||||
|
// 超时:放弃回收,直接进入下一步
|
||||||
|
this.phase = PHASE_BREAK_WAIT;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收尾:等待船销毁完成 + 释放蹲伏键让 Baritone 恢复正常寻路 */
|
||||||
|
private void exitBreakWait(MinecraftClient client) {
|
||||||
|
if (this.holdingSneak) {
|
||||||
|
this.releaseSneak(client);
|
||||||
|
this.holdingSneak = false;
|
||||||
|
}
|
||||||
|
this.phaseTicks++;
|
||||||
|
if (this.phaseTicks < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logPhaseOnce(client, "上岸并回收船完成");
|
||||||
|
this.flow = IDLE;
|
||||||
|
this.phase = PHASE_PLACE;
|
||||||
|
this.phaseTicks = 0;
|
||||||
|
this.boatEntityId = -1;
|
||||||
|
this.restoreSelectedSlot(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按下蹲伏键(持续按住) */
|
||||||
|
private void pressSneak(MinecraftClient client) {
|
||||||
|
if (client.player == null) return;
|
||||||
|
KeyBinding sneak = client.options.sneakKey;
|
||||||
|
sneak.setPressed(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 释放蹲伏键 */
|
||||||
|
private void releaseSneak(MinecraftClient client) {
|
||||||
|
if (client.player == null) return;
|
||||||
|
KeyBinding sneak = client.options.sneakKey;
|
||||||
|
sneak.setPressed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在指定半径内查找最接近玩家的船实体 */
|
||||||
|
private AbstractBoatEntity findNearbyBoat(MinecraftClient client, double radius) {
|
||||||
|
Vec3d center = client.player.getPos();
|
||||||
|
AbstractBoatEntity best = null;
|
||||||
|
double bestDistance = Double.MAX_VALUE;
|
||||||
|
for (Entity entity : client.world.getOtherEntities(client.player, client.player.getBoundingBox().expand(radius))) {
|
||||||
|
if (!(entity instanceof AbstractBoatEntity boat)) continue;
|
||||||
|
if (boat.isRemoved()) continue;
|
||||||
|
double d = entity.squaredDistanceTo(center);
|
||||||
|
if (d < bestDistance) {
|
||||||
|
bestDistance = d;
|
||||||
|
best = boat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按实体 ID 精确查找船实体(避免世界内多艘船的歧义) */
|
||||||
|
private AbstractBoatEntity findBoatById(MinecraftClient client, int entityId) {
|
||||||
|
if (entityId < 0) return null;
|
||||||
|
Entity entity = client.world.getEntityById(entityId);
|
||||||
|
if (entity instanceof AbstractBoatEntity boat && !boat.isRemoved()) {
|
||||||
|
return boat;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅在阶段切换时记录一次日志,避免每帧刷屏 */
|
||||||
|
private void logPhaseOnce(MinecraftClient client, String message) {
|
||||||
|
if (message == null || message.equals(this.lastLoggedPhase)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastLoggedPhase = message;
|
||||||
|
if (client.player != null) {
|
||||||
|
client.player.sendMessage(net.minecraft.text.Text.literal("[AutoStore] " + message), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,12 @@ import net.fabricmc.api.Environment;
|
|||||||
/**
|
/**
|
||||||
* 扩地规划器:根据当前邦国领土、配置与玩家位置,计算下一步要占领的区块列表。
|
* 扩地规划器:根据当前邦国领土、配置与玩家位置,计算下一步要占领的区块列表。
|
||||||
*
|
*
|
||||||
* <p>支持三种扩地策略:
|
* <p>支持四种扩地策略:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>策略 1:方形外扩(优先填内部空洞,再向外一圈圈扩展)</li>
|
* <li>策略 1:方形外扩(优先填内部空洞,再向外一圈圈扩展)</li>
|
||||||
|
* <li>策略 3:玩家周围扩地(围绕玩家从近到远铺开)</li>
|
||||||
* <li>策略 4:绕国一圈(沿领土边界按角度均匀向外扩张)</li>
|
* <li>策略 4:绕国一圈(沿领土边界按角度均匀向外扩张)</li>
|
||||||
* <li>其他(2/3):直线推进(沿一个方向连续向外延伸)</li>
|
* <li>其他(2 无圈选区域 / 未知值):直线推进(沿一个方向连续向外延伸)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public final class ExpansionPlanner {
|
public final class ExpansionPlanner {
|
||||||
@@ -45,6 +46,8 @@ public final class ExpansionPlanner {
|
|||||||
result = this.generateHolePriority(model, config, playerChunk);
|
result = this.generateHolePriority(model, config, playerChunk);
|
||||||
} else if (config.strategy == 4) {
|
} else if (config.strategy == 4) {
|
||||||
result = this.generatePerimeter(model, config, playerChunk);
|
result = this.generatePerimeter(model, config, playerChunk);
|
||||||
|
} else if (config.strategy == 3) {
|
||||||
|
result = this.generateAroundPlayer(model, config, playerChunk);
|
||||||
} else if (config.strategy == 2 && config.regionMaxX >= config.regionMinX) {
|
} else if (config.strategy == 2 && config.regionMaxX >= config.regionMinX) {
|
||||||
result = this.generateRectangle(model, config, playerChunk);
|
result = this.generateRectangle(model, config, playerChunk);
|
||||||
} else {
|
} else {
|
||||||
@@ -164,7 +167,32 @@ public final class ExpansionPlanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 策略 2/3:直线推进。
|
* 策略 3:玩家周围扩地。
|
||||||
|
*
|
||||||
|
* <p>收集所有与己方领土相邻的合法区块,以玩家当前位置为中心,
|
||||||
|
* 按与玩家的曼哈顿距离由近到远排序(距离相同者优先邻居数多、形状更紧实的),
|
||||||
|
* 围绕玩家一圈圈向外铺开,而非沿固定方向直线推进。</p>
|
||||||
|
*/
|
||||||
|
private List<ChunkPos> generateAroundPlayer(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
|
||||||
|
Set<ChunkPos> actualOwn = this.ownChunks(model);
|
||||||
|
Set<ChunkPos> allClaimed = model.allClaimedChunks();
|
||||||
|
if (actualOwn.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
int limit = config.targetCount > 0 ? config.targetCount : 64;
|
||||||
|
HashSet<ChunkPos> plannedOwn = new HashSet<ChunkPos>(actualOwn);
|
||||||
|
HashSet<ChunkPos> candidates = new HashSet<ChunkPos>();
|
||||||
|
this.addNeighbors(plannedOwn, candidates, allClaimed, config);
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Comparator<ChunkPos> byDistance = Comparator.comparingInt(playerChunk::manhattanDistance)
|
||||||
|
.thenComparingInt(chunk -> -this.ownNeighbors(chunk, plannedOwn));
|
||||||
|
return candidates.stream().sorted(byDistance).limit((long)limit).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 策略 2(无圈选区域时)/ 未知策略回退:直线推进。
|
||||||
*
|
*
|
||||||
* <p>从玩家当前位置出发,沿一个方向连续向外延伸占领区块(形成一条"线")。
|
* <p>从玩家当前位置出发,沿一个方向连续向外延伸占领区块(形成一条"线")。
|
||||||
* 每步从候选集中挑选评分最优的区块:距离越近、越贴合当前方向、邻居越多、形状越规整越好。
|
* 每步从候选集中挑选评分最优的区块:距离越近、越贴合当前方向、邻居越多、形状越规整越好。
|
||||||
@@ -456,7 +484,7 @@ public final class ExpansionPlanner {
|
|||||||
return chunk.x() >= config.regionMinX && chunk.x() <= config.regionMaxX
|
return chunk.x() >= config.regionMinX && chunk.x() <= config.regionMaxX
|
||||||
&& chunk.z() >= config.regionMinZ && chunk.z() <= config.regionMaxZ;
|
&& chunk.z() >= config.regionMinZ && chunk.z() <= config.regionMaxZ;
|
||||||
}
|
}
|
||||||
// 回退到圆形区域(中心+半径)
|
// 回退到以中心+半径限定的正方形区域(切比雪夫距离,与"圈数"距离定义一致)
|
||||||
int dx = Math.abs(chunk.x() - config.regionCenterX);
|
int dx = Math.abs(chunk.x() - config.regionCenterX);
|
||||||
return Math.max(dx, dz = Math.abs(chunk.z() - config.regionCenterZ)) <= Math.max(1, config.regionRadius);
|
return Math.max(dx, dz = Math.abs(chunk.z() - config.regionCenterZ)) <= Math.max(1, config.regionRadius);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
package autostore.client.territory;
|
package autostore.client.territory;
|
||||||
|
|
||||||
|
import autostore.client.api.SimpApiClient;
|
||||||
import autostore.client.territory.ChunkPos;
|
import autostore.client.territory.ChunkPos;
|
||||||
import autostore.client.territory.TerritoryModel;
|
import autostore.client.territory.TerritoryModel;
|
||||||
import autostore.client.territory.TerritoryState;
|
import autostore.client.territory.TerritoryState;
|
||||||
@@ -28,6 +29,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.Consumer;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import net.fabricmc.api.EnvType;
|
import net.fabricmc.api.EnvType;
|
||||||
@@ -38,12 +40,19 @@ import net.fabricmc.api.Environment;
|
|||||||
* 领土数据服务:定时从远程地图服务拉取邦国领土数据(markers.json),
|
* 领土数据服务:定时从远程地图服务拉取邦国领土数据(markers.json),
|
||||||
* 解析为 {@link TerritoryModel} 供规划器与 GUI 使用。
|
* 解析为 {@link TerritoryModel} 供规划器与 GUI 使用。
|
||||||
*
|
*
|
||||||
* <p>数据来源是 AutoStore 服务器自带的动态地图(Dynmap)接口,包含:
|
* <p>占领判定有四个数据源,任意一个显示区块被占领即算占领:
|
||||||
* <ul>
|
* <ol>
|
||||||
* <li>union_claims:各邦国的领土多边形(区块归属)</li>
|
* <li>游戏内字幕("开垦中 XX%")</li>
|
||||||
* <li>union_capitals:各邦国的王城坐标</li>
|
* <li>游戏消息("疆土(x,z) 已为你的邦国开拓")</li>
|
||||||
* </ul>
|
* <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 {
|
public final class TerritoryDataService {
|
||||||
/** 动态地图的 markers.json 接口地址(AutoStore 服务器) */
|
/** 动态地图的 markers.json 接口地址(AutoStore 服务器) */
|
||||||
@@ -88,9 +97,35 @@ public final class TerritoryDataService {
|
|||||||
/** 上次成功响应的正文哈希与长度,用于识别"网络缓存返回了相同旧数据"并跳过重复解析 */
|
/** 上次成功响应的正文哈希与长度,用于识别"网络缓存返回了相同旧数据"并跳过重复解析 */
|
||||||
private volatile int lastBodyHash;
|
private volatile int lastBodyHash;
|
||||||
private volatile int lastBodyLength = -1;
|
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() {
|
public TerritoryDataService() {
|
||||||
this.executor = TerritoryDataService.newExecutor();
|
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 秒)循环拉取。
|
* 启动定时刷新:立即刷新一次,之后按固定间隔(至少 10 秒)循环拉取。
|
||||||
*
|
*
|
||||||
@@ -125,11 +347,14 @@ public final class TerritoryDataService {
|
|||||||
long interval = Math.max(10L, refreshIntervalSeconds);
|
long interval = Math.max(10L, refreshIntervalSeconds);
|
||||||
fresh.scheduleWithFixedDelay(this::refreshAsync, interval, interval, TimeUnit.SECONDS);
|
fresh.scheduleWithFixedDelay(this::refreshAsync, interval, interval, TimeUnit.SECONDS);
|
||||||
old.shutdownNow();
|
old.shutdownNow();
|
||||||
|
// 刷新间隔变化不影响 API 轮询,但保持调度状态一致
|
||||||
|
this.rescheduleApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停止定时刷新,关闭调度线程 */
|
/** 停止定时刷新,关闭调度线程 */
|
||||||
public void stop() {
|
public void stop() {
|
||||||
this.executor.shutdownNow();
|
this.executor.shutdownNow();
|
||||||
|
this.apiExecutor.shutdownNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,6 +363,7 @@ public final class TerritoryDataService {
|
|||||||
* 只保留"已被地图确认"或"刚占领未超宽限期"的占领记录,避免刷新时把假占领永久留在模型里。
|
* 只保留"已被地图确认"或"刚占领未超宽限期"的占领记录,避免刷新时把假占领永久留在模型里。
|
||||||
*/
|
*/
|
||||||
public void refreshAsync() {
|
public void refreshAsync() {
|
||||||
|
long apiVersionAtStart = this.apiDataVersion;
|
||||||
this.executor.execute(() -> {
|
this.executor.execute(() -> {
|
||||||
this.lastAttemptMillis = System.currentTimeMillis();
|
this.lastAttemptMillis = System.currentTimeMillis();
|
||||||
try {
|
try {
|
||||||
@@ -154,34 +380,47 @@ public final class TerritoryDataService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String body = response.body();
|
String body = response.body();
|
||||||
// 内容未变化(服务器确实无更新,或缓存仍命中相同旧文):跳过重复解析,
|
// 内容未变化(服务器确实无更新,或缓存仍命中相同旧文)且 API 数据也未更新:跳过重复解析,
|
||||||
// 避免"每次刷新都重建 8 万区块模型"的 CPU/GC 开销;模型与飞地缓存保持原样。
|
// 避免"每次刷新都重建 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.lastSuccessfulFetchMillis = System.currentTimeMillis();
|
||||||
this.lastError = "";
|
this.lastError = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.lastBodyHash = body.hashCode();
|
this.lastBodyHash = body.hashCode();
|
||||||
this.lastBodyLength = body.length();
|
this.lastBodyLength = body.length();
|
||||||
|
this.lastMergedApiVersion = apiVersionAtStart;
|
||||||
TerritoryModel parsed = TerritoryDataService.parse(response.body());
|
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;
|
TerritoryModel previous = this.model;
|
||||||
if (previous != null) {
|
if (previous != null) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
// 本次拉取到的己方领土区块(地图数据即最终依据,无需缓存旧数据)
|
// 本次拉取到的己方领土区块(Dynmap 与 API 任一确认即算已确认)
|
||||||
Set<ChunkPos> freshOwn = new HashSet<>();
|
Set<ChunkPos> freshOwn = new HashSet<>();
|
||||||
TerritoryState freshSelf = parsed.state(this.selectedStateName);
|
TerritoryState freshSelf = parsed.state(this.selectedStateName);
|
||||||
if (freshSelf != null) {
|
if (freshSelf != null) {
|
||||||
freshOwn.addAll(freshSelf.chunks());
|
freshOwn.addAll(freshSelf.chunks());
|
||||||
}
|
}
|
||||||
|
Set<ChunkPos> apiOwn = apiSnapshot.getOrDefault(this.selectedStateName, Set.of());
|
||||||
for (ChunkPos sessionClaim : previous.sessionClaims()) {
|
for (ChunkPos sessionClaim : previous.sessionClaims()) {
|
||||||
Long claimTime = this.sessionClaimTimes.get(sessionClaim);
|
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;
|
boolean withinGrace = claimTime != null && now - claimTime < CLAIM_CONFIRM_GRACE_MS;
|
||||||
if (confirmed || withinGrace) {
|
if (confirmed || withinGrace) {
|
||||||
// 地图已确认,或尚在宽限期(Dynmap 可能还没同步):保留会话占领标记
|
// 地图/API 已确认,或尚在宽限期(服务端可能还没同步):保留会话占领标记
|
||||||
parsed.addSessionClaim(sessionClaim);
|
parsed.addSessionClaim(sessionClaim);
|
||||||
} else if (claimTime != null) {
|
} else if (claimTime != null) {
|
||||||
// 地图始终未确认且超时:判定为假占领并移除,使空洞区块重新进入规划
|
// 所有数据源始终未确认且超时:判定为假占领并移除,使空洞区块重新进入规划
|
||||||
this.sessionClaimTimes.remove(sessionClaim);
|
this.sessionClaimTimes.remove(sessionClaim);
|
||||||
}
|
}
|
||||||
// claimTime 为 null(从未在本服务记录)也视为未确认:不保留
|
// claimTime 为 null(从未在本服务记录)也视为未确认:不保留
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import net.fabricmc.api.Environment;
|
|||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public final class TerritoryModel {
|
public final class TerritoryModel {
|
||||||
/** 邦国名 → 邦国领土状态 */
|
/** 邦国名 → 邦国领土状态。允许整体替换(mergeApiData 在模型发布前重建 states) */
|
||||||
private final Map<String, TerritoryState> states;
|
private Map<String, TerritoryState> states;
|
||||||
/** 所有邦国已占领区块的并集 */
|
/** 所有邦国已占领区块的并集 */
|
||||||
private final Set<ChunkPos> allClaimed;
|
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()));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"schemaVersion":1,"id":"autostore","version":"1.2.5","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"}]}
|
{"schemaVersion":1,"id":"autostore","version":"1.3.0","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"}]}
|
||||||
在新工单中引用
屏蔽一个用户