36 次代码提交
作者 SHA1 备注 提交日期
lyizai d971373734 feat: 接入 SimpCore 开放 API 作为第四占领判定数据源
- 新增 SimpApiClient:对接 https://open.bgjq.top 查询 API(邦国列表/单国领土/
  全服领土/玩家邦国),响应本地缓存 5 分钟,Key 从配置或 SIMP_API_KEY 环境变量读取
- TerritoryDataService:API 数据独立低频轮询(默认 180s,min 60s),合并入领土模型;
  会话占领确认改为 Dynmap/API 任一确认即可;API 数据版本参与变更检测,
  Dynmap 正文未变但 API 更新时也会重新解析合并
- TerritoryModel.mergeApiData:API 独有区块以紫色新增邦国,王城缺失用 API 兜底
- /autostore auto:Dynmap 未覆盖所在区块时回退 API 按玩家名识别邦国
- 新增 /autostore api 命令组(status/key/on/off/refresh/full/union);
  GUI 自动识别按钮同样支持 API 兜底,状态栏显示 API 拉取时间
- .env.example 提供环境变量模板(真实 Key 留在 .env,不提交)
2026-09-07 13:32:00 +08:00
lyizai d348861598 fix: 自动游泳改为持续按住空格(只在水中且 Baritone 未接管时)
原逻辑: 完全没入水中时调用一次 player.jump(),离开水面后停止。
新逻辑: 玩家处于水中(player.isTouchingWater())、Baritone 未寻路(!baritoneBridge.isPathing())、
        玩家不在船中时持续按下 jumpKey;
        离开水/Baritone 接管/坐上船/任务停止任一条件即释放空格键。
新增 holdingJump 字段跟踪按压状态,stop() 中显式释放避免卡键。
2026-09-05 22:24:51 +08:00
lyizai 555b96d3ba feat: 自动用船过水(开阔水域自动放船登船、上岸前自动下船回收船)
新增配置项 autoBoat/autoBoatMinWidth/autoBoatMinLength(默认开启,可在 GUI 勾选)。

新增 AutoBoatController:
- 每帧检测玩家朝向前方开阔水域(宽度>=4/长度>=6 格)、背包有任意 *_boat/*_chest_boat 时自动放船登船;
- 船中且前方出现岸边时按蹲伏下船并攻击船实体回收船;
- 进出船流程临时按住蹲伏键阻止 Baritone 把玩家推进水里或撞上船,
  流程结束后释放蹲伏键, Baritone 自动恢复寻路。

ExpansionController: tick 在自动游泳后调用 tickAutoBoat, stop() 中调用 reset 释放蹲伏键与还原主手槽位。
GUI: 新增 "用船过水" 复选框(第四行)。
版本号 1.2.6 -> 1.3.0, README 同步。
2026-09-05 00:59:28 +08:00
lyizai 3669442d64 v1.2.6: 自动攻击敌对生物(豁免铁傀儡/僵尸猪灵等中立生物) + 低耐久自动/u return回城保护 2026-08-31 13:26:11 +08:00
lyizai 70ab6cc698 v1.2.5: 策略3落地为玩家周围扩地(按距离铺开);状态机switch改为具名分支;修复rings命令设置重启后失效 2026-08-31 13:14:02 +08:00
lyizai bc86c26d1e v1.2.5: 高度偏好寻路(优先往高处走) + 寻路/重试前校验目标接壤 + 超时先走两步再传送
- 新增 GoalXZAltitude 寻路目标:启发式成本 = 水平距离 - 高度权重×y,y 越大优先级越高、y 越小施惩罚,引导寻路优先走高
- ExpansionConfig 新增 altitudePreference/altitudeWeight 配置(默认开/0.1),BaritoneBridge 按配置选择原生 GoalXZ 或高度偏好 Goal
- 寻路/重试前先检查目标区块是否仍与己方领土接壤(startPathing/retrySameTarget),不接壤立即跳过
- 寻路超时不再直接传送:重试耗尽后先往边上走两步自救(每轮重试原目标,最多 sideStepMaxRounds=3 轮),多轮无效才按连续超时阈值回城或切换目标
2026-08-21 17:02:43 +08:00
lyizai a0963747b6 v1.2.4: 寻路重试耗尽先往边上走两步再重试原目标,仍失败才回城或切换目标 2026-08-21 16:25:00 +08:00
lyizai 08e15ffc9b v1.2.4: 连续寻路超时超过1分钟自动发送/u return回城,传送后从最高优先级区块重新扩地 2026-08-21 16:10:02 +08:00
lyizai a9c71861f5 v1.2.3: 更新README安装/构建的jar版本引用至1.2.3 2026-08-21 16:02:10 +08:00
lyizai 5193b2e379 v1.2.3: 不在开拓时段点击开始改为登记等待,进入11:00-22:00后自动启动;等待期间可手动取消 2026-08-20 08:55:09 +08:00
lyizai 2309f01291 v1.2.2: 根治寻路秒CALC_FAILED——删除自定义ChunkGoal(含高度isInGoal),改用Baritone原生GoalXZ到区块中心 2026-08-19 21:33:46 +08:00
lyizai 3cddee2abb v1.2.2: 一步之隔目标误判不可达——ChunkGoal.isInGoal回归纯二维(高度约束改为启发式软引导),根治边缘落点被秒判不可达 2026-08-19 21:21:46 +08:00
lyizai 28fe848fb6 v1.2.2: 目标地表高度改取中位数(避免高树孤峰拉高导致一步之隔误判不可达);CALC_FAILED改为先重试再切目标 2026-08-19 21:16:45 +08:00
lyizai 340fb2ddf1 v1.2.2: 寻路不再带玩家下地下矿坑——ChunkGoal增加高度约束,目标为该区块内经排除树叶的地表最高处,停在矿坑不算到达,不搭方块到树上 2026-08-19 21:05:14 +08:00
lyizai fc14e51894 v1.2.2: 修复开启放置权限时搭路原地抽搐放不出方块(配套allowParkourPlace+归零blockPlacementPenalty+允许跳跃搭路) 2026-08-19 20:52:52 +08:00
lyizai 00aa45a551 v1.2.1(重发): 修复刷新误标已占领(缓存击穿+宽限期60s+防误判)、修复GUI/刷新卡顿与内存(视口过滤/后台预计算/零复制校验/内容去重/ChunkPos驻留)、区域扩地重写为严格S形由内而外(起始边离王城最近) 2026-08-19 17:09:20 +08:00
lyizai c55e4eaf8b v1.2.1: 修复飞地检测——按 dynmap even-odd 规则解析领土多边形,内圈孔洞(国中飞地边界)不再被并集填充掩盖 2026-08-19 13:09:42 +08:00
lyizai 448dad6cc7 v1.2.0: README 更新版本号与飞地检测功能说明 2026-08-19 12:36:34 +08:00
lyizai 2b8211b3d0 v1.2.0: 内置地图检测并标记飞地(疏漏区块),自动扩地时优先填补这些缺口;新增 analyze_holes.py 分析工具 2026-08-19 12:36:15 +08:00
lyizai 25d95a5c22 v1.1.9: README 与 Wiki 增加赞助支持板块(赞赏码) 2026-08-19 12:04:43 +08:00
lyizai 385e0d13f1 v1.1.9: UI 运行日志区域改用小字号并自动换行,最新日志置顶显示 2026-08-19 10:45:29 +08:00
lyizai 1e99ff39e0 v1.1.9: 不在开拓时限内时向玩家发送聊天提示,并修复日志被 CANCELED 事件刷屏 2026-08-19 09:25:30 +08:00
lyizai c569065054 v1.1.9: 增加现实时间限制,仅 11:00-22:00 允许开拓 2026-08-19 08:19:03 +08:00
lyizai 3601bcd40e v1.1.9: 寻路超时改为重新寻路/跳过目标而非回城 2026-08-18 20:59:39 +08:00
lyizai e08dbb8104 v1.1.9: 增加运行时接壤校验与寻路失败监听,修复不接壤目标与大山寻路超时;新增日志导出 2026-08-18 20:40:04 +08:00
lyizai 7a45e6f676 v1.1.9: 修复区域扩地接壤判断误把其他邦国领土计入,导致选中与己方不相邻的目标 2026-08-18 20:04:26 +08:00
lyizai d2a52199ae v1.1.8: 刷新地图时核对已占领区块,丢弃地图未确认的超期假占领,修复高地空洞 2026-08-18 18:59:59 +08:00
lyizai 1f817199f7 v1.1.8: 寻路超时自动 /u return 回城自救
寻路超时后发送 /u return 回城,等待 10 秒传送回王城后重新寻路;重试次数耗尽则回城后跳过,避免整个系统因寻路卡死而停止。
2026-08-18 16:11:32 +08:00
lyizai 9ee4b41630 v1.1.8: 回退寻路逻辑至 1.1.3 并修复目标区块错位
回退:移除 1.1.4+ 引入的回城自救/接壤校验等寻路改动(导致玩家原地不动),恢复 1.1.3 的寻路行为。

修复错位:Baritone 自带 GoalXZ 容差为 0,精确到区块中心单格,中心不可达时玩家停在相邻区块造成目标错位与反复寻路。新增 ChunkGoal,完成条件改为「玩家脚下方块落在目标区块内」,与 playerChunk.equals(target) 判定一致。
2026-08-18 14:20:59 +08:00
lyizai 15b4a40b6d v1.1.7: 修复玩家不移动问题(移除寻路前相邻领土校验)
1.1.5 新增的「目标与己方领土接壤校验」在第三方领土地图刷新后,own 集合与规划器判定不一致,导致有效目标被误判跳过,玩家原地不动。已移除运行时的重复邻居验证(规划器已保证目标与己方领土相邻),仅保留「未被其它邦国占领」判断,恢复 1.1.2 及更早版本的行为。

构建:修正 build.gradle 使 baritone 嵌套打包进 META-INF/jars(1.1.6 缺失)。
2026-08-18 13:47:20 +08:00
lyizai b8e56dafcb v1.1.6: 新增 Fabric Loom 构建环境
- 添加 build.gradle / settings.gradle / gradle.properties / wrapper
- 嵌入 baritone-api-fabric-1.13.1.jar 依赖
- 添加 .gitignore
- 更新版本号至 1.1.6
2026-08-18 13:07:04 +08:00
Lyizai d0ed839d9f v1.1.5: 修复规划完成后不寻路问题
- isTargetStillValid 增加己方领土数据 fallback 与保守放行
- 修复地图刷新竞态导致 selfState 暂时为 null 时所有目标被跳过、玩家原地不动的问题
2026-08-17 13:07:51 +00:00
Lyizai 20c9cb10a3 docs: README 更新 v1.1.4 寻路优化说明 2026-08-17 12:29:29 +00:00
Lyizai e8f6009a66 v1.1.4: 寻路优化(避矿洞+超时回城自救+不接壤目标校验)
- Baritone设置:限制下落高度(10格)与轻微挖方块惩罚,减少走入矿洞,同时保留峡谷/高山通行
- 寻路超时且玩家在地下时自动执行 /u return 回城自救,等待10秒传送后重新规划
- 新增目标有效性校验:寻路/等待/重试前实时校验目标是否仍与己方领土接壤,不接壤立即跳过并重新规划,修复走到不接壤区块死等bug
2026-08-17 12:27:38 +00:00
Lyizai 8a2c4a21d8 v1.1.3: GUI左上角添加Git仓库与官网链接按钮(点击在系统浏览器打开) 2026-08-17 11:12:20 +00:00
Lyizai 134a6e6f20 v1.1.2: 美化UI并修复状态/进度文字重叠(底部信息区改单行截断+双栏面板) 2026-08-17 10:56:29 +00:00
共修改 29 个文件,包含 3853 行新增和 291 行删除
+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>
+22
查看文件
@@ -0,0 +1,22 @@
# Gradle
.gradle/
build/
# Secrets
.env
# IDE
.idea/
*.iml
.vscode/
.settings/
.classpath
.project
bin/
# OS
Thumbs.db
.DS_Store
# Build temp
.buildtmp/
+26 -4
查看文件
@@ -6,16 +6,30 @@
- 从远程 `markers.json` 拉取各国领土边界 - 从远程 `markers.json` 拉取各国领土边界
- 四种扩地策略:方形外扩、区域扩地、玩家周围、绕国一圈 - 四种扩地策略:方形外扩、区域扩地、玩家周围、绕国一圈
- 区域扩地采用 S 形扫描:从与现有领土接壤的区块开始,横向/纵向 S 形扫过整个矩形,不沿邦国边界,但保证每个新占领区块都与已占领领土接壤 - 区域扩地采用 S 形扫描:从与己方领土接壤的区块开始,横向/纵向 S 形扫过整个矩形,不沿邦国边界,但保证每个新占领区块都与**己方领土**接壤(仅四个正方向,不把其他邦国领土计入接壤)
- 自动寻路(Baritone)前往目标区块 - 自动寻路(Baritone)前往目标区块;监听 Baritone 路径事件,目标不可达(CALC_FAILED,如被悬崖/高山/水体隔断)时重试、耗尽后先"往边上走两步"再试,仍失败才切换目标;寻路超时则重新寻路(不立即回城)
- 高度偏好寻路:寻路优先往高处走(y 越大优先级越高,y 越小有惩罚),适合山区/高原目标;可通过配置关闭或调整权重
- 连续寻路超时回城:连续寻路超时累计超过 1 分钟(可配置)时,先反复尝试"往边上走两步"脱困(默认最多 3 轮),仍无法脱困才自动发送 `/u return`,约 10 秒后传送到可寻路位置,随后重新规划并从最高优先级区块重新开始扩地
- 运行时接壤校验:寻路/等待阶段每帧校验目标是否仍与己方领土接壤,地图刷新导致边界变化时立即跳过
- 目标有效性校验:目标若已被其他邦国占领则立即跳过
- 现实时间限制:仅 11:00-22:00(本地现实时间)允许开拓,其余时段启动会被拒绝、运行中到点自动停止
- 自动进食(饥饿时自动吃背包食物) - 自动进食(饥饿时自动吃背包食物)
- 自动攻击敌对生物:扩地途中自动面向并攻击索敌范围内的敌对生物(满蓄力出手、不追击、不打断寻路);严格豁免不会主动攻击玩家的中立生物——铁傀儡、僵尸猪灵永不攻击,末影人(仅被激怒)、蜘蛛(仅黑暗中)、猪灵(仅玩家未穿金甲)按原版敌对条件反击,狼、蜜蜂、羊驼等其它中立生物一律不打
- 耐久保护回城:每 2 秒检查主手/副手/四件盔甲耐久,任意一件剩余耐久低于阈值(默认 15%,可在 GUI 调整)时自动发送 `/u return` 回城并停止扩地,防止装备被怪物磨没
- 自动用船过水:扩地途中若路径前方出现开阔水域(默认宽度 ≥ 4 格、长度 ≥ 6 格),自动切到主手放船并登船;前方出现岸边时自动蹲伏下船并攻击船实体回收船(仅背包有任意 `*_boat`/`*_chest_boat` 时生效;不打断 Baritone 寻路)
- 实时地图 GUI(通过 Ctrl+R 打开) - 实时地图 GUI(通过 Ctrl+R 打开)
- 监听聊天/字幕消息判断开垦结果 - 监听聊天/字幕消息判断开垦结果
- 刷新地图时与最新 `markers.json` 核对已占领区块,丢弃地图未确认的超期假占领,避免高地扩地残留空洞
- 刷新请求带缓存击穿(时间戳参数 + no-cache 头),修复约半数刷新命中网络缓存拿到旧数据、导致目标被误标"已占领"且长时间不修正的问题;占领成功后立即触发刷新确认,假占领确认宽限期缩短至 60 秒
- 飞地(疏漏区块)检测:按 dynmap 奇偶填充规则解析领土多边形,正确识别国中由边界围出的内圈空腔;内置地图用红色标出己方飞地,自动扩地时优先填补这些缺口
- 区域扩地严格 S 形(蛇形)扫描:起始边为矩形四条边中离王城最近的一条,由内而外推进;即使待开垦区块构成不规则多边形也按 S 形一路扫过去
- 性能优化:地图渲染按视口快速过滤(不再每帧全量遍历 8 万区块)、飞地检测移至后台线程预计算、接壤校验零集合复制、响应内容未变化时跳过重复解析、ChunkPos 跨模型驻留复用——修复打开 GUI 与刷新地图时游戏卡顿、内存占用偏高的问题
- 日志导出:GUI 右上角“导出日志”按钮,或 `/autostore log` 命令,记录寻路、坐标、状态、路径事件等
## 安装 ## 安装
1. 安装 Fabric Loader 0.16.14+ 和 Fabric API 1. 安装 Fabric Loader 0.16.14+ 和 Fabric API
2. 将 `autostore-1.1.1.jar` 放入 `.minecraft/mods/` 目录 2. 将 `autostore-1.3.0.jar` 放入 `.minecraft/mods/` 目录
3. 启动游戏,按 `Ctrl+R` 打开 GUI 3. 启动游戏,按 `Ctrl+R` 打开 GUI
## 构建 ## 构建
@@ -25,4 +39,12 @@
./build_mod.sh ./build_mod.sh
``` ```
输出文件:`autostore-1.1.1.jar` 输出文件:`autostore-1.3.0.jar`
## 赞助支持
如果你觉得这个模组对你有帮助,欢迎赞助支持一下创作者 ❤️
![赞助码](docs/sponsor.png)
你的支持是持续开发的动力!
+126
查看文件
@@ -0,0 +1,126 @@
import json, math, urllib.request, sys
sys.dont_write_bytecode = True
URL = "http://bgjq.simpfun.cn/tiles/minecraft_overworld/markers.json"
data = urllib.request.urlopen(
urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0 autostore/1.0"}),
timeout=20).read()
root = json.loads(data.decode("utf-8"))
CLAIM_PATTERN_prefix = "疆土归属:"
def extract_polygons(points):
result = []
if points is None or not isinstance(points, list):
return result
if len(points) == 0:
return result
if isinstance(points[0], dict) and "x" in points[0] and "z" in points[0]:
poly = [(p["x"], p["z"]) for p in points]
if len(poly) >= 3:
result.append(poly)
else:
for el in points:
result.extend(extract_polygons(el))
return result
def point_in_polygon(x, z, poly):
inside = False
j = len(poly) - 1
for i in range(len(poly)):
xi, zi = poly[i]
xj, zj = poly[j]
if (zi > z) != (zj > z) and x < (xj - xi) * (z - zi) / (zj - zi) + xi:
inside = not inside
j = i
return inside
def fill_polygon(out, poly):
minx = min(math.floor(p[0]/16) for p in poly)
maxx = max(math.floor(p[0]/16) for p in poly)
minz = min(math.floor(p[1]/16) for p in poly)
maxz = max(math.floor(p[1]/16) for p in poly)
area = (maxx - minx + 1) * (maxz - minz + 1)
if area > 6_000_000:
return
for cx in range(minx, maxx+1):
for cz in range(minz, maxz+1):
if point_in_polygon(cx*16+8, cz*16+8, poly):
out.add((cx, cz))
# Polygon count per marker
poly_counts = {}
for layer in root:
if layer.get("id") != "union_claims":
continue
for marker in layer.get("markers", []):
popup = marker.get("popup", "")
idx = popup.find(CLAIM_PATTERN_prefix)
if idx < 0:
continue
rest = popup[idx+len(CLAIM_PATTERN_prefix):].lstrip()
name = ""
for ch in rest:
if ch in "<\r\n":
break
name += ch
name = name.strip()
if not name:
continue
polys = extract_polygons(marker.get("points"))
poly_counts.setdefault(name, 0)
poly_counts[name] += len(polys)
print("=== markers with multiple polygons (potential holes as inner rings) ===")
for name, cnt in poly_counts.items():
if cnt > 1:
print(f" {name}: {cnt} polygons")
# Also report how many total markers
print(f"\nTotal markers: {sum(poly_counts.values())}, distinct countries: {len(poly_counts)}")
claims = {}
for layer in root:
if layer.get("id") != "union_claims":
continue
for marker in layer.get("markers", []):
popup = marker.get("popup", "")
idx = popup.find(CLAIM_PATTERN_prefix)
if idx < 0:
continue
rest = popup[idx+len(CLAIM_PATTERN_prefix):].lstrip()
name = ""
for ch in rest:
if ch in "<\r\n":
break
name += ch
name = name.strip()
if not name:
continue
s = claims.setdefault(name, set())
for poly in extract_polygons(marker.get("points")):
fill_polygon(s, poly)
print(f"Total countries parsed: {len(claims)}")
print(f"Total claimed chunks: {sum(len(v) for v in claims.values())}")
print("\n=== Flying chunks (holes) per country ===")
total_holes = 0
for name, chunks in claims.items():
if not chunks:
continue
xs = [c[0] for c in chunks]
zs = [c[1] for c in chunks]
minx, maxx = min(xs), max(xs)
minz, maxz = min(zs), max(zs)
holes = []
for cx in range(minx, maxx+1):
for cz in range(minz, maxz+1):
if (cx, cz) in chunks:
continue
if all(((cx+dx, cz+dz) in chunks) for dx, dz in [(1,0),(-1,0),(0,1),(0,-1)]):
holes.append((cx, cz))
if holes:
total_holes += len(holes)
print(f" {name}: {len(holes)} holes, e.g. {holes[:8]}")
print(f"\n=== TOTAL flying chunks: {total_holes} ===")
+57
查看文件
@@ -0,0 +1,57 @@
plugins {
id 'fabric-loom' version '1.17.19'
id 'maven-publish'
}
version = project.mod_version
group = project.maven_group
base {
archivesName = project.archives_base_name
}
// 用于把 Baritone 原样打包进模组 jar 的资源(不放默认依赖里,避免 Gradle 误解析)
configurations {
nestedJar
}
dependencies {
// Minecraft
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
// Fabric API
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
// Baritone API (local jar, compile only)
modImplementation files("libs/baritone-api-fabric-1.13.1.jar")
// 标记需要嵌套打包的 jar
nestedJar files("libs/baritone-api-fabric-1.13.1.jar")
}
processResources {
inputs.property "version", project.version
filteringCharset "UTF-8"
filesMatching("fabric.mod.json") {
expand "version": project.version
}
// 将 Baritone 嵌套打包进 META-INF/jars,匹配 fabric.mod.json 的 jars 声明,
// 使玩家无需单独安装 baritone 依赖即可运行
from(configurations.nestedJar) {
into "META-INF/jars"
}
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 21
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 88 KiB

二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 11 KiB

+15
查看文件
@@ -0,0 +1,15 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true
# Fabric Properties
minecraft_version=1.21.4
yarn_mappings=1.21.4+build.8
loader_version=0.19.3
# Mod Properties
mod_version=1.3.0
maven_group=autostore
archives_base_name=autostore
# Fabric API
fabric_version=0.119.4+1.21.4
二进制
查看文件
二进制文件未显示。
+9
查看文件
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.7.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
第三方依赖
+248
查看文件
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
第三方依赖
+82
查看文件
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
二进制文件未显示。
+12
查看文件
@@ -0,0 +1,12 @@
pluginManagement {
repositories {
maven {
name = 'Fabric'
url = 'https://maven.fabricmc.net/'
}
mavenCentral()
gradlePluginPortal()
}
}
rootProject.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
) {
}
}
@@ -7,10 +7,14 @@ package autostore.client.bridge;
import baritone.api.BaritoneAPI; import baritone.api.BaritoneAPI;
import baritone.api.IBaritone; import baritone.api.IBaritone;
import baritone.api.Settings; import baritone.api.Settings;
import baritone.api.pathing.goals.Goal; import baritone.api.event.events.PathEvent;
import baritone.api.pathing.goals.GoalXZ; import baritone.api.event.listener.AbstractGameEventListener;
import autostore.client.config.ExpansionConfig; import autostore.client.config.ExpansionConfig;
import autostore.client.territory.ChunkPos; import autostore.client.territory.ChunkPos;
import baritone.api.pathing.goals.Goal;
import baritone.api.pathing.goals.GoalXZ;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
@@ -24,22 +28,62 @@ import net.fabricmc.api.Environment;
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public final class BaritoneBridge { public final class BaritoneBridge {
/**
* 寻路事件回调:用于把 Baritone 的路径事件(如计算失败、到达目标)转发给控制器,
* 使控制器能及时识别“目标不可达”并跳过,而不是傻等到超时。
*/
public interface PathListener {
void onPathEvent(PathEvent event);
}
/** 已注册的路径事件监听器集合(线程安全) */
private final Set<PathListener> pathListeners = ConcurrentHashMap.newKeySet();
/** 是否已向 Baritone 注册事件监听器(只注册一次) */
private volatile boolean eventListenerRegistered;
/** 最近一次应用的行为配置(applyConfig 时保存,供 pathTo 选择寻路 Goal) */
private ExpansionConfig config = new ExpansionConfig();
/** 注册一个路径事件监听器;同时确保已向 Baritone 事件总线挂载转发器 */
public void addPathListener(PathListener listener) {
this.pathListeners.add(listener);
this.primary();
}
/** /**
* 将模组配置同步到 Baritone 的全局设置。 * 将模组配置同步到 Baritone 的全局设置。
* *
* @param config 扩地配置 * @param config 扩地配置
*/ */
public void applyConfig(ExpansionConfig config) { public void applyConfig(ExpansionConfig config) {
this.config = config;
Settings settings = BaritoneAPI.getSettings(); Settings settings = BaritoneAPI.getSettings();
settings.allowSprint.value = config.allowSprint; settings.allowSprint.value = config.allowSprint;
settings.allowBreak.value = config.allowBreak; settings.allowBreak.value = config.allowBreak;
settings.allowPlace.value = config.allowPlace; settings.allowPlace.value = config.allowPlace;
settings.allowParkour.value = config.allowJump; // 跳跃(跑酷)前进:开启放置时也允许跳跃搭路,避免过河/过缝时必须纯步行
settings.allowParkour.value = config.allowJump || config.allowPlace;
// 关键修复:开启放置时,配套允许“跑酷搭路放方块”。
// 若仅开 allowPlace 而不给 allowParkourPlace,Baritone 在边缘会把
// “下蹲放方块”与“跳跃过去”两条腿计划打架:想放又判定不该放,
// 于是原地反复下蹲+跳跃却始终放不出方块(用户观察到的“原地抽搐”)。
settings.allowParkourPlace.value = config.allowPlace;
// 降低放方块的路径代价:Baritone 默认对搭路惩罚较高,会倾向犹豫/绕行;
// 归零后它更愿意稳定搭路,而不是在缺口边反复空跳。
settings.blockPlacementPenalty.value = 0.0;
settings.avoidance.value = config.avoidMobs; settings.avoidance.value = config.avoidMobs;
} }
/** /**
* 让 Baritone 寻路前往指定区块的中心。 * 让 Baritone 寻路前往指定区块中心。
*
* <p>默认使用 Baritone <b>原生</b>{@link GoalXZ}(目标区块中心单格):原生 GoalXZ 对
* 可达地形几乎不会误判“目标不可达”,是最好的默认选择。早期用自定义
* {@code ChunkGoal}(曾加入高度硬约束)疑似是“平原上一按 W 就能走、Baritone 却秒
* CALC_FAILED”的根因——自定义 Goal 在某些站位/边界下会把可达目标判成不可达。
* 到达判定仍由控制器 {@code playerChunk.equals(target)} 保证,与 Goal 自身容差无关。</p>
*
* <p>当开启高度偏好({@code altitudePreference})时改用 {@link GoalXZAltitude}:
* 到达判定与 GoalXZ 完全一致(水平到达),仅启发式成本偏向高处,引导寻路优先走高。</p>
* *
* @param chunk 目标区块 * @param chunk 目标区块
*/ */
@@ -48,9 +92,13 @@ public final class BaritoneBridge {
if (baritone == null) { if (baritone == null) {
return; return;
} }
// 使用 GoalXZ 前往区块中心:Baritone 的 GoalXZ 自带约 10 格容差, Goal goal;
// 只需到达区块中心附近即可,避免中心点不可达导致寻路卡死 if (this.config.altitudePreference) {
baritone.getCustomGoalProcess().setGoalAndPath(new GoalXZ(chunk.centerBlockX(), chunk.centerBlockZ())); goal = new GoalXZAltitude(chunk.centerBlockX(), chunk.centerBlockZ(), this.config.altitudeWeight);
} else {
goal = new GoalXZ(chunk.centerBlockX(), chunk.centerBlockZ());
}
baritone.getCustomGoalProcess().setGoalAndPath(goal);
} }
/** 强制取消当前寻路任务。 */ /** 强制取消当前寻路任务。 */
@@ -75,12 +123,30 @@ public final class BaritoneBridge {
/** /**
* 获取 Baritone 主实例;加载失败或异常时返回 null。 * 获取 Baritone 主实例;加载失败或异常时返回 null。
* 首次成功获取时,向 Baritone 事件总线注册一个转发器,
* 把路径事件分发给所有已注册的 {@link PathListener}。
* *
* @return 主 Baritone 实例,或 null * @return 主 Baritone 实例,或 null
*/ */
private IBaritone primary() { private IBaritone primary() {
try { try {
return BaritoneAPI.getProvider().getPrimaryBaritone(); IBaritone baritone = BaritoneAPI.getProvider().getPrimaryBaritone();
if (baritone != null && !this.eventListenerRegistered) {
this.eventListenerRegistered = true;
baritone.getGameEventHandler().registerEventListener(new AbstractGameEventListener() {
@Override
public void onPathEvent(PathEvent event) {
for (PathListener listener : BaritoneBridge.this.pathListeners) {
try {
listener.onPathEvent(event);
} catch (Throwable ignored) {
// 单个监听器异常不影响其他监听器
}
}
}
});
}
return baritone;
} catch (Throwable ignored) { } catch (Throwable ignored) {
return null; return null;
} }
@@ -0,0 +1,51 @@
/*
* 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152),
* 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
*/
package autostore.client.bridge;
import baritone.api.pathing.goals.Goal;
import baritone.api.pathing.goals.GoalXZ;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
/**
* 带高度偏好的区块中心寻路目标。
*
* <p>到达判定与 Baritone 原生 {@link GoalXZ} 完全一致(水平到达目标区块中心即成功,
* 不约束高度,避免旧版 ChunkGoal 因高度硬约束误判“不可达”);
* 但启发式成本中加入高度项:y 越大启发成本越低(A* 更倾向走高处),
* y 越低成本越高(对走低处施惩罚),从而实现“寻路优先往高处走”。</p>
*/
@Environment(EnvType.CLIENT)
public final class GoalXZAltitude implements Goal {
/** 目标区块中心 X */
private final int x;
/** 目标区块中心 Z */
private final int z;
/** 高度偏好权重:每上升 1 格减少的启发成本;越大越明显偏向高处 */
private final double altitudeWeight;
public GoalXZAltitude(int x, int z, double altitudeWeight) {
this.x = x;
this.z = z;
this.altitudeWeight = altitudeWeight;
}
/** 到达判定与 GoalXZ 相同:水平坐标命中目标区块中心即算到达,不限制高度 */
@Override
public boolean isInGoal(int x, int y, int z) {
return this.x == x && this.z == z;
}
/**
* 启发式成本 = 水平基础成本(复用 GoalXZ 的对角线切比雪夫估计) - 高度奖励。
* y 越高启发值越小(优先级越高),y 越启发值越大(惩罚);clamp 非负避免破坏 A* 排序。
*/
@Override
public double heuristic(int x, int y, int z) {
double base = GoalXZ.calculate(x - this.x, z - this.z);
double altitudeCost = this.altitudeWeight * y;
return Math.max(0.0, base - altitudeCost);
}
}
@@ -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>蜘蛛:仅黑暗中(眼睛亮度 &lt; 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;
}
}
@@ -1,6 +1,5 @@
/* /*
* 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), * AutoStore 客户端命令:注册 /autostore 系列命令。
* 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。
*/ */
package autostore.client.command; package autostore.client.command;
@@ -10,11 +9,10 @@ import autostore.client.controller.ExpansionController;
import autostore.client.territory.ChunkPos; import autostore.client.territory.ChunkPos;
import autostore.client.territory.TerritoryState; import autostore.client.territory.TerritoryState;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.ArgumentType;
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 com.mojang.brigadier.context.CommandContext; 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;
@@ -37,6 +35,7 @@ import net.minecraft.text.Text;
* <li>/autostore state &lt;邦国名&gt; —— 设置目标邦国</li> * <li>/autostore state &lt;邦国名&gt; —— 设置目标邦国</li>
* <li>/autostore strategy &lt;1-4&gt; —— 设置扩地策略</li> * <li>/autostore strategy &lt;1-4&gt; —— 设置扩地策略</li>
* <li>/autostore rings &lt;层数&gt; —— 设置扩圈上限(0 为持续)</li> * <li>/autostore rings &lt;层数&gt; —— 设置扩圈上限(0 为持续)</li>
* <li>/autostore log —— 导出运行日志到文件</li>
* </ul> * </ul>
*/ */
public final class AutoStoreCommands { public final class AutoStoreCommands {
@@ -45,70 +44,158 @@ public final class AutoStoreCommands {
/** 注册全部 /autostore 子命令到命令分发器 */ /** 注册全部 /autostore 子命令到命令分发器 */
public static void register(CommandDispatcher<FabricClientCommandSource> dispatcher, AutoStoreModClient mod) { public static void register(CommandDispatcher<FabricClientCommandSource> dispatcher, AutoStoreModClient mod) {
dispatcher.register((((((((((ClientCommandManager.literal((String)"autostore").then(ClientCommandManager.literal((String)"gui").executes(ctx -> { LiteralArgumentBuilder<FabricClientCommandSource> root = ClientCommandManager.literal("autostore");
(ctx.getSource()).sendFeedback(Text.literal((String)"\u5df2\u6253\u5f00 AutoStore GUI"));
// 打开 GUI
root.then(ClientCommandManager.literal("gui").executes(ctx -> {
ctx.getSource().sendFeedback(Text.literal("已打开 AutoStore GUI"));
mod.openGui(); mod.openGui();
return 1; return 1;
}))).then((ClientCommandManager.literal((String)"start").executes(ctx -> AutoStoreCommands.start(ctx.getSource(), mod, mod.config().targetCount, mod.config().strategy))).then(ClientCommandManager.argument((String)"count", IntegerArgumentType.integer()).executes(ctx -> AutoStoreCommands.start(ctx.getSource(), mod, IntegerArgumentType.getInteger(ctx, (String)"count"), mod.config().strategy))))).then(ClientCommandManager.literal((String)"stop").executes(ctx -> { }));
// 开始扩地(可选数量参数)
root.then(ClientCommandManager.literal("start")
.executes(ctx -> start(ctx.getSource(), mod, mod.config().targetCount, mod.config().strategy))
.then(ClientCommandManager.argument("count", IntegerArgumentType.integer())
.executes(ctx -> start(ctx.getSource(), mod, IntegerArgumentType.getInteger(ctx, "count"), mod.config().strategy))));
// 停止扩地
root.then(ClientCommandManager.literal("stop").executes(ctx -> {
mod.controller().stop(); mod.controller().stop();
(ctx.getSource()).sendFeedback(Text.literal((String)"\u5df2\u505c\u6b62\u81ea\u52a8\u6269\u5730")); ctx.getSource().sendFeedback(Text.literal("已停止自动扩地"));
return 1; return 1;
}))).then(ClientCommandManager.literal((String)"status").executes(ctx -> AutoStoreCommands.status(ctx.getSource(), mod)))).then(ClientCommandManager.literal((String)"refresh").executes(ctx -> { }));
// 查看状态
root.then(ClientCommandManager.literal("status").executes(ctx -> status(ctx.getSource(), mod)));
// 刷新地图
root.then(ClientCommandManager.literal("refresh").executes(ctx -> {
mod.dataService().refreshAsync(); mod.dataService().refreshAsync();
(ctx.getSource()).sendFeedback(Text.literal((String)"\u6b63\u5728\u5237\u65b0\u90a6\u56fd\u5730\u56fe")); ctx.getSource().sendFeedback(Text.literal("正在刷新邦国地图"));
return 1; return 1;
}))).then(ClientCommandManager.literal((String)"auto").executes(ctx -> { }));
TerritoryState state = mod.dataService().model().findContainingState(AutoStoreCommands.playerChunk(ctx.getSource()));
if (state == null) { // 自动识别邦国:先用 Dynmap 数据按所在区块判断,失败时回退 SimpCore API 按玩家名查询
(ctx.getSource()).sendError(Text.literal((String)"\u65e0\u6cd5\u8bc6\u522b\u5f53\u524d\u90a6\u56fd")); root.then(ClientCommandManager.literal("auto").executes(ctx -> {
} else { ChunkPos chunk = playerChunk(ctx.getSource());
TerritoryState state = mod.dataService().model().findContainingState(chunk);
if (state != null) {
mod.config().stateName = state.name(); mod.config().stateName = state.name();
mod.saveConfig(); mod.saveConfig();
(ctx.getSource()).sendFeedback(Text.literal(("\u5df2\u81ea\u52a8\u8bc6\u522b\u90a6\u56fd\uff1a" + 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;
}))).then(ClientCommandManager.literal((String)"state").then(ClientCommandManager.argument((String)"name", StringArgumentType.greedyString()).executes(ctx -> { }));
String name;
mod.config().stateName = name = StringArgumentType.getString(ctx, (String)"name"); // ===== SimpCore 开放 API 命令组 =====
registerApiCommands(root, mod);
// 设置邦国
root.then(ClientCommandManager.literal("state").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> {
String name = StringArgumentType.getString(ctx, "name");
mod.config().stateName = name;
mod.saveConfig(); mod.saveConfig();
(ctx.getSource()).sendFeedback(Text.literal(("\u5df2\u8bbe\u7f6e\u90a6\u56fd\uff1a" + name))); ctx.getSource().sendFeedback(Text.literal("已设置邦国:" + name));
return 1;
})))).then(ClientCommandManager.literal((String)"strategy").then(ClientCommandManager.argument((String)"strategy", IntegerArgumentType.integer((int)1, (int)4)).executes(ctx -> {
int strategy;
mod.config().strategy = strategy = IntegerArgumentType.getInteger(ctx, (String)"strategy");
mod.saveConfig();
(ctx.getSource()).sendFeedback(Text.literal(("\u5df2\u8bbe\u7f6e\u6269\u5f20\u7b56\u7565\uff1a" + strategy)));
return 1;
})))).then(ClientCommandManager.literal((String)"rings").then(ClientCommandManager.argument((String)"rings", IntegerArgumentType.integer((int)0)).executes(ctx -> {
int rings;
mod.config().maxRings = rings = IntegerArgumentType.getInteger(ctx, (String)"rings");
mod.saveConfig();
(ctx.getSource()).sendFeedback(Text.literal((rings <= 0 ? "\u5df2\u8bbe\u7f6e\u6301\u7eed\u6269\u5730" : "\u5df2\u8bbe\u7f6e\u6269\u5708\u4e0a\u9650\uff1a" + rings)));
return 1;
})))).then(ClientCommandManager.literal((String)"help").executes(ctx -> {
(ctx.getSource()).sendFeedback(Text.literal((String)"/autostore help \u663e\u793a\u5e2e\u52a9\n/autostore gui \u6253\u5f00 GUI\uff08\u6216\u6309 F6 \u5feb\u6377\u952e\uff09\n/autostore start \u5f00\u59cb\u6269\u5730\n/autostore start <\u6570\u91cf> \u6307\u5b9a\u76ee\u6807\u6570\u91cf\uff0c-1 \u4e3a\u6301\u7eed\n/autostore stop \u505c\u6b62\n/autostore status \u67e5\u770b\u72b6\u6001\n/autostore refresh \u5237\u65b0\u5730\u56fe\n/autostore auto \u81ea\u52a8\u8bc6\u522b\u90a6\u56fd\n/autostore state <\u90a6\u56fd\u540d> \u8bbe\u7f6e\u90a6\u56fd\n/autostore strategy <1|2|3|4> \u8bbe\u7f6e\u7b56\u7565\n/autostore rings <\u5c42\u6570> \u8bbe\u7f6e\u5708\u6570\u4e0a\u9650"));
return 1; return 1;
}))); })));
// 设置策略
root.then(ClientCommandManager.literal("strategy").then(ClientCommandManager.argument("strategy", IntegerArgumentType.integer(1, 4)).executes(ctx -> {
int strategy = IntegerArgumentType.getInteger(ctx, "strategy");
mod.config().strategy = strategy;
mod.saveConfig();
ctx.getSource().sendFeedback(Text.literal("已设置扩张策略:" + strategy));
return 1;
})));
// 设置圈数上限
root.then(ClientCommandManager.literal("rings").then(ClientCommandManager.argument("rings", IntegerArgumentType.integer(0)).executes(ctx -> {
int rings = IntegerArgumentType.getInteger(ctx, "rings");
mod.config().maxRings = rings;
mod.saveConfig();
ctx.getSource().sendFeedback(Text.literal(rings <= 0 ? "已设置持续扩地" : "已设置扩圈上限:" + rings));
return 1;
})));
// 帮助
root.then(ClientCommandManager.literal("help").executes(ctx -> {
ctx.getSource().sendFeedback(Text.literal(
"/autostore help 显示帮助\n" +
"/autostore gui 打开 GUI(或按 Ctrl+R 快捷键)\n" +
"/autostore start 开始扩地\n" +
"/autostore start <数量> 指定目标数量,-1 为持续\n" +
"/autostore stop 停止\n" +
"/autostore status 查看状态\n" +
"/autostore refresh 刷新地图\n" +
"/autostore auto 自动识别邦国\n" +
"/autostore state <邦国名> 设置邦国\n" +
"/autostore strategy <1|2|3|4> 设置策略\n" +
"/autostore rings <层数> 设置圈数上限\n" +
"/autostore api status 查看 API 状态\n" +
"/autostore api key <key> 设置 API Key\n" +
"/autostore api on|off 启用/停用 API 数据源\n" +
"/autostore api refresh 立即拉取 API 领土数据\n" +
"/autostore api full 拉取全服领土(重接口)\n" +
"/autostore api union <邦国名> 查询邦国信息\n" +
"/autostore log 导出日志"));
return 1;
}));
// 导出日志
root.then(ClientCommandManager.literal("log").executes(ctx -> {
java.nio.file.Path path = mod.controller().exportLogs();
if (path != null) {
ctx.getSource().sendFeedback(Text.literal("日志已导出:" + path));
} else {
ctx.getSource().sendError(Text.literal("日志导出失败"));
}
return 1;
}));
dispatcher.register(root);
} }
/** /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;
mod.config().strategy = strategy; mod.config().strategy = strategy;
mod.saveConfig(); mod.saveConfig();
boolean started = mod.controller().start(config); boolean started = mod.controller().start(config);
source.sendFeedback(Text.literal((started ? "\u5f00\u59cb\u81ea\u52a8\u6269\u5730" : "\u542f\u52a8\u5931\u8d25\uff1a" + mod.controller().status()))); source.sendFeedback(Text.literal(started ? "开始自动扩地" : "启动失败:" + mod.controller().status()));
return started ? 1 : 0; return started ? 1 : 0;
} }
/** /autostore status 命令处理:汇总状态、进度、队列、策略与邦国信息并反馈 */ /** /autostore status 命令处理:汇总状态、进度、队列、策略与邦国信息并反馈 */
private static int status(FabricClientCommandSource source, AutoStoreModClient mod) { private static int status(FabricClientCommandSource source, AutoStoreModClient mod) {
ExpansionController controller = mod.controller(); ExpansionController controller = mod.controller();
String text = "\u72b6\u6001\uff1a" + controller.status() + " | \u8fdb\u5ea6\uff1a" + controller.completedClaims() + (mod.config().targetCount > 0 ? "/" + mod.config().targetCount : "/\u6301\u7eed") + " | \u961f\u5217\uff1a" + controller.queuedTargets() + " | \u7b56\u7565\uff1a" + mod.config().strategy + " | \u90a6\u56fd\uff1a" + (mod.config().stateName.isEmpty() ? "\u81ea\u52a8" : mod.config().stateName); String text = "状态:" + controller.status()
source.sendFeedback(Text.literal((String)text)); + " | 进度:" + controller.completedClaims()
+ (mod.config().targetCount > 0 ? "/" + mod.config().targetCount : "/持续")
+ " | 队列:" + controller.queuedTargets()
+ " | 策略:" + mod.config().strategy
+ " | 邦国:" + (mod.config().stateName.isEmpty() ? "自动" : mod.config().stateName);
source.sendFeedback(Text.literal(text));
return 1; return 1;
} }
@@ -117,5 +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 &lt;key&gt; —— 设置并启用 API Key</li>
* <li>/autostore api on / off —— 启用/停用 API 数据源</li>
* <li>/autostore api refresh —— 立即拉取己方邦国 API 领土数据</li>
* <li>/autostore api full —— 手动拉取全服领土(重接口,慎用)</li>
* <li>/autostore api union &lt;邦国名&gt; —— 查询某邦国在 API 中的区块数</li>
* </ul>
*/
private static void registerApiCommands(LiteralArgumentBuilder<FabricClientCommandSource> root, AutoStoreModClient mod) {
LiteralArgumentBuilder<FabricClientCommandSource> api = ClientCommandManager.literal("api");
// api status
api.then(ClientCommandManager.literal("status").executes(ctx -> {
var client = mod.dataService().apiClient();
StringBuilder sb = new StringBuilder("API 状态:");
sb.append(mod.dataService().isApiEnabled() ? "已启用" : "未启用");
sb.append(" | Key:").append(client.hasApiKey() ? "已配置(" + maskKey(client.getApiKey()) + ")" : "未配置");
if (mod.dataService().lastApiSuccessMillis() > 0) {
long seconds = (System.currentTimeMillis() - mod.dataService().lastApiSuccessMillis()) / 1000L;
sb.append("\n最近成功拉取:").append(seconds >= 60 ? seconds / 60 + " 分钟前" : seconds + " 秒前");
} else {
sb.append("\n最近成功拉取:从未");
}
Map<String, java.util.Set<ChunkPos>> snapshot = mod.dataService().apiTerritorySnapshot(); int total = 0;
for (java.util.Set<ChunkPos> chunks : snapshot.values()) {
total += chunks.size();
}
sb.append(" | API 邦国数:").append(snapshot.size()).append(" | API 区块数:").append(total);
if (!client.lastError().isEmpty()) {
sb.append("\n最近错误:").append(client.lastError());
}
ctx.getSource().sendFeedback(Text.literal(sb.toString()));
return 1;
}));
// api key <key>
api.then(ClientCommandManager.literal("key").then(ClientCommandManager.argument("key", StringArgumentType.greedyString()).executes(ctx -> {
String key = StringArgumentType.getString(ctx, "key").trim();
if (key.isEmpty()) {
ctx.getSource().sendError(Text.literal("Key 不能为空"));
return 0;
}
mod.config().apiKey = key;
mod.config().apiEnabled = true;
mod.saveConfig();
ctx.getSource().sendFeedback(Text.literal("API Key 已设置并启用(" + maskKey(key) + ")"));
return 1;
})));
// api on / off
api.then(ClientCommandManager.literal("on").executes(ctx -> {
if (mod.config().apiKey.isBlank()) {
ctx.getSource().sendError(Text.literal("请先用 /autostore api key <key> 设置 API Key"));
return 0;
}
mod.config().apiEnabled = true;
mod.saveConfig();
ctx.getSource().sendFeedback(Text.literal("API 数据源已启用,轮询间隔 " + mod.config().apiRefreshIntervalSeconds + " 秒"));
return 1;
}));
api.then(ClientCommandManager.literal("off").executes(ctx -> {
mod.config().apiEnabled = false;
mod.saveConfig();
ctx.getSource().sendFeedback(Text.literal("API 数据源已停用"));
return 1;
}));
// api refresh
api.then(ClientCommandManager.literal("refresh").executes(ctx -> {
if (!mod.dataService().apiClient().hasApiKey()) {
ctx.getSource().sendError(Text.literal("未配置 API Key,请先用 /autostore api key <key> 设置"));
return 0;
}
ctx.getSource().sendFeedback(Text.literal("正在拉取 API 领土数据(邦国:" + (mod.dataService().selectedStateName().isEmpty() ? "未选择" : mod.dataService().selectedStateName()) + ")"));
mod.dataService().refreshApiAsync();
return 1;
}));
// api full(重接口,手动触发)
api.then(ClientCommandManager.literal("full").executes(ctx -> {
ctx.getSource().sendFeedback(Text.literal("正在拉取全服领土数据(数据量大,可能需要较长时间)…"));
mod.dataService().fetchAllTerritoryOnce(error -> net.minecraft.client.MinecraftClient.getInstance().execute(() -> {
if (error == null) {
ctx.getSource().sendFeedback(Text.literal("全服领土数据拉取完成,已合并入本地模型"));
} else {
ctx.getSource().sendError(Text.literal("全服领土拉取失败:" + error));
}
}));
return 1;
}));
// api union <邦国名>
api.then(ClientCommandManager.literal("union").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> {
String name = StringArgumentType.getString(ctx, "name").trim();
var client = mod.dataService().apiClient();
if (!client.hasApiKey()) {
ctx.getSource().sendError(Text.literal("未配置 API Key,请先用 /autostore api key <key> 设置"));
return 0;
}
ctx.getSource().sendFeedback(Text.literal("正在查询邦国:" + name));
// 网络请求不能阻塞主线程:在 API 调度线程执行,结果回切主线程反馈
net.minecraft.client.MinecraftClient.getInstance().execute(() ->
java.util.concurrent.CompletableFuture.runAsync(() -> {
var union = client.getUnion(name);
net.minecraft.client.MinecraftClient.getInstance().execute(() -> {
if (union == null) {
ctx.getSource().sendError(Text.literal("API 未找到该邦国:" + name
+ (client.lastError().isEmpty() ? "" : "(" + client.lastError() + ")")));
} else {
ctx.getSource().sendFeedback(Text.literal(
"邦国:" + union.name() + "(id=" + union.id() + ")"
+ " | 君主:" + union.leader()
+ " | 成员:" + union.memberCount()
+ " | 区块:" + union.chunkCount()
+ " | 面积:" + union.blockArea()
+ " | 特产:" + union.specialOutput()));
}
});
}));
return 1;
})));
root.then(api);
}
/** API Key 打码显示:保留前 8 位与后 4 位,中间以 … 代替 */
private static String maskKey(String key) {
if (key == null || key.length() <= 12) {
return "***";
}
return key.substring(0, 8) + "…" + key.substring(key.length() - 4);
}
}
@@ -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) {
// 解析失败时回退到默认配置 // 解析失败时回退到默认配置
@@ -31,6 +31,15 @@ public class ExpansionConfig {
/** 超时后的最大重试次数 */ /** 超时后的最大重试次数 */
public int retries = 2; public int retries = 2;
/** 连续寻路超时累计达到该秒数后,发送 /u return 回城传送并重新开始(默认 60 秒) */
public int returnThresholdSeconds = 60;
/** 发送 /u return 后等待传送完成的时间(秒);默认 12 秒,覆盖 10 秒传送延迟并留余量 */
public int returnWaitSeconds = 12;
/** 单个目标最多触发"往边上走两步"自救的轮数;每轮走两步后仍超时再重试,多轮无效后才回城/切换目标(默认 3) */
public int sideStepMaxRounds = 3;
/** 区域扩地策略的中心 X 坐标(区块) */ /** 区域扩地策略的中心 X 坐标(区块) */
public int regionCenterX; public int regionCenterX;
@@ -67,6 +76,12 @@ public class ExpansionConfig {
/** 是否允许 Baritone 放置方块 */ /** 是否允许 Baritone 放置方块 */
public boolean allowPlace; public boolean allowPlace;
/** 寻路高度偏好:true 时寻路优先往高处走(y 越大启发成本越低/优先级越高,y 越小施惩罚) */
public boolean altitudePreference = true;
/** 高度偏好权重:每上升 1 格折算的启发成本减少量(越大越明显偏向高处,默认 0.1) */
public double altitudeWeight = 0.1;
/** 是否启用避开怪物 */ /** 是否启用避开怪物 */
public boolean avoidMobs = true; public boolean avoidMobs = true;
@@ -76,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;
/** /**
* 深拷贝当前配置,返回一个独立的副本。 * 深拷贝当前配置,返回一个独立的副本。
* *
@@ -94,6 +142,9 @@ public class ExpansionConfig {
c.targetCount = this.targetCount; c.targetCount = this.targetCount;
c.waitTimeoutSeconds = this.waitTimeoutSeconds; c.waitTimeoutSeconds = this.waitTimeoutSeconds;
c.retries = this.retries; c.retries = this.retries;
c.returnThresholdSeconds = this.returnThresholdSeconds;
c.returnWaitSeconds = this.returnWaitSeconds;
c.sideStepMaxRounds = this.sideStepMaxRounds;
c.regionCenterX = this.regionCenterX; c.regionCenterX = this.regionCenterX;
c.regionCenterZ = this.regionCenterZ; c.regionCenterZ = this.regionCenterZ;
c.regionRadius = this.regionRadius; c.regionRadius = this.regionRadius;
@@ -106,11 +157,22 @@ public class ExpansionConfig {
c.allowJump = this.allowJump; c.allowJump = this.allowJump;
c.allowBreak = this.allowBreak; c.allowBreak = this.allowBreak;
c.allowPlace = this.allowPlace; c.allowPlace = this.allowPlace;
c.altitudePreference = this.altitudePreference;
c.altitudeWeight = this.altitudeWeight;
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,13 +5,22 @@
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;
import autostore.client.territory.TerritoryModel; import autostore.client.territory.TerritoryModel;
import autostore.client.territory.TerritoryState; import autostore.client.territory.TerritoryState;
import baritone.api.event.events.PathEvent;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Deque; import java.util.Deque;
@@ -20,13 +29,19 @@ import java.util.List;
import java.util.Set; import java.util.Set;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
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.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.component.DataComponentTypes; import net.minecraft.component.DataComponentTypes;
import net.minecraft.text.Text;
@Environment(value=EnvType.CLIENT) @Environment(value=EnvType.CLIENT)
/** /**
@@ -34,16 +49,29 @@ import net.minecraft.component.DataComponentTypes;
* *
* <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 { implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
/** 日志环形缓冲区的最大条数 */ /** 日志环形缓冲区的最大条数(含导出用详细日志,故调大) */
private static final int MAX_LOGS = 80; private static final int MAX_LOGS = 2000;
/** 日志时间戳格式(时:分:秒) */
private static final DateTimeFormatter LOG_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
/** 允许开拓的现实时间起始小时(含,本地时区) */
private static final int CLAIM_START_HOUR = 11;
/** 允许开拓的现实时间结束小时(不含,本地时区),即 22:00 起停止开拓 */
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);
/** 领土数据服务(提供实时领土模型) */ /** 领土数据服务(提供实时领土模型) */
@@ -76,9 +104,39 @@ implements SubtitleChatMonitor.Listener {
private final Set<ChunkPos> claimedSet = new HashSet<>(); private final Set<ChunkPos> claimedSet = new HashSet<>();
/** 是否正在自动进食 */ /** 是否正在自动进食 */
private boolean eating; private boolean eating;
/** Baritone 最近一次路径计算是否失败(目标不可达);由事件监听设置,寻路阶段消费 */
private volatile boolean pathCalcFailed;
/** 等待开拓时段自动开始:不在 11:00-22:00 时点击开始会暂存配置,进入时段后自动启动(null 表示无等待任务) */
private ExpansionConfig pendingStart;
/** 连续寻路超时累计时长(毫秒):多次寻路超时逐次累加,达到阈值触发 /u return 回城传送 */
private long consecutiveTimeoutMillis;
/** 发送 /u return 回城指令的时间戳(RETURNING 状态使用,用于等待传送完成) */
private long returnStartMillis;
/** 当前目标已触发"往边上走两步"的次数(每次重试耗尽先走两步再试,达上限才切换目标/回城) */
private int sideStepCount;
/** 是否正在"往边上走两步"阶段(手动向目标连线垂直方向走开约 2.5 格再重试) */
private boolean sideStepping;
/** 边走两步的方向符号(+1 左侧 / -1 右侧),被挡时换另一侧 */
private int sideStepSign = 1;
/** 边走两步的整体超时截止时间戳(毫秒) */
private long sideStepDeadlineMillis;
/** 最近一次换向/开始的时间戳(毫秒,用于判断该方向是否卡住) */
private long sideStepSinceMillis;
/** 边走两步开始时的玩家方块坐标(用于判断是否已走开约 2.5 格) */
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;
// 注册路径事件监听,及时识别“目标不可达”并跳过(避免大山/悬崖地形傻等超时)
this.baritoneBridge.addPathListener(this);
} }
public SubtitleChatMonitor monitor() { public SubtitleChatMonitor monitor() {
@@ -121,6 +179,22 @@ implements SubtitleChatMonitor.Listener {
return this.state != State.IDLE && this.state != State.COMPLETE && this.state != State.STOPPED; return this.state != State.IDLE && this.state != State.COMPLETE && this.state != State.STOPPED;
} }
/** @return 是否处于"等待开拓时段自动开始"状态 */
public boolean isWaitingForClaimTime() {
return this.pendingStart != null;
}
/**
* 判断当前现实时间(本地时区)是否在允许开拓的时间段内。
* 仅 11:00(含)到 22:00(不含)允许开拓,其余时段服务器会拒绝开拓。
*
* @return true 表示当前在允许开拓时段内
*/
public static boolean isWithinClaimTime() {
int hour = LocalTime.now().getHour();
return hour >= CLAIM_START_HOUR && hour < CLAIM_END_HOUR;
}
/** /**
* 启动自动扩地任务。 * 启动自动扩地任务。
* *
@@ -141,6 +215,16 @@ implements SubtitleChatMonitor.Listener {
this.status = "Baritone \u5c1a\u672a\u521d\u59cb\u5316\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5"; this.status = "Baritone \u5c1a\u672a\u521d\u59cb\u5316\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5";
return false; return false;
} }
// 现实时间限制:仅 11:00-22:00 允许开拓,其余时段服务器会拒绝开拓(等待超时)。
// 不在时段时不再直接拒绝,而是登记"等待自动开始":进入时段后由 tick() 自动启动。
if (!ExpansionController.isWithinClaimTime()) {
this.pendingStart = requestedConfig.copy();
this.setState(State.IDLE, "\u7b49\u5f85\u5f00\u62d3\u65f6\u6bb5\uff0c11:00 \u81ea\u52a8\u5f00\u59cb");
this.log("\u4e0d\u5728\u5f00\u62d3\u65f6\u6bb5\u5185\uff0811:00-22:00\uff09\uff0c\u5df2\u767b\u8bb0\u81ea\u52a8\u5f00\u59cb\uff0c\u5f53\u524d\u73b0\u5b9e\u65f6\u95f4 " + LocalTime.now().format(LOG_TIME_FORMAT));
// 给玩家发送聊天栏提示,避免玩家未打开 GUI 时看不到原因
client.player.sendMessage(Text.literal("[AutoStore] \u4e0d\u5728\u5f00\u62d3\u65f6\u6bb5\u5185\uff0811:00-22:00\uff09\uff0c\u5df2\u767b\u8bb0\uff1a\u8fdb\u5165\u65f6\u6bb5\u540e\u81ea\u52a8\u5f00\u59cb"), false);
return true;
}
this.config = requestedConfig.copy(); this.config = requestedConfig.copy();
this.model = this.dataService.model(); this.model = this.dataService.model();
this.playerChunk = this.playerChunk(client); this.playerChunk = this.playerChunk(client);
@@ -164,6 +248,12 @@ implements SubtitleChatMonitor.Listener {
} }
this.completedClaims = 0; this.completedClaims = 0;
this.retryCount = 0; this.retryCount = 0;
this.consecutiveTimeoutMillis = 0L;
this.sideStepCount = 0;
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;
@@ -175,12 +265,26 @@ implements SubtitleChatMonitor.Listener {
return true; return true;
} }
/** 手动停止任务:取消 Baritone 寻路并进入 STOPPED 状态 */ /** 手动停止任务:取消 Baritone 寻路并进入 STOPPED 状态;若处于等待自动开始,则取消等待 */
public void stop() { public void stop() {
if (this.pendingStart != null) {
this.pendingStart = null;
this.setState(State.IDLE, "\u5df2\u53d6\u6d88\u81ea\u52a8\u5f00\u59cb");
this.log("\u5df2\u53d6\u6d88\u7b49\u5f85\u81ea\u52a8\u5f00\u59cb");
return;
}
if (this.state == State.IDLE || this.state == State.COMPLETE || this.state == State.STOPPED) { if (this.state == State.IDLE || this.state == State.COMPLETE || this.state == State.STOPPED) {
return; return;
} }
this.baritoneBridge.cancel(); this.baritoneBridge.cancel();
// 若正处于"往边上走两步"阶段,先释放手动按下的前进键
if (this.sideStepping) {
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");
@@ -188,8 +292,8 @@ implements SubtitleChatMonitor.Listener {
/** /**
* 每帧驱动逻辑(由客户端 tick 事件调用): * 每帧驱动逻辑(由客户端 tick 事件调用):
* 更新玩家区块,优先处理自动进食,再按当前状态分发到对应的 tick 方法。 * 更新玩家区块,依次处理自动进食、耐久保护、自动游泳、自动攻击,
* 若已退出世界则自动停止任务。 * 再按当前状态分发到对应的 tick 方法。若已退出世界则自动停止任务。
*/ */
public void tick() { public void tick() {
MinecraftClient client = MinecraftClient.getInstance(); MinecraftClient client = MinecraftClient.getInstance();
@@ -201,35 +305,68 @@ implements SubtitleChatMonitor.Listener {
return; return;
} }
this.playerChunk = this.playerChunk(client); this.playerChunk = this.playerChunk(client);
// 等待开拓时段自动开始:进入 11:00-22:00 后自动启动已登记的任务
if (this.pendingStart != null && ExpansionController.isWithinClaimTime()) {
ExpansionConfig pending = this.pendingStart;
this.pendingStart = null;
this.log("\u5df2\u8fdb\u5165\u5f00\u62d3\u65f6\u6bb5\uff0c\u81ea\u52a8\u5f00\u59cb");
this.start(pending);
}
// 现实时间限制:运行中超出 11:00-22:00 则自动停止,避免服务器拒绝开拓导致超时
if (this.isRunning() && !ExpansionController.isWithinClaimTime()) {
this.stop();
this.status = "\u5df2\u5230\u5f00\u62d3\u65f6\u9650\u5916\uff0811:00-22:00\uff09\uff0c\u4efb\u52a1\u505c\u6b62";
this.log("\u5df2\u5230\u5f00\u62d3\u65f6\u9650\u5916\uff0c\u505c\u6b62\u4efb\u52a1\uff08\u5f53\u524d\u73b0\u5b9e\u65f6\u95f4 " + LocalTime.now().format(LOG_TIME_FORMAT) + "\uff09");
client.player.sendMessage(Text.literal("[AutoStore] \u5df2\u5230\u5f00\u62d3\u65f6\u9650\u5916\uff0811:00-22:00\uff09\uff0c\u4efb\u52a1\u505c\u6b62"), false);
return;
}
if (this.tickAutoFood(client)) { if (this.tickAutoFood(client)) {
return; return;
} }
// 耐久保护优先于一切寻路/战斗:装备耐久过低时先回城,防止装备被怪物磨没
if (this.tickDurabilityGuard(client)) {
return;
}
this.tickAutoSwim(client); this.tickAutoSwim(client);
switch (this.state.ordinal()) { // 自动用船过水:开阔水域自动放船登船、上岸前自动下船回收
case 1: { this.tickAutoBoat(client);
// 处于"往边上走两步"阶段:每帧驱动手动横向行走,走开后自动重试原目标(该阶段不自动攻击,避免抢方向键)
if (this.sideStepping) {
this.tickSideStep();
return;
}
// 自动攻击:面向并攻击范围内的敌对生物(不追击,不打断寻路状态机)
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 RETURNING: {
this.tickReturning();
break;
}
// IDLE / COMPLETE / STOPPED 无需每帧驱动
} }
} }
@@ -268,16 +405,22 @@ implements SubtitleChatMonitor.Listener {
/** 让 Baritone 寻路前往当前目标区块,并进入 PATHING 状态 */ /** 让 Baritone 寻路前往当前目标区块,并进入 PATHING 状态 */
private void startPathing() { private void startPathing() {
// 寻路前先校验目标区块是否仍与己方领土接壤(地图异步刷新后可能已不接壤),不接壤则直接跳过
if (this.skipIfTargetNotAdjacent()) {
return;
}
this.retryCount = 0; this.retryCount = 0;
this.sideStepCount = 0;
this.stageStartedMillis = System.currentTimeMillis(); this.stageStartedMillis = System.currentTimeMillis();
this.lastProgress = 0; this.lastProgress = 0;
this.baritoneBridge.pathTo(this.target); this.baritoneBridge.pathTo(this.target);
this.setState(State.PATHING, "\u6b63\u5728\u524d\u5f80\u76ee\u6807\u533a\u5757 " + this.target.x() + "," + this.target.z()); this.setState(State.PATHING, "\u6b63\u5728\u524d\u5f80\u76ee\u6807\u533a\u5757 " + this.target.x() + "," + this.target.z());
this.log("\u5bfb\u8def\u524d\u5f80 " + this.target.x() + "," + this.target.z() + "\uff08\u73a9\u5bb6\u533a\u5757 " + this.playerChunk.x() + "," + this.playerChunk.z() + "\uff09");
} }
/** /**
* 寻路阶段每帧逻辑:到达目标区块后进入等待开垦状态; * 寻路阶段每帧逻辑:到达目标区块后进入等待开垦状态;
* 若寻路超时则重试,超过重试次数则跳过该目标。 * 若寻路超时则重新寻路;重试耗尽先"往边上走两步"再重试,仍失败则回城(连续超时达阈值)或跳过该目标。
*/ */
private void tickPathing() { private void tickPathing() {
if (this.target == null) { if (this.target == null) {
@@ -288,27 +431,197 @@ implements SubtitleChatMonitor.Listener {
if (this.skipIfTargetClaimed("\u76ee\u6807\u5df2\u88ab\u5360\u9886")) { if (this.skipIfTargetClaimed("\u76ee\u6807\u5df2\u88ab\u5360\u9886")) {
return; return;
} }
// Baritone 报告路径计算失败(目标不可达,如被悬崖/高山/水体完全隔断)。
// 不立即跳过:CALC_FAILED 常因瞬时能力限制或起点卡位误报,先重新寻路一次
//(相当于“往边上走两步再算一遍”),重试耗尽才跳过当前目标。
if (this.pathCalcFailed) {
this.pathCalcFailed = false;
++this.retryCount;
if (this.retryCount >= this.config.retries) {
// 重试耗尽:不直接切换目标/回城,先"往边上走两步"再重试原目标;可反复多轮,达上限才交给回城/切换
if (this.sideStepCount < this.config.sideStepMaxRounds) {
this.startSideStep();
} else {
this.giveUpTarget("\u5bfb\u8def\u5931\u8d25\uff08\u76ee\u6807\u4e0d\u53ef\u8fbe\uff09");
}
} else {
this.log("\u5bfb\u8def\u5931\u8d25\uff08\u76ee\u6807\u4e0d\u53ef\u8fbe\uff09\uff0c\u91cd\u65b0\u5bfb\u8def " + this.retryCount + "/" + this.config.retries);
this.dataService.refreshAsync();
this.retrySameTarget("\u5bfb\u8def\u5931\u8d25\uff0c\u91cd\u65b0\u5bfb\u8def");
}
return;
}
// 目标已不再与己方领土接壤(地图刷新后边界变化):立即跳过,避免走到无效区块
if (this.skipIfTargetNotAdjacent()) {
return;
}
if (this.playerChunk.equals(this.target)) { if (this.playerChunk.equals(this.target)) {
this.baritoneBridge.cancel(); this.baritoneBridge.cancel();
// 成功到达目标:寻路不再卡死,清零连续超时计数
this.consecutiveTimeoutMillis = 0L;
this.stageStartedMillis = System.currentTimeMillis(); this.stageStartedMillis = System.currentTimeMillis();
this.setState(State.WAITING, "\u5df2\u5230\u8fbe\uff0c\u7b49\u5f85\u9886\u5730\u5f00\u62d3"); this.setState(State.WAITING, "\u5df2\u5230\u8fbe\uff0c\u7b49\u5f85\u9886\u5730\u5f00\u62d3");
this.log("\u5df2\u5230\u8fbe\u76ee\u6807 " + this.target.x() + "," + this.target.z() + "\uff0c\u5f00\u59cb\u7b49\u5f85\u5f00\u57a6");
return; return;
} }
long elapsedSeconds = (System.currentTimeMillis() - this.stageStartedMillis) / 1000L; long elapsedSeconds = (System.currentTimeMillis() - this.stageStartedMillis) / 1000L;
if (elapsedSeconds >= this.config.waitTimeoutSeconds) { if (elapsedSeconds >= this.config.waitTimeoutSeconds) {
++this.retryCount; ++this.retryCount;
// 主动刷新地图,尽快同步"区块已被占领"的最新状态,避免玩家被困 // 主动刷新地图,尽快同步最新领土状态
this.dataService.refreshAsync(); this.dataService.refreshAsync();
if (this.retryCount >= this.config.retries) { // 累计"连续寻路超时"时长:连续多次寻路都超时说明玩家被地形/缝隙卡死走不到目标,
this.log("\u5bfb\u8def\u8d85\u65f6\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z()); // 达到阈值后作为发送 /u return 回城(约 10 秒后传送到可寻路位置)的最后手段,平时不打断重试/走两步。
this.setState(State.FAIL, "\u5bfb\u8def\u8d85\u65f6"); this.consecutiveTimeoutMillis += (long)this.config.waitTimeoutSeconds * 1000L;
if (this.retryCount < this.config.retries) {
// 未耗尽:重新寻路(而非回城),取消旧路径并重新计算,往往"在边上走走"就能走出困境
this.log("\u5bfb\u8def\u8d85\u65f6\uff0c\u91cd\u65b0\u5bfb\u8def " + this.retryCount + "/" + this.config.retries);
this.retrySameTarget("\u5bfb\u8def\u8d85\u65f6\uff0c\u91cd\u65b0\u5bfb\u8def");
} else if (this.sideStepCount < this.config.sideStepMaxRounds) {
// 重试耗尽:先不切换目标/回城,用"往边上走两步"再重试一次(走开障碍后重新寻路原目标);可反复多轮
this.startSideStep();
} else { } else {
this.log("\u5bfb\u8def\u8d85\u65f6\uff0c\u91cd\u8bd5 " + this.retryCount + "/" + this.config.retries); // 多轮走两步也未能脱困:连续超时达到阈值则回城传送重新开始,否则切换目标
this.retrySameTarget("\u91cd\u8bd5\u524d\u5f80 " + this.target.x() + "," + this.target.z()); this.giveUpTarget("\u5bfb\u8def\u8d85\u65f6");
} }
} }
} }
/**
* 发送 /u return 回城:取消当前寻路,向服务器发送回城指令(约 10 秒后自动传送回安全位置),
* 随后进入 RETURNING 状态等待传送完成。
*
* <p>传送完成后的走向由 {@link #returnThenStop} 决定:
* 寻路超时回城会重新规划继续扩地;耐久保护回城则停止任务,等待玩家修复装备后手动重新开始。</p>
*
* @param reason 触发回城的原因(写入日志与聊天提示)
*/
private void sendReturnCommand(String reason) {
this.baritoneBridge.cancel();
MinecraftClient client = MinecraftClient.getInstance();
if (client != null && client.player != null) {
this.log(reason + ",发送 /u return 回城,10 秒后自动传送");
// 通过玩家网络处理器发送聊天指令 /u return(Yarn 映射:sendChatCommand 自动带上前缀斜杠)
client.player.networkHandler.sendChatCommand("u return");
client.player.sendMessage(Text.literal("[AutoStore] " + reason + ",已发送 /u return,传送后"
+ (this.returnThenStop ? "停止扩地" : "重新扩地")), false);
} else {
this.log(reason + ",但玩家不在世界,跳过");
}
this.returnStartMillis = System.currentTimeMillis();
this.setState(State.RETURNING, "等待回城传送(10 秒后自动传送)");
}
/**
* 回城传送等待阶段每帧逻辑:
* 发送 /u return 后服务器约 10 秒把玩家传送回安全位置。
* 等待 {@code returnWaitSeconds} 秒(覆盖传送延迟并留余量)后:
* 耐久保护触发的回城 → 停止任务并提示修复装备;
* 寻路超时触发的回城 → 清零连续超时计数并重新规划扩地计划,从最高优先级区块重新开始。
*/
private void tickReturning() {
long elapsedMillis = System.currentTimeMillis() - this.returnStartMillis;
if (elapsedMillis < (long)this.config.returnWaitSeconds * 1000L) {
return;
}
if (this.returnThenStop) {
this.returnThenStop = false;
this.log("耐久保护回城完成,任务已停止;请修复/更换装备后重新开始扩地");
this.setState(State.STOPPED, "装备耐久过低已回城,请修复装备后重新开始");
return;
}
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.plan();
}
/**
* 目标寻路重试耗尽时的脱困策略入口:
* 取消当前寻路,改为手动朝"目标连线垂直方向"走开约 2.5 格(左右各试一侧),
* 走开后自动重新寻路原目标;走不开/超时则再走两步或交给 {@link #giveUpTarget} 回城/切换目标。
* 每个目标最多触发 {@code sideStepMaxRounds} 轮(sideStepCount 在 startPathing 时重置)。
*/
private void startSideStep() {
this.baritoneBridge.cancel();
MinecraftClient client = MinecraftClient.getInstance();
this.sideStepping = true;
++this.sideStepCount;
this.sideStepSign = 1; // 默认先朝目标连线左侧走
this.sideStepStartPos = client != null && client.player != null ? client.player.getBlockPos() : null;
this.sideStepDeadlineMillis = System.currentTimeMillis() + 10000L; // 整体最多走 10 秒
this.sideStepSinceMillis = System.currentTimeMillis();
this.log("\u5bfb\u8def\u91cd\u8bd5\u8017\u5c3d\uff0c\u5f00\u59cb\u5f80\u8fb9\u4e0a\u8d70\u4e24\u6b65\u518d\u91cd\u8bd5\u539f\u76ee\u6807\uff08\u7b2c " + this.sideStepCount + "/" + this.config.sideStepMaxRounds + " \u8f6e\uff09");
this.setState(State.PATHING, "\u5f80\u8fb9\u4e0a\u8d70\u4e24\u6b65...");
}
/** 走两步阶段每帧逻辑:朝目标连线垂直方向按住前进键行走,走开约 2.5 格后重试原目标 */
private void tickSideStep() {
MinecraftClient client = MinecraftClient.getInstance();
if (client == null || client.player == null || this.sideStepStartPos == null || this.target == null) {
this.stopSideStep(false);
return;
}
// 整体超时:往边上走两步也未能脱困
if (System.currentTimeMillis() >= this.sideStepDeadlineMillis) {
this.stopSideStep(false);
this.giveUpTarget("\u5f80\u8fb9\u4e0a\u8d70\u4e24\u6b65\u8d85\u65f6");
return;
}
// 目标连线方向(水平),取垂直向量作为横向移动方向(左为 +,右为 -)
Vec3d playerPos = client.player.getPos();
Vec3d dirToTarget = new Vec3d(this.target.centerBlockX() + 0.5, playerPos.y, this.target.centerBlockZ() + 0.5)
.subtract(playerPos).normalize();
Vec3d sideDir = new Vec3d(-dirToTarget.z, 0.0, dirToTarget.x).multiply(this.sideStepSign);
// 面朝横向移动方向并按住前进键
float yaw = (float)Math.toDegrees(Math.atan2(sideDir.z, sideDir.x)) - 90.0f;
client.player.setYaw(yaw);
client.options.forwardKey.setPressed(true);
// 已走开足够距离(约 2.5 格):停止并重试原目标
if (client.player.getBlockPos().getSquaredDistance(this.sideStepStartPos) >= 6.25) {
this.stopSideStep(true);
this.retrySameTarget("\u5f80\u8fb9\u4e0a\u8d70\u4e24\u6b65\u5b8c\u6210\uff0c\u91cd\u65b0\u5bfb\u8def");
return;
}
// 卡住检查:同一方向 5 秒没移动就换另一侧;两侧都试过仍不动则放弃
if (System.currentTimeMillis() - this.sideStepSinceMillis > 5000L) {
if (this.sideStepSign != -1) {
this.sideStepSign = -1;
this.sideStepSinceMillis = System.currentTimeMillis();
this.log("\u5f80\u8fb9\u4e0a\u8d70\u88ab\u6321\uff0c\u53d6\u53cd\u65b9\u5411\u7ee7\u7eed");
} else {
this.stopSideStep(false);
this.giveUpTarget("\u5f80\u8fb9\u4e0a\u8d70\u4e24\u4fa7\u5747\u88ab\u6321");
}
}
}
/** 结束走两步阶段:释放手动按下的前进键;success 表示已走开并准备重试原目标 */
private void stopSideStep(boolean success) {
this.sideStepping = false;
MinecraftClient client = MinecraftClient.getInstance();
if (client != null && client.options != null) {
client.options.forwardKey.setPressed(false);
}
}
/**
* 放弃当前目标(重试与走两步均已尝试)的统一出口:
* 若连续寻路超时累计已达到阈值,则发送 /u return 回城传送并重新规划;
* 否则记录日志并进入 FAIL 状态切换下一个目标。
*/
private void giveUpTarget(String reason) {
if (this.consecutiveTimeoutMillis >= (long)this.config.returnThresholdSeconds * 1000L) {
this.sendReturnCommand("连续寻路超时超过 " + this.config.returnThresholdSeconds + " 秒");
return;
}
if (this.target != null) {
this.log(reason + "\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z());
} else {
this.log(reason);
}
this.setState(State.FAIL, reason);
}
/** 等待开垦阶段每帧逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过当前目标 */ /** 等待开垦阶段每帧逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过当前目标 */
private void tickWaiting() { private void tickWaiting() {
if (this.target == null) { if (this.target == null) {
@@ -319,6 +632,10 @@ implements SubtitleChatMonitor.Listener {
if (this.skipIfTargetClaimed("\u76ee\u6807\u5df2\u88ab\u5360\u9886")) { if (this.skipIfTargetClaimed("\u76ee\u6807\u5df2\u88ab\u5360\u9886")) {
return; return;
} }
// 目标已不再与己方领土接壤:立即跳过,避免在无效区块上干等开垦
if (this.skipIfTargetNotAdjacent()) {
return;
}
// 玩家离开了目标区块(被击退/寻路偏差),重新寻路回去 // 玩家离开了目标区块(被击退/寻路偏差),重新寻路回去
if (!this.playerChunk.equals(this.target)) { if (!this.playerChunk.equals(this.target)) {
this.retrySameTarget("\u79bb\u5f00\u76ee\u6807\u533a\u5757\uff0c\u91cd\u65b0\u524d\u5f80 " + this.target.x() + "," + this.target.z()); this.retrySameTarget("\u79bb\u5f00\u76ee\u6807\u533a\u5757\uff0c\u91cd\u65b0\u524d\u5f80 " + this.target.x() + "," + this.target.z());
@@ -341,10 +658,13 @@ implements SubtitleChatMonitor.Listener {
/** 重试当前目标:取消旧寻路,重新让 Baritone 前往同一目标区块 */ /** 重试当前目标:取消旧寻路,重新让 Baritone 前往同一目标区块 */
private void retrySameTarget(String statusText) { private void retrySameTarget(String statusText) {
// 目标已被他人占领(地图刷新后发现):不再重试,直接跳过,避免玩家被困在已占领区块 // 重试前先校验目标有效性:已被他人占领或已不接壤时不再重试,直接跳过,避免玩家被困在无效区块
if (this.skipIfTargetClaimed("\u91cd\u8bd5\u65f6\u53d1\u73b0\u76ee\u6807\u5df2\u88ab\u5360\u9886")) { if (this.skipIfTargetClaimed("\u91cd\u8bd5\u65f6\u53d1\u73b0\u76ee\u6807\u5df2\u88ab\u5360\u9886")) {
return; return;
} }
if (this.skipIfTargetNotAdjacent()) {
return;
}
this.baritoneBridge.cancel(); this.baritoneBridge.cancel();
this.stageStartedMillis = System.currentTimeMillis(); this.stageStartedMillis = System.currentTimeMillis();
this.baritoneBridge.pathTo(this.target); this.baritoneBridge.pathTo(this.target);
@@ -365,6 +685,49 @@ implements SubtitleChatMonitor.Listener {
return false; return false;
} }
/**
* 若当前目标区块不再与己方领土(含本次会话已占领)的四个正方向接壤,
* 则跳过并进入 NEXT 状态,返回 true。
*
* <p>这是解决“走到不接壤区块”的关键:规划时目标必然接壤,但寻路/等待过程中
* 领土数据会异步刷新(边界变化、假占领被剔除等),导致目标可能变得不接壤。
* 在寻路与等待阶段每帧校验,一旦发现不接壤立即放弃,避免在无效区块上白费时间。</p>
*/
private boolean skipIfTargetNotAdjacent() {
if (this.target != null && !this.isAdjacentToOwn(this.target)) {
this.log("\u76ee\u6807\u4e0e\u5df1\u65b9\u9886\u571f\u4e0d\u76f8\u90bb\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z());
this.setState(State.NEXT, "\u8df3\u8fc7\u4e0d\u63a5\u58e4\u533a\u5757");
return true;
}
return false;
}
/**
* 判断区块是否与己方领土(己方邦国地图区块 + 本次会话新占领区块)接壤。
*
* <p>接壤采用四个正方向(上下左右);只对“己方”领土判断,绝不把其他邦国领土计入。
* 实现上直接查询四个邻居是否命中集合,零集合复制——
* 旧实现每帧把数千个己方区块复制进临时 HashSet,是持续卡顿与内存压力的来源之一。</p>
*/
private boolean isAdjacentToOwn(ChunkPos chunk) {
TerritoryModel model = this.dataService.model();
TerritoryState self = model.selfState();
Set<ChunkPos> ownChunks = self == null ? null : self.chunks();
Set<ChunkPos> sessionClaims = model.sessionClaims();
int x = chunk.x();
int z = chunk.z();
return this.isOwnAt(ownChunks, sessionClaims, x + 1, z)
|| this.isOwnAt(ownChunks, sessionClaims, x - 1, z)
|| this.isOwnAt(ownChunks, sessionClaims, x, z + 1)
|| this.isOwnAt(ownChunks, sessionClaims, x, z - 1);
}
/** 判断指定坐标的区块是否属于己方领土或本次会话新占领 */
private boolean isOwnAt(Set<ChunkPos> ownChunks, Set<ChunkPos> sessionClaims, int x, int z) {
ChunkPos probe = new ChunkPos(x, z);
return ownChunks != null && ownChunks.contains(probe) || sessionClaims.contains(probe);
}
/** 成功后的过渡:进入 NEXT 状态,准备处理下一个目标 */ /** 成功后的过渡:进入 NEXT 状态,准备处理下一个目标 */
private void tickSuccess() { private void tickSuccess() {
this.setState(State.NEXT, "\u5df2\u5b8c\u6210 " + this.completedClaims + (this.config.targetCount > 0 ? "/" + this.config.targetCount : "/\u6301\u7eed")); this.setState(State.NEXT, "\u5df2\u5b8c\u6210 " + this.completedClaims + (this.config.targetCount > 0 ? "/" + this.config.targetCount : "/\u6301\u7eed"));
@@ -425,6 +788,29 @@ implements SubtitleChatMonitor.Listener {
return min == Integer.MAX_VALUE ? 0 : min; return min == Integer.MAX_VALUE ? 0 : min;
} }
/**
* Baritone 路径事件回调:记录关键事件,并在“计算失败”时置标志供寻路阶段消费。
* 本回调可能来自 Baritone 事件线程,只做轻量操作(置标志 + 写日志),
* 具体处理放在 tick 的寻路阶段(主线程)完成,避免跨线程状态竞争。
*/
@Override
public void onPathEvent(PathEvent event) {
if (event == PathEvent.CALC_STARTED) {
this.pathCalcFailed = false;
} else if (event == PathEvent.CALC_FAILED) {
this.pathCalcFailed = true;
}
// 仅在任务运行中记录关键事件,避免空闲时 Baritone 高频 CANCELED/中间态刷屏
if (!this.isRunning()) {
return;
}
// 只记录关键事件,且跳过高频的 CANCELED(空闲/重寻路时每秒触发多次,无排查价值)
if (event == PathEvent.CALC_STARTED || event == PathEvent.CALC_FINISHED_NOW_EXECUTING
|| event == PathEvent.CALC_FAILED || event == PathEvent.AT_GOAL) {
this.log("\u5bfb\u8def\u4e8b\u4ef6\uff1a" + event);
}
}
/** 监听器回调:开垦进度更新(0-100%) */ /** 监听器回调:开垦进度更新(0-100%) */
@Override @Override
public void onProgress(int percent) { public void onProgress(int percent) {
@@ -456,7 +842,12 @@ implements SubtitleChatMonitor.Listener {
} }
this.model.addSessionClaim(chunk); this.model.addSessionClaim(chunk);
this.dataService.addSessionClaim(chunk); this.dataService.addSessionClaim(chunk);
// 占领成功后立即触发一次地图刷新(请求侧已带缓存击穿),尽快让地图数据确认该区块,
// 缩短"会话占领等待确认"窗口,避免假占领长时间残留
this.dataService.refreshAsync();
++this.completedClaims; ++this.completedClaims;
// 开垦成功:寻路/等待恢复正常,清零连续超时计数
this.consecutiveTimeoutMillis = 0L;
this.target = chunk; this.target = chunk;
this.lastProgress = 100; this.lastProgress = 100;
this.log("\u5360\u9886\u6210\u529f " + chunk.x() + "," + chunk.z() + "\uff08\u533a\u5757 " + chunk.x() + "," + chunk.z() + " / \u4e16\u754c " + blockX + "," + blockZ + "\uff09"); this.log("\u5360\u9886\u6210\u529f " + chunk.x() + "," + chunk.z() + "\uff08\u533a\u5757 " + chunk.x() + "," + chunk.z() + " / \u4e16\u754c " + blockX + "," + blockZ + "\uff09");
@@ -472,13 +863,23 @@ implements SubtitleChatMonitor.Listener {
if (this.target == null) { if (this.target == null) {
return; return;
} }
// 开垦只能发生在玩家所在区块:校验玩家确实位于目标区块上。
// 通用成功文案采用模糊匹配,可能被服务器广播的其他玩家开垦消息误触发;
// 不校验会把未占领的目标错误标记为"已占领"并跳过,直到很久之后才被地图数据纠正(v1.2.1 修复)。
if (!this.playerChunk.equals(this.target)) {
return;
}
// 去重:同一区块只计一次 // 去重:同一区块只计一次
if (!this.claimedSet.add(this.target)) { if (!this.claimedSet.add(this.target)) {
return; return;
} }
this.model.addSessionClaim(this.target); this.model.addSessionClaim(this.target);
this.dataService.addSessionClaim(this.target); this.dataService.addSessionClaim(this.target);
// 占领成功后立即触发一次地图刷新,尽快确认该区块
this.dataService.refreshAsync();
++this.completedClaims; ++this.completedClaims;
// 开垦成功:寻路/等待恢复正常,清零连续超时计数
this.consecutiveTimeoutMillis = 0L;
this.lastProgress = 100; this.lastProgress = 100;
this.log("\u5360\u9886\u6210\u529f " + this.target.x() + "," + this.target.z()); this.log("\u5360\u9886\u6210\u529f " + this.target.x() + "," + this.target.z());
this.setState(State.SUCCESS, "\u7586\u571f\u5df2\u5f00\u62d3 " + this.target.x() + "," + this.target.z()); this.setState(State.SUCCESS, "\u7586\u571f\u5df2\u5f00\u62d3 " + this.target.x() + "," + this.target.z());
@@ -495,28 +896,116 @@ implements SubtitleChatMonitor.Listener {
return this.dataService.model().allClaimedChunks().contains(chunk); return this.dataService.model().allClaimedChunks().contains(chunk);
} }
/** 返回区块的四个正方向邻居 */
private List<ChunkPos> cardinalNeighbors(ChunkPos chunk) {
return List.of(new ChunkPos(chunk.x() + 1, chunk.z()), new ChunkPos(chunk.x() - 1, chunk.z()), new ChunkPos(chunk.x(), chunk.z() + 1), new ChunkPos(chunk.x(), chunk.z() - 1));
}
/** /**
* 自动游泳逻辑(每帧调用): * 自动游泳逻辑(每帧调用):
* 仅在玩家完全没入水中(有溺水风险)时自动跳跃,保持浮在水面。 * 仅在玩家处于水中(含没入 / 水面 / 浅水)、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;
} }
/** /**
@@ -580,10 +1069,10 @@ implements SubtitleChatMonitor.Listener {
return -1; return -1;
} }
/** 进食完成后恢复任务:若仍在寻路阶段则重新发起寻路 */ /** 进食完成后恢复任务:若仍在寻路阶段则重新发起寻路(走两步阶段不抢路) */
private void resumeAfterEating() { private void resumeAfterEating() {
this.eating = false; this.eating = false;
if (this.state == State.PATHING && this.target != null) { if (this.state == State.PATHING && this.target != null && !this.sideStepping) {
this.stageStartedMillis = System.currentTimeMillis(); this.stageStartedMillis = System.currentTimeMillis();
this.baritoneBridge.pathTo(this.target); this.baritoneBridge.pathTo(this.target);
} }
@@ -599,13 +1088,42 @@ implements SubtitleChatMonitor.Listener {
private void log(String message) { private void log(String message) {
Deque<String> deque = this.logs; Deque<String> deque = this.logs;
synchronized (deque) { synchronized (deque) {
this.logs.addLast(System.currentTimeMillis() + " " + message); this.logs.addLast("[" + LocalTime.now().format(LOG_TIME_FORMAT) + "] " + message);
while (this.logs.size() > 80) { while (this.logs.size() > MAX_LOGS) {
this.logs.removeFirst(); this.logs.removeFirst();
} }
} }
} }
/**
* 导出运行日志到文件,返回文件路径;失败时返回 null。
* 文件写入 Fabric 配置目录下的 {@code autostore-debug.log},
* 内容包含当前状态摘要(状态、目标、玩家区块、队列、进度)与全部详细日志。
*
* @return 导出文件的绝对路径,或 null(写入失败时)
*/
public Path exportLogs() {
Path path = FabricLoader.getInstance().getConfigDir().resolve("autostore-debug.log");
try {
StringBuilder sb = new StringBuilder(8192);
sb.append("==== AutoStore 调试日志 ====\n");
sb.append("状态: ").append(this.state).append(" / ").append(this.status).append('\n');
sb.append("目标区块: ").append(this.target == null ? "无" : this.target.x() + "," + this.target.z()).append('\n');
sb.append("玩家区块: ").append(this.playerChunk.x()).append(',').append(this.playerChunk.z()).append('\n');
sb.append("队列: ").append(this.queue.size()).append(" | 已占领: ").append(this.completedClaims)
.append(" | 重试: ").append(this.retryCount).append(" | 最近进度: ").append(this.lastProgress).append("%\n\n");
synchronized (this.logs) {
for (String line : this.logs) {
sb.append(line).append('\n');
}
}
Files.writeString(path, sb.toString(), StandardCharsets.UTF_8);
return path;
} catch (IOException e) {
return null;
}
}
/** /**
* 扩地任务状态机枚举: * 扩地任务状态机枚举:
* <ul> * <ul>
@@ -629,6 +1147,7 @@ implements SubtitleChatMonitor.Listener {
SUCCESS, SUCCESS,
FAIL, FAIL,
NEXT, NEXT,
RETURNING,
COMPLETE, COMPLETE,
STOPPED; STOPPED;
@@ -14,9 +14,11 @@ 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;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import net.minecraft.util.Util;
import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.BlockPos;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import net.minecraft.client.font.TextRenderer; import net.minecraft.client.font.TextRenderer;
@@ -27,6 +29,7 @@ import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.client.gui.widget.CheckboxWidget; import net.minecraft.client.gui.widget.CheckboxWidget;
import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.screen.Screen;
import net.minecraft.text.StringVisitable; import net.minecraft.text.StringVisitable;
import net.minecraft.text.OrderedText;
@Environment(value=EnvType.CLIENT) @Environment(value=EnvType.CLIENT)
/** /**
@@ -38,17 +41,24 @@ import net.minecraft.text.StringVisitable;
*/ */
public final class AutoStoreGui public final class AutoStoreGui
extends Screen { extends Screen {
// ---- 外部链接 ----
private static final String GIT_REPO_URL = "https://git.bgjq.top/Lyizai/AutoStore/";
private static final String HOMEPAGE_URL = "https://8w.bgjq.top/";
private final AutoStoreModClient mod; private final AutoStoreModClient mod;
private final ExpansionConfig config; private final ExpansionConfig config;
private final ExpansionController controller; private final ExpansionController controller;
// ---- 输入控件 ---- // ---- 输入控件 ----
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;
@@ -89,7 +99,7 @@ extends Screen {
this.mapY = 40; this.mapY = 40;
this.mapW = Math.max(120, this.panelX - this.mapX - 8); this.mapW = Math.max(120, this.panelX - this.mapX - 8);
// 底部信息区高度(日志 + 状态文本),地图填满其上方空间 // 底部信息区高度(日志 + 状态文本),地图填满其上方空间
int infoHeight = 78; int infoHeight = 88;
this.mapH = Math.max(140, this.height - this.mapY - infoHeight - 8); this.mapH = Math.max(140, this.height - this.mapY - infoHeight - 8);
// 屏幕较矮时压缩垂直间距,保证右侧面板内容不被截断 // 屏幕较矮时压缩垂直间距,保证右侧面板内容不被截断
@@ -99,6 +109,11 @@ extends Screen {
// ===== 顶部控制按钮 ===== // ===== 顶部控制按钮 =====
int topX = this.panelX; int topX = this.panelX;
// 左上角外部链接按钮:Git 仓库与官网(点击在系统浏览器打开)
this.addDrawableChild(ButtonWidget.builder(Text.literal((String)"Git \u4ed3\u5e93"), button -> this.openUrl(GIT_REPO_URL)).dimensions(this.mapX, 8, 70, 16).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal((String)"\u5b98\u7f51"), button -> this.openUrl(HOMEPAGE_URL)).dimensions(this.mapX + 76, 8, 50, 16).build());
// 导出日志按钮:把当前运行日志写入文件,方便排查寻路/坐标/状态问题
this.addDrawableChild(ButtonWidget.builder(Text.literal((String)"\u5bfc\u51fa\u65e5\u5fd7"), button -> this.exportLogs()).dimensions(this.mapX + 132, 8, 70, 16).build());
ButtonWidget startButton = ButtonWidget.builder(Text.literal((String)"\u5f00\u59cb\u81ea\u52a8\u6269\u5730"), button -> this.start()).dimensions(topX, 34, 154, 30).build(); ButtonWidget startButton = ButtonWidget.builder(Text.literal((String)"\u5f00\u59cb\u81ea\u52a8\u6269\u5730"), button -> this.start()).dimensions(topX, 34, 154, 30).build();
ButtonWidget stopButton = ButtonWidget.builder(Text.literal((String)"\u505c\u6b62"), button -> this.controller.stop()).dimensions(topX + 160, 34, 74, 30).build(); ButtonWidget stopButton = ButtonWidget.builder(Text.literal((String)"\u505c\u6b62"), button -> this.controller.stop()).dimensions(topX + 160, 34, 74, 30).build();
this.addDrawableChild(startButton); this.addDrawableChild(startButton);
@@ -131,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;
@@ -144,20 +161,58 @@ 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();
} }
/** 在系统默认浏览器中打开外部链接(失败时静默忽略) */
private void openUrl(String url) {
try {
Util.getOperatingSystem().open(new java.net.URI(url));
} catch (Exception ignored) {
// 链接打开失败不影响界面正常使用
}
}
/** 导出运行日志到文件,并在聊天栏提示导出结果与路径 */
private void exportLogs() {
java.nio.file.Path path = this.controller.exportLogs();
if (this.client != null && this.client.player != null) {
if (path != null) {
this.client.player.sendMessage(Text.literal("\u65e5\u5fd7\u5df2\u5bfc\u51fa\uff1a" + path), false);
} else {
this.client.player.sendMessage(Text.literal("\u65e5\u5fd7\u5bfc\u51fa\u5931\u8d25"), false);
}
}
}
/** 创建整数输入框(初始值为 value,最多 12 位) */ /** 创建整数输入框(初始值为 value,最多 12 位) */
private TextFieldWidget numberField(int value, int x, int y, int width) { private TextFieldWidget numberField(int value, int x, int y, int width) {
TextFieldWidget field = new TextFieldWidget(this.textRenderer, x, y, width, 20, Text.literal((String)"")); TextFieldWidget field = new TextFieldWidget(this.textRenderer, x, y, width, 20, Text.literal((String)""));
@@ -316,14 +371,27 @@ extends Screen {
context.drawTextWithShadow(this.textRenderer, "\u9886\u571f\u5730\u56fe", this.mapX + 2, this.mapY - 12, -1513240); context.drawTextWithShadow(this.textRenderer, "\u9886\u571f\u5730\u56fe", this.mapX + 2, this.mapY - 12, -1513240);
} }
/** 绘制各邦国领土区块(己方高亮、其他半透明),并绘制会话新占领区块 */ /** 绘制各邦国领土区块(己方高亮、其他半透明),并绘制会话新占领区块与飞地(疏漏区块) */
private void drawTerritories(DrawContext context) { private void drawTerritories(DrawContext context) {
TerritoryModel model = this.mod.dataService().model(); TerritoryModel model = this.mod.dataService().model();
// 先反推当前视口覆盖的区块范围(含一个区块边距),遍历时用整数比较快速剔除视口外区块。
// 全图约 8 万区块,旧实现每帧对每个区块都调用屏幕坐标换算,是打开 GUI / 刷新后掉帧的主因。
double invScale = 1.0 / this.scale;
int edge = (int)Math.ceil(this.scale) + 2;
int halfW = this.mapW / 2;
int halfH = this.mapH / 2;
int visMinX = (int)Math.floor((this.mapX - edge - this.mapX - halfW) * invScale) + this.viewCenterX;
int visMaxX = (int)Math.ceil((this.mapX + this.mapW + edge - this.mapX - halfW) * invScale) + this.viewCenterX;
int visMinZ = (int)Math.floor((this.mapY - edge - this.mapY - halfH) * invScale) + this.viewCenterZ;
int visMaxZ = (int)Math.ceil((this.mapY + this.mapH + edge - this.mapY - halfH) * invScale) + this.viewCenterZ;
for (TerritoryState state : model.states()) { for (TerritoryState state : model.states()) {
boolean self = state.equals(model.selfState()); boolean self = state.equals(model.selfState());
int color = state.color(); int color = state.color();
int fill = this.withAlpha(color, self ? 155 : 92); int fill = this.withAlpha(color, self ? 155 : 92);
for (ChunkPos chunk : state.chunks()) { for (ChunkPos chunk : state.chunks()) {
int cx = chunk.x();
int cz = chunk.z();
if (cx < visMinX || cx > visMaxX || cz < visMinZ || cz > visMaxZ) continue;
this.drawChunkFill(context, chunk, fill); this.drawChunkFill(context, chunk, fill);
} }
if (!this.isVisible(state.capital())) continue; if (!this.isVisible(state.capital())) continue;
@@ -332,6 +400,11 @@ extends Screen {
for (ChunkPos chunk : model.sessionClaims()) { for (ChunkPos chunk : model.sessionClaims()) {
this.drawChunkFill(context, chunk, -1938440928); this.drawChunkFill(context, chunk, -1938440928);
} }
// 标记飞地(疏漏区块):己方用醒目的红色描边提示填补,其他邦国用暗灰色标示
Set<ChunkPos> selfFlying = model.selfFlyingChunks();
for (ChunkPos chunk : model.flyingChunks()) {
this.drawChunkOutline(context, chunk, selfFlying.contains(chunk) ? -65536 : this.withAlpha(-8333060, 160));
}
} }
/** 绘制玩家位置(白色方块)与当前目标区块(橙色描边) */ /** 绘制玩家位置(白色方块)与当前目标区块(橙色描边) */
@@ -422,51 +495,121 @@ extends Screen {
/** 绘制右侧信息面板:字段标签、运行日志与状态/进度信息 */ /** 绘制右侧信息面板:字段标签、运行日志与状态/进度信息 */
private void drawInfo(DrawContext context) { private void drawInfo(DrawContext context) {
int shown; // ===== 右侧配置面板字段标签 =====
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);
int belowY = this.mapY + this.mapH + 12; // 耐久回城阈值输入框的单位标签(位于输入框右侧)
int logWidth = Math.max(120, (int)(this.mapW * 0.52)); if (this.durabilityField != null) {
int statusX = this.mapX + logWidth + 20; context.drawTextWithShadow(this.textRenderer, "\u8010\u4e45%",
int statusWidth = this.mapW - logWidth - 20; this.durabilityField.getX() + this.durabilityField.getWidth() + 4,
List<String> logs = this.controller.logs(); this.durabilityField.getY() + 6, -5592406);
for (int i = shown = Math.min(4, logs.size()); i > 0; --i) {
String trimmed = this.textRenderer.trimToWidth(logs.get(logs.size() - i), logWidth);
context.drawTextWithShadow(this.textRenderer, trimmed, this.mapX, belowY + (shown - i) * 12, -6577233);
} }
// ===== 底部信息面板:日志(左)+ 状态(右),均单行截断,避免换行导致的重叠 =====
int infoTop = this.mapY + this.mapH + 10;
int logWidth = Math.max(120, (int)(this.mapW * 0.52));
int statusX = this.mapX + logWidth + 24;
int statusWidth = this.mapW - logWidth - 24;
// 半透明面板背景,使信息区与地图明显分隔
context.fill(this.mapX, infoTop - 2, this.mapX + this.mapW, infoTop + 74, 0x30000000);
// 两栏标题与竖向分隔线
context.drawTextWithShadow(this.textRenderer, "\u8fd0\u884c\u65e5\u5fd7", this.mapX + 2, infoTop, -5592406);
context.drawTextWithShadow(this.textRenderer, "\u72b6\u6001\u4fe1\u606f", statusX, infoTop, -5592406);
context.fill(statusX - 12, infoTop - 1, statusX - 11, infoTop + 58, -12761518);
// 运行日志:使用较小字体并自动换行,最新日志在最上方,逐行向下显示直到填满可视高度
List<String> logs = this.controller.logs();
float logScale = 0.75f; // 日志字号缩放(比默认小一号)
int logAreaTop = infoTop + 12;
int logAreaBottom = infoTop + 56; // 与竖向分隔线底部对齐
int wrapWidth = (int)((logWidth - 4) / logScale); // 缩放后的可用换行宽度(按未缩放坐标计算)
context.getMatrices().push();
context.getMatrices().scale(logScale, logScale, 1.0f);
int lineY = logAreaTop;
outer:
for (int i = logs.size() - 1; i >= 0; --i) {
for (OrderedText line : this.textRenderer.wrapLines(Text.literal(logs.get(i)), wrapWidth)) {
if (lineY + 9 > logAreaBottom) {
break outer;
}
context.drawTextWithShadow(this.textRenderer, line, (int)((this.mapX + 2) / logScale), (int)(lineY / logScale), -6577233);
lineY += 9;
}
}
context.getMatrices().pop();
// 状态信息:单行截断 + 固定 12px 行距,彻底消除换行重叠
int target = this.config.targetCount; int target = this.config.targetCount;
String targetText = target > 0 ? Integer.toString(target) : "\u6301\u7eed"; String targetText = target > 0 ? Integer.toString(target) : "\u6301\u7eed";
context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(("\u72b6\u6001\uff1a" + this.controller.status())), statusX, belowY, statusWidth, -1); String error = this.mod.dataService().lastError();
context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(("\u8fdb\u5ea6\uff1a" + this.controller.completedClaims() + " / " + targetText + "\uff0c\u7b49\u5f85\uff1a" + this.controller.lastProgress() + "%")), statusX, belowY + 14, statusWidth, -1); String[] statusLines = new String[]{
context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(("\u961f\u5217\uff1a" + this.controller.queuedTargets() + "\uff0c\u6570\u636e\u9519\u8bef\uff1a" + (this.mod.dataService().lastError().isEmpty() ? "\u65e0" : this.mod.dataService().lastError()))), statusX, belowY + 30, statusWidth, -1); "\u72b6\u6001\uff1a" + this.controller.status(),
context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(this.refreshStatusText()), statusX, belowY + 46, statusWidth, -5592406); "\u8fdb\u5ea6\uff1a" + this.controller.completedClaims() + " / " + targetText + "\uff0c\u7b49\u5f85\uff1a" + this.controller.lastProgress() + "%",
context.drawWrappedTextWithShadow(this.textRenderer, Text.literal((String)"\u5f00\u59cb\u524d\u8bf7\u786e\u8ba4\u5df2\u624b\u52a8\u6267\u884c /u claimmode"), statusX, belowY + 62, statusWidth, -13210); "\u961f\u5217\uff1a" + this.controller.queuedTargets() + "\uff0c\u6570\u636e\u9519\u8bef\uff1a" + (error.isEmpty() ? "\u65e0" : error),
this.refreshStatusText()
};
for (int i = 0; i < statusLines.length; ++i) {
String trimmed = this.textRenderer.trimToWidth(statusLines[i], statusWidth);
context.drawTextWithShadow(this.textRenderer, trimmed, statusX, infoTop + 12 + i * 12, -1);
}
// 底部整行提示
String hint = this.textRenderer.trimToWidth("\u5f00\u59cb\u524d\u8bf7\u786e\u8ba4\u5df2\u624b\u52a8\u6267\u884c /u claimmode", this.mapW);
context.drawTextWithShadow(this.textRenderer, hint, this.mapX + 2, infoTop + 62, -13210);
} }
/** 生成地图刷新状态文本(显示上次成功刷新距今的时间) */ /** 生成地图刷新状态文本(显示上次成功刷新距今的时间) */
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;
mapText = seconds < 60 ? "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + seconds + "\u79d2\u524d" : "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + (seconds / 60) + "\u5206\u949f\u524d";
} }
long seconds = (System.currentTimeMillis() - last) / 1000L; // API 数据源状态:启用时显示最近一次成功拉取距今的时间
if (seconds < 60) { if (this.mod.dataService().isApiEnabled()) {
return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + seconds + "\u79d2\u524d"; long apiLast = this.mod.dataService().lastApiSuccessMillis();
String apiText;
if (apiLast <= 0) {
apiText = "API \u672a\u62c9\u53d6\u5230\u6570\u636e";
} else {
long s = (System.currentTimeMillis() - apiLast) / 1000L;
apiText = s < 60 ? "API \u5df2\u62c9\u53d6\uff1a" + s + "\u79d2\u524d" : "API \u5df2\u62c9\u53d6\uff1a" + (s / 60) + "\u5206\u949f\u524d";
}
return mapText + "\uff0c" + apiText;
} }
return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + (seconds / 60) + "\u5206\u949f\u524d"; return mapText;
} }
/** 自动识别玩家当前所在邦国并填入邦国输入框 */ /** 自动识别玩家当前所在邦国并填入邦国输入框(Dynmap 未覆盖时回退 API 按玩家名识别) */
private void autoState() { 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 配置并启动扩地 */
@@ -485,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 全局配置并保存到磁盘 */
@@ -498,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 {
@@ -40,17 +41,49 @@ public final class ExpansionPlanner {
* @return 建议占领的区块列表(按优先级排序) * @return 建议占领的区块列表(按优先级排序)
*/ */
public List<ChunkPos> generate(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) { public List<ChunkPos> generate(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
List<ChunkPos> result;
if (config.strategy == 1) { if (config.strategy == 1) {
return this.generateHolePriority(model, config, playerChunk); result = this.generateHolePriority(model, config, playerChunk);
} else if (config.strategy == 4) {
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) {
result = this.generateRectangle(model, config, playerChunk);
} else {
result = this.generateLine(model, config, playerChunk);
} }
if (config.strategy == 4) { return this.prefixFlyingChunks(model, config, playerChunk, result);
return this.generatePerimeter(model, config, playerChunk); }
/**
* 将己方领土内的"飞地(疏漏区块)"提到队列最前,优先填补。
*
* <p>飞地指未被占领、但四周(四个正方向)都被己方领土包围的区块,
* 是自动扩地过程中最容易漏掉的疏漏。先把它们填上可保证领土闭合、不留缺口。</p>
*/
private List<ChunkPos> prefixFlyingChunks(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk, List<ChunkPos> planned) {
Set<ChunkPos> flying = model.selfFlyingChunks();
if (flying == null || flying.isEmpty()) {
return planned;
} }
// 策略 2 区域扩地:玩家圈选了矩形时,完全按矩形区域扩地 int limit = config.targetCount > 0 ? config.targetCount : Math.max(planned.size(), flying.size());
if (config.strategy == 2 && config.regionMaxX >= config.regionMinX) { Comparator<ChunkPos> byDistance = Comparator.comparingInt(playerChunk::manhattanDistance);
return this.generateRectangle(model, config, playerChunk); ArrayList<ChunkPos> result = new ArrayList<ChunkPos>();
// 飞地优先,且必须仍在配置区域内
for (ChunkPos chunk : flying) {
if (result.size() >= limit) break;
if (!this.isInRegion(chunk, config)) continue;
result.add(chunk);
} }
return this.generateLine(model, config, playerChunk); result.sort(byDistance);
// 其余规划区块依次补充,去重且不超过 limit
for (ChunkPos chunk : planned) {
if (result.size() >= limit) break;
if (flying.contains(chunk)) continue;
result.add(chunk);
}
return result;
} }
/** /**
@@ -134,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>从玩家当前位置出发,沿一个方向连续向外延伸占领区块(形成一条"线")。
* 每步从候选集中挑选评分最优的区块:距离越近、越贴合当前方向、邻居越多、形状越规整越好。 * 每步从候选集中挑选评分最优的区块:距离越近、越贴合当前方向、邻居越多、形状越规整越好。
@@ -190,13 +248,18 @@ public final class ExpansionPlanner {
} }
/** /**
* 策略 2 矩形区域扩地:在玩家圈选的矩形内,从与现有领土接壤的区块开始, * 策略 2 矩形区域扩地:严格 S 形(蛇形)扫描,由内而外推进。
* 横向或纵向 S 形扫过整个矩形。
* *
* <p>与旧版"沿邦国边界逐层向内 BFS"不同,本算法不再贴着邦国现有边界, * <p>扫描规则(v1.2.1 重写):
* 而是生成一条贯穿矩形的 S 形路径:从接壤起点开始,逐行/逐列交替反向扫过, * <ul>
* 保证路径上每个区块都与已占领领土(含己方领土、本次会话新占领)接壤, * <li>起始扫描线 = 矩形四条边中<b>离王城直线距离最近</b>的那条边(点到线段距离),
* 矩形内已占领的区块自动跳过、不重复占领。 * 从该边向对边单调推进——即"由内而外",避免先跑到区域外侧绕圈;</li>
* <li>扫描行/列内做标准蛇形往返(相邻行方向交替),即使待开垦区块构成不规则多边形,
* 也始终按 S 形顺序一路扫过去,跳过暂时不可开垦的区块而不打乱顺序;</li>
* <li>首条扫描线的行内起始端同样选取离王城更近的一端;</li>
* <li>每个入选区块必须与"己方领土(含本次已选)"四正方向接壤,保证服务器接受开拓。</li>
* </ul>
* 复杂度:O(区域面积) 单遍扫描 + O(4) 接壤检查。</p>
*/ */
private List<ChunkPos> generateRectangle(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) { private List<ChunkPos> generateRectangle(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
Set<ChunkPos> actualOwn = this.ownChunks(model); Set<ChunkPos> actualOwn = this.ownChunks(model);
@@ -209,136 +272,84 @@ public final class ExpansionPlanner {
int minZ = config.regionMinZ; int minZ = config.regionMinZ;
int maxZ = config.regionMaxZ; int maxZ = config.regionMaxZ;
int limit = config.targetCount > 0 ? config.targetCount : Integer.MAX_VALUE; int limit = config.targetCount > 0 ? config.targetCount : Integer.MAX_VALUE;
// 找出矩形内所有与己方领土接壤的未占领区块(作为 S 形起点候选) ChunkPos capital = this.capital(model, playerChunk);
ArrayList<ChunkPos> borderChunks = new ArrayList<ChunkPos>(); // ===== 1. 四条边到王城的距离(点到线段距离的平方),选最近者为起始边 =====
for (int x = minX; x <= maxX; ++x) { // 王城坐标投影到边所在线段的横向偏移(王城在区域横向范围外时才有偏移)
for (int z = minZ; z <= maxZ; ++z) { int dxEdge = Math.max(0, Math.max(minX - capital.x(), capital.x() - maxX));
ChunkPos chunk = new ChunkPos(x, z); int dzEdge = Math.max(0, Math.max(minZ - capital.z(), capital.z() - maxZ));
if (allClaimed.contains(chunk)) continue; long distNorth = (long)dxEdge * dxEdge + (long)(minZ - capital.z()) * (minZ - capital.z());
if (this.ownNeighbors(chunk, actualOwn) > 0) { long distSouth = (long)dxEdge * dxEdge + (long)(maxZ - capital.z()) * (maxZ - capital.z());
borderChunks.add(chunk); long distWest = (long)(minX - capital.x()) * (minX - capital.x()) + (long)dzEdge * dzEdge;
long distEast = (long)(maxX - capital.x()) * (maxX - capital.x()) + (long)dzEdge * dzEdge;
// 最近边决定扫描方向:北/南边最近 → 横向逐行扫(行沿 Z 推进);西/东边最近 → 纵向逐列扫
boolean rowScan = Math.min(distNorth, distSouth) <= Math.min(distWest, distEast);
// ===== 2. 生成 S 形扫描序列并按接壤过滤 =====
// 接壤判断集合:己方领土 + 本次已选区块。
// 注意:绝不能把其他邦国领土(allClaimed)混入接壤判断集合,
// 否则会选中"挨着其他邦国、但与己方领土不相邻"的区块,服务器会拒绝开拓(历史 bug)。
HashSet<ChunkPos> claimed = new HashSet<ChunkPos>(actualOwn);
ArrayList<ChunkPos> result = new ArrayList<ChunkPos>();
if (rowScan) {
// 行序:从离王城近的边(z=minZ 或 maxZ)向对边推进;首行行内起点选离王城 x 近的一端
int startRow = distNorth <= distSouth ? minZ : maxZ;
int rowStep = startRow == minZ ? 1 : -1;
boolean ltr = Math.abs(capital.x() - minX) <= Math.abs(capital.x() - maxX);
for (int row = startRow; row >= minZ && row <= maxZ; row += rowStep) {
if (ltr) {
for (int x = minX; x <= maxX; ++x) {
this.tryClaimRect(result, claimed, allClaimed, x, row, limit);
}
} else {
for (int x = maxX; x >= minX; --x) {
this.tryClaimRect(result, claimed, allClaimed, x, row, limit);
}
}
ltr = !ltr;
if (result.size() >= limit) {
return result;
}
}
} else {
// 列序:从离王城近的边(x=minX 或 maxX)向对边推进;首列列内起点选离王城 z 近的一端
int startCol = distWest <= distEast ? minX : maxX;
int colStep = startCol == minX ? 1 : -1;
boolean ttb = Math.abs(capital.z() - minZ) <= Math.abs(capital.z() - maxZ);
for (int col = startCol; col >= minX && col <= maxX; col += colStep) {
if (ttb) {
for (int z = minZ; z <= maxZ; ++z) {
this.tryClaimRect(result, claimed, allClaimed, col, z, limit);
}
} else {
for (int z = maxZ; z >= minZ; --z) {
this.tryClaimRect(result, claimed, allClaimed, col, z, limit);
}
}
ttb = !ttb;
if (result.size() >= limit) {
return result;
} }
} }
}
if (borderChunks.isEmpty()) {
return List.of();
}
// 选择起点:优先矩形角落附近与领土接壤的区块,使 S 形能自然覆盖整个矩形
ChunkPos start = this.chooseStart(borderChunks, minX, maxX, minZ, maxZ);
// 生成 S 形扫描路径(覆盖矩形内所有区块,含已占领的)
List<ChunkPos> sPath = this.buildSPath(start, minX, maxX, minZ, maxZ);
// 已占领集合:己方领土 + 所有已占领 + 本次规划已纳入的区块
HashSet<ChunkPos> claimed = new HashSet<ChunkPos>(actualOwn);
claimed.addAll(allClaimed);
ArrayList<ChunkPos> result = new ArrayList<ChunkPos>();
for (ChunkPos chunk : sPath) {
if (result.size() >= limit) break;
if (claimed.contains(chunk)) continue;
// 必须与已占领领土接壤,保证能够开拓
if (this.ownNeighbors(chunk, claimed) == 0) continue;
result.add(chunk);
claimed.add(chunk);
} }
return result; return result;
} }
/** /**
* 从接壤候选区块中选择 S 形扫描的起点: * S 形扫描中的一个格子:若该区块未被任何邦国占领、且与"己方领土(含本次已选)"接壤,
* 优先选择矩形角落上的接壤区块;没有角落接壤时,选择离任一角落最近的接壤区块。 * 则纳入本次扩张计划;否则跳过(保持 S 形顺序一路扫过去,跳过的不打乱后续顺序)。
*/ */
private ChunkPos chooseStart(List<ChunkPos> borderChunks, int minX, int maxX, int minZ, int maxZ) { private void tryClaimRect(List<ChunkPos> result, Set<ChunkPos> claimed, Set<ChunkPos> allClaimed, int x, int z, int limit) {
ChunkPos[] corners = new ChunkPos[]{new ChunkPos(minX, minZ), new ChunkPos(maxX, minZ), new ChunkPos(minX, maxZ), new ChunkPos(maxX, maxZ)}; if (result.size() >= limit) {
for (ChunkPos corner : corners) { return;
if (borderChunks.contains(corner)) {
return corner;
}
} }
ChunkPos best = null; ChunkPos chunk = new ChunkPos(x, z);
int bestDist = Integer.MAX_VALUE; if (allClaimed.contains(chunk) || claimed.contains(chunk)) {
for (ChunkPos chunk : borderChunks) { return;
for (ChunkPos corner : corners) {
int dist = Math.abs(chunk.x() - corner.x()) + Math.abs(chunk.z() - corner.z());
if (dist < bestDist) {
bestDist = dist;
best = chunk;
}
}
} }
return best; if (this.ownNeighbors(chunk, claimed) == 0) {
} return;
/**
* 生成从起点开始的 S 形扫描路径,覆盖矩形内所有区块。
*
* <p>扫描主轴根据起点所在边缘决定:起点在顶/底行时横向逐行扫描,
* 起点在左/右列时纵向逐列扫描。每行/列内方向交替反向,形成 S 形。
* 起点所在行/列从起点向两侧铺开,保证起点是路径第一个区块。
*/
private List<ChunkPos> buildSPath(ChunkPos start, int minX, int maxX, int minZ, int maxZ) {
ArrayList<ChunkPos> path = new ArrayList<ChunkPos>();
boolean horizontal = start.z() == minZ || start.z() == maxZ;
if (horizontal) {
int startRow = start.z();
// 起点行:从起点向一侧到边缘,再反向到另一侧,完整覆盖该行
boolean leftToRight = start.x() == minX;
if (leftToRight) {
for (int x = start.x(); x <= maxX; ++x) path.add(new ChunkPos(x, startRow));
for (int x = start.x() - 1; x >= minX; --x) path.add(new ChunkPos(x, startRow));
} else {
for (int x = start.x(); x >= minX; --x) path.add(new ChunkPos(x, startRow));
for (int x = start.x() + 1; x <= maxX; ++x) path.add(new ChunkPos(x, startRow));
}
// 其余行:先向下(+z)再向上(-z),每行方向交替,形成 S 形
boolean ltr = leftToRight;
for (int z = startRow + 1; z <= maxZ; ++z) {
this.appendRow(path, z, minX, maxX, ltr);
ltr = !ltr;
}
for (int z = startRow - 1; z >= minZ; --z) {
this.appendRow(path, z, minX, maxX, ltr);
ltr = !ltr;
}
} else {
int startCol = start.x();
// 起点列:从起点向一侧到边缘,再反向到另一侧,完整覆盖该列
boolean topToBottom = start.z() == minZ;
if (topToBottom) {
for (int z = start.z(); z <= maxZ; ++z) path.add(new ChunkPos(startCol, z));
for (int z = start.z() - 1; z >= minZ; --z) path.add(new ChunkPos(startCol, z));
} else {
for (int z = start.z(); z >= minZ; --z) path.add(new ChunkPos(startCol, z));
for (int z = start.z() + 1; z <= maxZ; ++z) path.add(new ChunkPos(startCol, z));
}
// 其余列:先向右(+x)再向左(-x),每列方向交替,形成 S 形
boolean ttb = topToBottom;
for (int x = startCol + 1; x <= maxX; ++x) {
this.appendCol(path, x, minZ, maxZ, ttb);
ttb = !ttb;
}
for (int x = startCol - 1; x >= minX; --x) {
this.appendCol(path, x, minZ, maxZ, ttb);
ttb = !ttb;
}
}
return path;
}
/** 追加一行区块到路径(leftToRight=true 时从左到右,否则从右到左) */
private void appendRow(List<ChunkPos> path, int row, int minX, int maxX, boolean leftToRight) {
if (leftToRight) {
for (int x = minX; x <= maxX; ++x) path.add(new ChunkPos(x, row));
} else {
for (int x = maxX; x >= minX; --x) path.add(new ChunkPos(x, row));
}
}
/** 追加一列区块到路径(topToBottom=true 时从上到下,否则从下到上) */
private void appendCol(List<ChunkPos> path, int col, int minZ, int maxZ, boolean topToBottom) {
if (topToBottom) {
for (int z = minZ; z <= maxZ; ++z) path.add(new ChunkPos(col, z));
} else {
for (int z = maxZ; z >= minZ; --z) path.add(new ChunkPos(col, z));
} }
result.add(chunk);
claimed.add(chunk);
} }
/** 在候选集中找到离当前游标最近的一个区块(曼哈顿距离),作为推进的前沿 */ /** 在候选集中找到离当前游标最近的一个区块(曼哈顿距离),作为推进的前沿 */
@@ -473,12 +484,18 @@ 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);
} }
/** 统计某区块周围(上下左右)属于给定集合的邻居数量 */ /**
* 统计某区块周围(上下左右四个正方向)属于给定集合的邻居数量。
*
* <p>调用方必须传入“己方领土”集合(含本次规划已纳入的区块)作为 {@code own},
* 用于判断区块是否与己方领土接壤。绝不能把其他邦国的领土混入该集合,
* 否则会误判“与他国接壤”为“可开拓”,选中一个与己方领土不相邻的无效目标。</p>
*/
private int ownNeighbors(ChunkPos chunk, Set<ChunkPos> own) { private int ownNeighbors(ChunkPos chunk, Set<ChunkPos> own) {
int count = 0; int count = 0;
for (ChunkPos neighbor : this.cardinalNeighbors(chunk)) { for (ChunkPos neighbor : this.cardinalNeighbors(chunk)) {
@@ -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;
@@ -24,9 +25,11 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
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;
@@ -37,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 服务器) */
@@ -65,9 +75,57 @@ public final class TerritoryDataService {
private volatile long lastSuccessfulFetchMillis; private volatile long lastSuccessfulFetchMillis;
/** 最近一次尝试拉取的时间戳(毫秒) */ /** 最近一次尝试拉取的时间戳(毫秒) */
private volatile long lastAttemptMillis; private volatile long lastAttemptMillis;
/** 会话内新占领区块的本地记录时间(毫秒),用于刷新时判断该占领是否已被地图数据确认 */
private final Map<ChunkPos, Long> sessionClaimTimes = new ConcurrentHashMap<>();
/**
* 会话占领"确认宽限"时长(毫秒)。
*
* <p>占领成功后,服务器动态地图(Dynmap/markers.json)可能尚未同步该区块,
* 因此在宽限期内即使地图未确认也先保留"会话占领"标记,避免刚占领的区块被误判为假占领。
* 超过宽限期仍未在地图中确认的,视为未真正占领并丢弃,使空洞区块能重新进入规划。
* (v1.2.1 由 120 秒缩短为 60 秒:配合请求侧缓存击穿,Dynmap 秒级同步,缩短可更快修正假占领)</p>
*/
private static final long CLAIM_CONFIRM_GRACE_MS = 60_000L;
/**
* ChunkPos 驻留池:坐标 → 复用实例。
*
* <p>每次解析 markers.json 会创建约 8 万个 ChunkPos,且各次刷新的坐标高度重合。
* 驻留后新旧模型共享同一批实例,刷新时垃圾量从"整份模型"降为"集合外壳",
* 显著降低 GC 压力(缓解刷新时游戏卡顿与内存占用)。</p>
*/
private static final ConcurrentHashMap<Long, ChunkPos> CHUNK_POOL = new ConcurrentHashMap<Long, ChunkPos>();
/** 上次成功响应的正文哈希与长度,用于识别"网络缓存返回了相同旧数据"并跳过重复解析 */
private volatile int lastBodyHash;
private volatile int lastBodyLength = -1;
/**
* API 数据版本号:apiTerritory/apiCapitals 每次成功更新后自增。
* 参与 refreshAsync 的"内容未变化跳过解析"判定——Dynmap 正文未变但 API 数据更新时,
* 也必须重新解析合并,否则 API 新确认的区块/占领要等到 Dynmap 下次变化才生效。
*/
private volatile long apiDataVersion;
/** 上次解析合并模型时使用的 API 数据版本(与 apiDataVersion 配合做变更检测) */
private volatile long lastMergedApiVersion = -1;
// ==================== SimpCore 开放 API 集成 ====================
/** SimpCore 开放 API 客户端(第四占领判定数据源:字幕/聊天/Dynmap 之外) */
private final SimpApiClient apiClient = new SimpApiClient();
/** API 轮询调度器(独立于 Dynmap 刷新线程,避免慢请求拖垮地图刷新) */
private volatile ScheduledExecutorService apiExecutor;
/** API 轮询间隔(秒);0 表示未启用 */
private volatile long apiPollIntervalSeconds;
/** API 最近一次成功拉取的 邦国名 → 区块集合(用于展示/调试) */
private final ConcurrentHashMap<String, Set<ChunkPos>> apiTerritory = new ConcurrentHashMap<>();
/** API 最近一次成功拉取的 邦国名 → 王城区块 */
private final ConcurrentHashMap<String, ChunkPos> apiCapitals = new ConcurrentHashMap<>();
/** API 最近一次成功拉取的时间戳(毫秒),0 表示从未成功 */
private volatile long lastApiSuccessMillis;
/** API 邦国在地图上的显示颜色(紫 #FF43DE),与 Dynmap 数据区分来源 */
private static final int API_STATE_COLOR = 0xFFFF43DE;
public TerritoryDataService() { public TerritoryDataService() {
this.executor = TerritoryDataService.newExecutor(); this.executor = TerritoryDataService.newExecutor();
this.apiExecutor = TerritoryDataService.newApiExecutor();
} }
/** 创建单线程调度器(守护线程,不阻塞游戏退出) */ /** 创建单线程调度器(守护线程,不阻塞游戏退出) */
@@ -79,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 秒)循环拉取。
* *
@@ -102,36 +347,89 @@ 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();
} }
/** /**
* 异步刷新领土数据(在调度线程中执行)。 * 异步刷新领土数据(在调度线程中执行)。
* 拉取成功后解析 JSON,并把上一份模型中的"会话新占领区块"合并进新模型, * 拉取成功后解析 JSON,并把上一份模型中的"会话新占领区块"与刚拉取的最新地图数据仔细核对,
* 避免刷新覆盖掉本次会话的占领记录。 * 只保留"已被地图确认"或"刚占领未超宽限期"的占领记录,避免刷新时把假占领永久留在模型里。
*/ */
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 {
HttpRequest request = HttpRequest.newBuilder(URI.create(MARKERS_URL)).timeout(Duration.ofSeconds(15L)).header("User-Agent", "Mozilla/5.0 autostore/1.0").GET().build(); // 请求侧缓存击穿:URL 追加时间戳参数 + no-cache 头,确保每次都拿到服务器最新数据。
// 此前约半数刷新命中网络缓存拿到旧数据,导致会话占领迟迟无法被地图确认/纠正。
String url = MARKERS_URL + "?_=" + System.currentTimeMillis();
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15L))
.header("User-Agent", "Mozilla/5.0 autostore/1.2.1")
.header("Cache-Control", "no-cache, no-store")
.header("Pragma", "no-cache").GET().build();
HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) { if (response.statusCode() < 200 || response.statusCode() >= 300) {
this.lastError = "HTTP " + response.statusCode(); this.lastError = "HTTP " + response.statusCode();
return; return;
} }
String body = response.body();
// 内容未变化(服务器确实无更新,或缓存仍命中相同旧文)且 API 数据也未更新:跳过重复解析,
// 避免"每次刷新都重建 8 万区块模型"的 CPU/GC 开销;模型与飞地缓存保持原样。
// 注意:API 数据版本必须参与比较——Dynmap 正文未变但 API 拉到新数据时仍需重新解析合并。
if (body.length() == this.lastBodyLength && body.hashCode() == this.lastBodyHash
&& apiVersionAtStart == this.lastMergedApiVersion) {
this.lastSuccessfulFetchMillis = System.currentTimeMillis();
this.lastError = "";
return;
}
this.lastBodyHash = body.hashCode();
this.lastBodyLength = body.length();
this.lastMergedApiVersion = apiVersionAtStart;
TerritoryModel parsed = TerritoryDataService.parse(response.body()); 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();
// 本次拉取到的己方领土区块(Dynmap 与 API 任一确认即算已确认)
Set<ChunkPos> freshOwn = new HashSet<>();
TerritoryState freshSelf = parsed.state(this.selectedStateName);
if (freshSelf != null) {
freshOwn.addAll(freshSelf.chunks());
}
Set<ChunkPos> apiOwn = apiSnapshot.getOrDefault(this.selectedStateName, Set.of());
for (ChunkPos sessionClaim : previous.sessionClaims()) { for (ChunkPos sessionClaim : previous.sessionClaims()) {
parsed.addSessionClaim(sessionClaim); Long claimTime = this.sessionClaimTimes.get(sessionClaim);
// Dynmap 已确认,或 API 已确认(任一来源确认即可),或尚在宽限期
boolean confirmed = freshOwn.contains(sessionClaim) || apiOwn.contains(sessionClaim);
boolean withinGrace = claimTime != null && now - claimTime < CLAIM_CONFIRM_GRACE_MS;
if (confirmed || withinGrace) {
// 地图/API 已确认,或尚在宽限期(服务端可能还没同步):保留会话占领标记
parsed.addSessionClaim(sessionClaim);
} else if (claimTime != null) {
// 所有数据源始终未确认且超时:判定为假占领并移除,使空洞区块重新进入规划
this.sessionClaimTimes.remove(sessionClaim);
}
// claimTime 为 null(从未在本服务记录)也视为未确认:不保留
} }
} }
parsed.setSelfStateName(this.selectedStateName); parsed.setSelfStateName(this.selectedStateName);
// 后台线程预计算飞地缓存:把"各国外接矩形全量扫描"从渲染帧挪到这里,
// 修复"刷新地图/打开 GUI 时游戏卡一下"的问题
parsed.precomputeFlyingChunks();
this.model = parsed; this.model = parsed;
this.lastSuccessfulFetchMillis = System.currentTimeMillis(); this.lastSuccessfulFetchMillis = System.currentTimeMillis();
this.lastError = ""; this.lastError = "";
@@ -163,6 +461,7 @@ public final class TerritoryDataService {
} }
public void addSessionClaim(ChunkPos chunk) { public void addSessionClaim(ChunkPos chunk) {
this.sessionClaimTimes.put(chunk, System.currentTimeMillis());
TerritoryModel current = this.model; TerritoryModel current = this.model;
if (current != null) { if (current != null) {
current.addSessionClaim(chunk); current.addSessionClaim(chunk);
@@ -240,9 +539,9 @@ public final class TerritoryDataService {
return; return;
} }
Set chunks = claims.computeIfAbsent(name, ignored -> new HashSet()); Set chunks = claims.computeIfAbsent(name, ignored -> new HashSet());
for (List<int[]> polygon : TerritoryDataService.extractPolygons(marker.get("points"))) { // 一个 marker 的多个多边形遵循 dynmap 的 even-odd 填充规则:
TerritoryDataService.fillPolygon(chunks, polygon); // 外圈为正,内圈多边形(国中飞地的边界)作为孔洞被减去,而非作为独立领土并集填充。
} TerritoryDataService.fillPolygonEvenOdd(chunks, TerritoryDataService.extractPolygons(marker.get("points")));
colors.putIfAbsent(name, TerritoryDataService.parseColor(TerritoryDataService.stringOr(marker, "color", "#75bd20"))); colors.putIfAbsent(name, TerritoryDataService.parseColor(TerritoryDataService.stringOr(marker, "color", "#75bd20")));
} }
@@ -308,35 +607,59 @@ public final class TerritoryDataService {
} }
/** /**
* 将多边形覆盖的区块填充进集合: * 将多边形组覆盖的区块按 even-odd 规则填充进集合。
* 先算出多边形外接矩形(换算为区块坐标),再逐区块用"点在多边形内"算法判断, *
* 以区块中心点(cx*16+8, cz*16+8)是否落在多边形内为准。 * <p>一个 marker 里的多个多边形遵循 dynmap 的奇偶填充:区块落入<b>奇数个</b>多边形内时才视为领土;
* 面积超过 600 万区块时直接放弃,防止异常数据导致卡死。 * 这样内圈多边形(国中飞地/孔洞的边界)会被正确减去,而不是像并集那样把孔洞也填成领土。</p>
*
* @param output 输出集合(该邦国的区块集合)
* @param polygons 该 marker 的全部多边形,第一个为外圈、可为多个内圈孔洞
*/ */
private static void fillPolygon(Set<ChunkPos> output, List<int[]> polygon) { private static void fillPolygonEvenOdd(Set<ChunkPos> output, List<List<int[]>> polygons) {
int area; if (polygons.isEmpty()) {
return;
}
int minX = Integer.MAX_VALUE; int minX = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE; int maxX = Integer.MIN_VALUE;
int minZ = Integer.MAX_VALUE; int minZ = Integer.MAX_VALUE;
int maxZ = Integer.MIN_VALUE; int maxZ = Integer.MIN_VALUE;
for (int[] point : polygon) { for (List<int[]> polygon : polygons) {
minX = Math.min(minX, Math.floorDiv(point[0], 16)); for (int[] point : polygon) {
maxX = Math.max(maxX, Math.floorDiv(point[0], 16)); minX = Math.min(minX, Math.floorDiv(point[0], 16));
minZ = Math.min(minZ, Math.floorDiv(point[1], 16)); maxX = Math.max(maxX, Math.floorDiv(point[0], 16));
maxZ = Math.max(maxZ, Math.floorDiv(point[1], 16)); minZ = Math.min(minZ, Math.floorDiv(point[1], 16));
maxZ = Math.max(maxZ, Math.floorDiv(point[1], 16));
}
} }
int n = area = ((maxX - minX) + 1L) * ((maxZ - minZ) + 1L) > Integer.MAX_VALUE ? Integer.MAX_VALUE : (maxX - minX + 1) * (maxZ - minZ + 1); // 面积超过 600 万区块时直接放弃,防止异常数据导致卡死
if (area > 6000000) { if ((long)(maxX - minX + 1) * (maxZ - minZ + 1) > 6000000L) {
return; return;
} }
for (int cx = minX; cx <= maxX; ++cx) { for (int cx = minX; cx <= maxX; ++cx) {
for (int cz = minZ; cz <= maxZ; ++cz) { for (int cz = minZ; cz <= maxZ; ++cz) {
if (!TerritoryDataService.pointInPolygon(cx * 16 + 8, cz * 16 + 8, polygon)) continue; // 奇偶填充:落在奇数个多边形内则为领土;否则(含恰好落在偶数个内圈孔洞中)忽略
output.add(new ChunkPos(cx, cz)); boolean inside = false;
for (List<int[]> polygon : polygons) {
if (TerritoryDataService.pointInPolygon(cx * 16 + 8, cz * 16 + 8, polygon)) {
inside = !inside;
}
}
if (inside) {
output.add(TerritoryDataService.internChunk(cx, cz));
}
} }
} }
} }
/**
* 驻留池获取区块坐标实例:同一坐标跨模型复用同一对象。
* 刷新频率下大幅减少 ChunkPos 临时分配与 GC 压力(见 {@link #CHUNK_POOL})。
*/
private static ChunkPos internChunk(int x, int z) {
long key = (long)x << 32 | (long)z & 0xFFFFFFFFL;
return TerritoryDataService.CHUNK_POOL.computeIfAbsent(key, k -> new ChunkPos(x, z));
}
/** 射线法判断点 (x, z) 是否在多边形内(经典奇偶规则) */ /** 射线法判断点 (x, z) 是否在多边形内(经典奇偶规则) */
private static boolean pointInPolygon(int x, int z, List<int[]> polygon) { private static boolean pointInPolygon(int x, int z, List<int[]> polygon) {
boolean inside = false; boolean inside = false;
@@ -27,14 +27,26 @@ 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;
/** 本次会话新占领的区块 */ /** 本次会话新占领的区块 */
private final Set<ChunkPos> sessionClaims; private final Set<ChunkPos> sessionClaims;
/** 当前选中的己方邦国名称 */ /** 当前选中的己方邦国名称 */
private String selfStateName = ""; private String selfStateName = "";
/**
* 飞地(疏漏区块)缓存:邦国名 → 该国飞地区块集合。
*
* <p>飞地指"四周(四个正方向)都被同一邦国占领、但自身未被占领"的区块,
* 通常是自动扩地时漏掉的疏漏。该结果在模型构建后懒计算并缓存,
* 仅在新增会话占领(addSessionClaim)后失效重算。
* 字段为 volatile:后台线程可调用 {@link #precomputeFlyingChunks()} 预热,
* 渲染线程读取时保证可见性,避免把全量扫描留在渲染帧里(卡顿来源)。</p>
*/
private transient volatile Map<String, Set<ChunkPos>> cachedFlyingChunks;
/** 所有邦国飞地的合并并集缓存(volatile,同上);仅少量区块,供 GUI 直接遍历 */
private transient volatile Set<ChunkPos> cachedAllFlyingChunks;
public TerritoryModel(Map<String, TerritoryState> states) { public TerritoryModel(Map<String, TerritoryState> states) {
this.states = new HashMap<String, TerritoryState>(states); this.states = new HashMap<String, TerritoryState>(states);
@@ -94,6 +106,95 @@ public final class TerritoryModel {
return null; return null;
} }
/**
* 返回所有邦国的飞地(疏漏区块)并集(只读视图,结果缓存,帧间零分配)。
*
* <p>飞地指“四周(四个正方向)都被同一邦国占领、但自身未被占领”的区块。
* 这些往往是自动扩地过程中漏掉的疏漏,需要在地图上标出并优先填补。</p>
*/
public Set<ChunkPos> flyingChunks() {
Set<ChunkPos> cached = this.cachedAllFlyingChunks;
if (cached != null) {
return cached;
}
HashSet<ChunkPos> result = new HashSet<ChunkPos>();
for (Set<ChunkPos> chunks : this.flyingChunksByState().values()) {
result.addAll(chunks);
}
Set<ChunkPos> frozen = Collections.unmodifiableSet(result);
this.cachedAllFlyingChunks = frozen;
return frozen;
}
/** 返回己方邦国的飞地(疏漏区块)(只读视图);未选择邦国时为空集 */
public Set<ChunkPos> selfFlyingChunks() {
return Collections.unmodifiableSet(this.flyingChunksByState().getOrDefault(this.selfStateName, Set.of()));
}
/**
* 后台预计算飞地缓存(在数据刷新线程调用):
* 把"各国外接矩形全量扫描"提前到后台线程完成,
* 渲染线程读到的永远是现成缓存,避免模型刷新后第一帧卡顿。
*/
public void precomputeFlyingChunks() {
this.flyingChunks();
}
/** 计算并缓存各邦国的飞地区块(懒加载,addSessionClaim 后失效重算) */
private Map<String, Set<ChunkPos>> flyingChunksByState() {
Map<String, Set<ChunkPos>> cached = this.cachedFlyingChunks;
if (cached != null) {
return cached;
}
HashMap<String, Set<ChunkPos>> map = new HashMap<String, Set<ChunkPos>>();
for (TerritoryState state : this.states.values()) {
map.put(state.name(), this.computeFlyingChunks(state.chunks()));
}
this.cachedFlyingChunks = map;
return map;
}
/**
* 计算某邦国的全部飞地区块:
* 在该邦国的外接矩形内,找出“未被任何邦国占领、但四个正方向均属于该邦国”的区块。
*/
private Set<ChunkPos> computeFlyingChunks(Set<ChunkPos> own) {
HashSet<ChunkPos> result = new HashSet<ChunkPos>();
if (own.isEmpty()) {
return result;
}
int minX = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int minZ = Integer.MAX_VALUE;
int maxZ = Integer.MIN_VALUE;
for (ChunkPos chunk : own) {
minX = Math.min(minX, chunk.x());
maxX = Math.max(maxX, chunk.x());
minZ = Math.min(minZ, chunk.z());
maxZ = Math.max(maxZ, chunk.z());
}
for (int cx = minX; cx <= maxX; ++cx) {
for (int cz = minZ; cz <= maxZ; ++cz) {
ChunkPos chunk = new ChunkPos(cx, cz);
if (own.contains(chunk) || this.allClaimed.contains(chunk)) {
continue;
}
if (this.surroundedBySameState(chunk, own)) {
result.add(chunk);
}
}
}
return result;
}
/** 判断区块的四个正方向邻居是否全部属于给定邦国的区块集合 */
private boolean surroundedBySameState(ChunkPos chunk, Set<ChunkPos> own) {
return own.contains(new ChunkPos(chunk.x() + 1, chunk.z()))
&& own.contains(new ChunkPos(chunk.x() - 1, chunk.z()))
&& own.contains(new ChunkPos(chunk.x(), chunk.z() + 1))
&& own.contains(new ChunkPos(chunk.x(), chunk.z() - 1));
}
/** /**
* 记录一次会话内新占领的区块: * 记录一次会话内新占领的区块:
* 加入会话集合与全局已占领集合,并同步更新己方邦国的区块集合。 * 加入会话集合与全局已占领集合,并同步更新己方邦国的区块集合。
@@ -101,6 +202,8 @@ public final class TerritoryModel {
public void addSessionClaim(ChunkPos chunk) { public void addSessionClaim(ChunkPos chunk) {
this.sessionClaims.add(chunk); this.sessionClaims.add(chunk);
this.allClaimed.add(chunk); this.allClaimed.add(chunk);
this.cachedFlyingChunks = null;
this.cachedAllFlyingChunks = null;
TerritoryState self = this.states.get(this.selfStateName); TerritoryState self = this.states.get(this.selfStateName);
if (self != null) { if (self != null) {
HashSet<ChunkPos> updated = new HashSet<ChunkPos>(self.chunks()); HashSet<ChunkPos> updated = new HashSet<ChunkPos>(self.chunks());
@@ -108,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
查看文件
@@ -1 +1 @@
{"schemaVersion":1,"id":"autostore","version":"1.1.1","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"}]}