Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0b9a10ae9 | ||
|
|
874fe81039 |
No files matched your search
@@ -32,3 +32,7 @@ Thumbs.db
|
||||
# 发版 token
|
||||
maipulse/tools/.release-token
|
||||
tools/.release-token
|
||||
# 打包时从 maipulse/ 复制进去的运行时副本,不入库
|
||||
android/app/src/main/assets/
|
||||
android/.gradle/
|
||||
android/build/
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
// 和网页里的 APP_VERSION 对齐,升级时一起改
|
||||
versionCode 102
|
||||
versionName "1.0.2"
|
||||
versionCode 103
|
||||
versionName "1.0.3"
|
||||
|
||||
ndk {
|
||||
abiFilters "arm64-v8a"
|
||||
|
||||
@@ -736,6 +736,12 @@ class PlayData:
|
||||
default_factory=lambda: config.PLAY_MUSIC_PLAY_COUNT_GAIN)
|
||||
map_distance_gain: int = field(default_factory=lambda: config.PLAY_MAP_DISTANCE_GAIN)
|
||||
character_exp_gain: int = field(default_factory=lambda: config.PLAY_CHARACTER_EXP_GAIN)
|
||||
#: ★ 目标地图:0 = 不切换(沿用服务器下发的 selectMapId,只推进当前选中的那张)。
|
||||
#: 非 0 = 先把 userData.selectMapId 切到该 mapId,再把 distance 加在它身上。
|
||||
#: 注意:服务端是否接受客户端改 selectMapId 尚未在真机验证 —— 真机样本里
|
||||
#: selectMapId 只出现在 Get 侧,Upsert 侧从没被改过。若服务端忽略该字段,
|
||||
#: 推进仍会落在「服务器当前选中的地图」上,而不是这里指定的那张。
|
||||
select_map_id: int = 0
|
||||
|
||||
# ---- 里程 / 登录奖励 ----
|
||||
# ★ 这两个是**登录奖励发放**才产生的,普通游玩结算不回传(留空)。
|
||||
@@ -1757,6 +1763,11 @@ def apply_play_effects(
|
||||
ud["dateTime"] = int(auth_date_time)
|
||||
if ud.get("banState") is None:
|
||||
ud["banState"] = 0
|
||||
# ★ 切区域:只有显式给了目标 mapId 才写入;否则保持服务器下发的 selectMapId。
|
||||
# 放在这里(而不是差分段)是为了让下面 userMapList 能读到"切换后"的值。
|
||||
_want_map = int(getattr(play, "select_map_id", 0) or 0)
|
||||
if _want_map:
|
||||
ud["selectMapId"] = _want_map
|
||||
|
||||
# ---------------- userExtend ----------------
|
||||
ext_list = user_all.get("userExtend") or []
|
||||
@@ -1824,16 +1835,30 @@ def apply_play_effects(
|
||||
if "userMapList" in user_all:
|
||||
if play.map_distance_gain:
|
||||
baseline = user_all.get("_baseline_map") or []
|
||||
map_id = None
|
||||
ud = (user_all.get("userData") or [{}])[0]
|
||||
if isinstance(ud, dict):
|
||||
map_id = ud.get("selectMapId")
|
||||
want = int(getattr(play, "select_map_id", 0) or 0)
|
||||
map_id = want or (ud.get("selectMapId") if isinstance(ud, dict) else None)
|
||||
target = None
|
||||
for item in baseline:
|
||||
if isinstance(item, dict) and item.get("mapId") == map_id:
|
||||
target = copy.deepcopy(item)
|
||||
break
|
||||
if target is None and baseline and isinstance(baseline[0], dict):
|
||||
if target is None:
|
||||
if map_id:
|
||||
# ★ 目标地图不在玩家基线里(没进过这个区域):
|
||||
# 按 GetUserMap 返回的字段形状造一条全新记录,distance 从 0 起算。
|
||||
# 这条会因「新状态有、基线没有」在差分环节被保留,
|
||||
# isNewMapList 自动打 "1"(新增)。
|
||||
target = {
|
||||
"mapId": int(map_id),
|
||||
"distance": 0,
|
||||
"isLock": False,
|
||||
"isClear": False,
|
||||
"isComplete": False,
|
||||
"unlockFlag": 0,
|
||||
}
|
||||
log_info("userMapList:目标地图 " + str(map_id) + " 不在基线中,按新记录构造")
|
||||
elif baseline and isinstance(baseline[0], dict):
|
||||
target = copy.deepcopy(baseline[0])
|
||||
if target is not None:
|
||||
target["distance"] = int(target.get("distance", 0) or 0) + play.map_distance_gain
|
||||
|
||||
+59
-9
@@ -37,6 +37,7 @@ from flask import Flask, jsonify, request, send_file # noqa: E402
|
||||
import api # noqa: E402
|
||||
from core import accounts, charge, items, repair, session, shop, unlock, b50, profile # noqa: E402
|
||||
from core import characters, mileage, history, recommend as recommend_mod # noqa: E402
|
||||
from core import runmap # noqa: E402
|
||||
app = Flask(__name__, static_folder=None)
|
||||
|
||||
LOCK = threading.Lock()
|
||||
@@ -370,9 +371,13 @@ def api_ds():
|
||||
@app.post("/api/logout")
|
||||
@guarded
|
||||
def api_logout():
|
||||
if not S["logged_in"]:
|
||||
# ★ 只要还有 token 就去登出:热重启/异常后 logged_in 可能已经是 False,
|
||||
# 但服务端会话还活着,这时候不踢掉就会卡 isLogin=True,再扫码 returnCode=100。
|
||||
if not S.get("logged_in") and not S.get("token"):
|
||||
return fail("未登录")
|
||||
resp = session.logout(S)
|
||||
S.update({"logged_in": False, "is_login": False, "token": "",
|
||||
"login_id": "", "login_at": 0})
|
||||
return ok(returnCode=getattr(resp, "returnCode", None))
|
||||
|
||||
|
||||
@@ -387,11 +392,11 @@ def api_shutdown():
|
||||
"""
|
||||
info = {}
|
||||
try:
|
||||
if S.get("logged_in"):
|
||||
if S.get("logged_in") or S.get("token"):
|
||||
resp = session.logout(S)
|
||||
info["logout"] = getattr(resp, "returnCode", None)
|
||||
else:
|
||||
info["logout"] = "未登录,跳过"
|
||||
info["logout"] = "没有会话,跳过"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
info["logoutError"] = str(exc)
|
||||
threading.Timer(0.8, lambda: os._exit(0)).start()
|
||||
@@ -408,20 +413,24 @@ def api_reload():
|
||||
登录态和会话都保住。只重载 core 包内的纯逻辑模块,不碰 api / Flask。
|
||||
"""
|
||||
import importlib
|
||||
names = ["core.settle", "core.play", "core.items", "core.unlock",
|
||||
"core.charge", "core.repair", "core.session", "core.shop",
|
||||
"core.b50", "core.profile", "core.characters", "core.mileage"]
|
||||
import pkgutil
|
||||
import core as _core_pkg
|
||||
# ★ 自动发现 core 包下所有已加载的模块,别再手写列表:
|
||||
# 漏一个(比如后加的 runmap)就会出现「改了代码、reload 报成功、实际没生效」。
|
||||
names = sorted("core." + m.name for m in pkgutil.iter_modules(_core_pkg.__path__))
|
||||
done = []
|
||||
skipped = []
|
||||
for name in names:
|
||||
mod = sys.modules.get(name)
|
||||
if mod is None:
|
||||
skipped.append(name) # 没被 import 过的不用管
|
||||
continue
|
||||
try:
|
||||
importlib.reload(mod)
|
||||
done.append(name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return fail("重载 %s 失败:%s" % (name, exc))
|
||||
return ok(reloaded=done, logged=bool(S.get("logged_in")))
|
||||
return ok(reloaded=done, skipped=skipped, logged=bool(S.get("logged_in")))
|
||||
|
||||
|
||||
@app.get("/api/state")
|
||||
@@ -655,6 +664,47 @@ def api_unlock():
|
||||
return ok(**result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 一键跑图
|
||||
|
||||
@app.get("/api/runmap")
|
||||
@guarded
|
||||
def api_runmap_list():
|
||||
"""区域列表 + 玩家当前进度(只读)。"""
|
||||
need_login()
|
||||
try:
|
||||
return ok(**runmap.overview(S))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return fail(str(exc))
|
||||
|
||||
|
||||
@app.post("/api/runmap")
|
||||
@guarded
|
||||
def api_runmap_write():
|
||||
"""一键跑图:把选中区域推到终点,或推指定距离。默认 dry-run。"""
|
||||
need_login()
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
map_id = int(body.get("mapId") or 0)
|
||||
distance = int(body.get("distance") or 0)
|
||||
step = int(body.get("step") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return fail("mapId / distance / step 必须都是数字")
|
||||
if not map_id:
|
||||
return fail("没有选区区域")
|
||||
mode = str(body.get("mode") or "fill")
|
||||
if mode not in ("fill", "delta"):
|
||||
return fail("mode 只能是 fill 或 delta")
|
||||
if mode == "delta" and distance <= 0:
|
||||
return fail("delta 模式下 distance 必须大于 0")
|
||||
try:
|
||||
result = runmap.run(S, map_id, mode=mode, distance=distance, step=step,
|
||||
dry_run=bool(body.get("dryRun", True)),
|
||||
cooldown=float(body.get("cooldown", 0)))
|
||||
return ok(**result)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return fail(str(exc))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 小黑屋
|
||||
|
||||
@app.post("/api/unstick")
|
||||
@@ -874,8 +924,8 @@ def api_master():
|
||||
|
||||
# ---------------------------------------------------------------- 更新
|
||||
|
||||
APP_VERSION = "1.0.2"
|
||||
APP_BUILD = "2026-10-30"
|
||||
APP_VERSION = "1.0.3"
|
||||
APP_BUILD = "2026-11-04"
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""一键跑图:把指定区域的地图进度推到终点。
|
||||
|
||||
机制(不是独立接口,走 UpsertUserAll 的差分):
|
||||
userData.selectMapId 指向本次推进的地图
|
||||
userMapList 只回传「distance 变了」的那一条
|
||||
PlayData.map_distance_gain 每局推进的距离
|
||||
|
||||
地图表来自游戏包 StreamingAssets/<版本>/map/MapXXXXXX/Map.xml:
|
||||
TreasureExDatas 里每条奖励带一个 Distance,
|
||||
Flag=End 那条就是跑完这张图的终点距离。
|
||||
|
||||
★ 两个现场才看得出来的不确定点(代码已按最乐观实现,需要真实账号验证):
|
||||
1. 服务端是否接受客户端改写 selectMapId。真机样本里 selectMapId 只出现在
|
||||
Get 侧,Upsert 侧从没被改过。若不接受,推进仍会落在服务器当前选中的图上。
|
||||
2. 一局能走多远。真机一局 11000,跑完 Phigros 要 68 局;一次性 +750000
|
||||
可能被拒绝或截断。所以默认分段(step)推进,而不是一把梭。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import api
|
||||
|
||||
from . import settle, session
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MAP_JSON = os.path.join(BASE_DIR, "data", "master", "map.json")
|
||||
|
||||
#: 真机一局走过的距离(机台实测值)
|
||||
STEP_DISTANCE = 11000
|
||||
|
||||
_CATALOG = None
|
||||
|
||||
|
||||
def catalog() -> list:
|
||||
"""全部区域(含终点距离)。按 mapId 升序。"""
|
||||
global _CATALOG
|
||||
if _CATALOG is None:
|
||||
if not os.path.isfile(MAP_JSON):
|
||||
_CATALOG = []
|
||||
else:
|
||||
with open(MAP_JSON, encoding="utf-8") as fp:
|
||||
_CATALOG = json.load(fp)
|
||||
return _CATALOG
|
||||
|
||||
|
||||
def find_area(map_id: int) -> dict:
|
||||
mid = int(map_id)
|
||||
for row in catalog():
|
||||
try:
|
||||
if int(row.get("mapId") or 0) == mid:
|
||||
return row
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def progress(snapshot: dict) -> dict:
|
||||
"""从快照取玩家各地图进度:mapId -> {distance, isClear, isComplete, isLock}。
|
||||
|
||||
★ 节点可能是「Get 响应包装」{"userMapList": [...]},要先拆一层。
|
||||
"""
|
||||
node = (snapshot or {}).get("userMapList")
|
||||
if isinstance(node, dict):
|
||||
node = node.get("userMapList") or []
|
||||
out = {}
|
||||
for row in node or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
mid = int(row.get("mapId") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not mid:
|
||||
continue
|
||||
out[mid] = {
|
||||
"distance": int(row.get("distance") or 0),
|
||||
"isClear": bool(row.get("isClear")),
|
||||
"isComplete": bool(row.get("isComplete")),
|
||||
"isLock": bool(row.get("isLock")),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def overview(state: dict) -> dict:
|
||||
"""区域列表 + 玩家进度,给前端渲染。"""
|
||||
user_id = int(state["user_id"])
|
||||
snapshot = api.GetNeedUserData(user_id, token=state["token"], save=False)
|
||||
have = progress(snapshot)
|
||||
select = None
|
||||
node = snapshot.get("userData")
|
||||
# ★ Get 侧 userData 是两层嵌套:{"userData": {"userId":…, "userData": {...}, "banState":0}}
|
||||
# 内层是**单个 dict**,不是 list;Upsert 侧才可能是 list。两种都要兼容。
|
||||
if isinstance(node, dict):
|
||||
inner = node.get("userData")
|
||||
if isinstance(inner, dict):
|
||||
node = [inner]
|
||||
elif isinstance(inner, list):
|
||||
node = inner
|
||||
elif isinstance(node, list):
|
||||
node = node
|
||||
if isinstance(node, dict):
|
||||
node = [node]
|
||||
if isinstance(node, list) and node and isinstance(node[0], dict):
|
||||
try:
|
||||
select = int(node[0].get("selectMapId") or 0) or None
|
||||
except (TypeError, ValueError):
|
||||
select = None
|
||||
|
||||
areas = []
|
||||
for row in catalog():
|
||||
mid = int(row.get("mapId") or 0)
|
||||
cur = have.get(mid) or {}
|
||||
end = int(row.get("endDistance") or 0)
|
||||
dist = int(cur.get("distance") or 0)
|
||||
area = dict(row)
|
||||
area.update({
|
||||
"current": dist,
|
||||
"cleared": bool(cur.get("isClear") or cur.get("isComplete")),
|
||||
"entered": mid in have,
|
||||
"selected": mid == select,
|
||||
"remain": max(0, end - dist) if end else None,
|
||||
"percent": (round(dist * 100.0 / end, 1) if end else None),
|
||||
})
|
||||
areas.append(area)
|
||||
return {"areas": areas, "selectMapId": select, "entered": len(have)}
|
||||
|
||||
|
||||
def plan(map_id: int, mode: str = "fill", distance: int = 0,
|
||||
current: int = 0, step: int = 0) -> list:
|
||||
"""算出要分几段推,每段推多少。返回 [gain, gain, ...]。"""
|
||||
area = find_area(map_id)
|
||||
end = int(area.get("endDistance") or 0)
|
||||
|
||||
if mode == "fill":
|
||||
total = max(0, end - int(current or 0))
|
||||
else:
|
||||
total = max(0, int(distance or 0))
|
||||
if total <= 0:
|
||||
return []
|
||||
|
||||
step = int(step or 0)
|
||||
if step <= 0:
|
||||
# 没指定就按「不超过终点的 1/8、也不超过 10 万」切,避免单包过猛
|
||||
cap = max(STEP_DISTANCE, min(100000, (end or total) // 8 or STEP_DISTANCE))
|
||||
step = max(STEP_DISTANCE, cap)
|
||||
|
||||
gains, left = [], total
|
||||
while left > 0:
|
||||
take = min(step, left)
|
||||
gains.append(take)
|
||||
left -= take
|
||||
return gains
|
||||
|
||||
|
||||
def run(state: dict, map_id: int, *, mode: str = "fill", distance: int = 0,
|
||||
step: int = 0, dry_run: bool = False, cooldown: float = 0.0,
|
||||
verify: bool = False) -> dict:
|
||||
"""把指定区域推进到终点(mode=fill)或推指定距离(mode=delta)。"""
|
||||
mid = int(map_id)
|
||||
area = find_area(mid)
|
||||
if not area:
|
||||
raise RuntimeError("没有这个区域:%s" % mid)
|
||||
|
||||
user_id = int(state["user_id"])
|
||||
probe = api.GetNeedUserData(user_id, token=state["token"], save=False)
|
||||
cur = (progress(probe).get(mid) or {}).get("distance") or 0
|
||||
gains = plan(mid, mode=mode, distance=distance, current=cur, step=step)
|
||||
|
||||
result = {
|
||||
"mode": mode,
|
||||
"mapId": mid,
|
||||
"name": area.get("name"),
|
||||
"endDistance": int(area.get("endDistance") or 0),
|
||||
"before": cur,
|
||||
"total": sum(gains),
|
||||
"batches": len(gains),
|
||||
"done": 0,
|
||||
"failed": 0,
|
||||
"dryRun": bool(dry_run),
|
||||
"returnCode": None,
|
||||
"errors": [],
|
||||
"steps": [],
|
||||
}
|
||||
if not gains:
|
||||
result["note"] = "已经是终点,不用推"
|
||||
return result
|
||||
|
||||
for index, gain in enumerate(gains, 1):
|
||||
one = _run_once(state, mid, gain, dry_run, cooldown, verify)
|
||||
result["returnCode"] = one.get("returnCode")
|
||||
if one.get("error"):
|
||||
result["failed"] += gain
|
||||
result["errors"].append("第 %d 段:%s" % (index, one["error"]))
|
||||
break
|
||||
result["done"] += gain
|
||||
result["steps"].append({"seq": index, "gain": gain,
|
||||
"after": one.get("after"),
|
||||
"returnCode": one.get("returnCode")})
|
||||
if dry_run:
|
||||
break
|
||||
result["after"] = result["before"] + result["done"]
|
||||
return result
|
||||
|
||||
|
||||
def _run_once(state: dict, map_id: int, gain: int,
|
||||
dry_run: bool, cooldown: float, verify: bool = False) -> dict:
|
||||
"""发一局,推进一小段。
|
||||
|
||||
★ verify=False(默认):发完不回头读。回读要再拉一次全量快照,
|
||||
一段就多几秒,跑几十段时几乎全是等回读。进度靠本地累加,
|
||||
真要看落地效果,跑完点一次「读进度」就行。
|
||||
"""
|
||||
user_id = int(state["user_id"])
|
||||
session.fresh(state)
|
||||
who = session.ids()
|
||||
snapshot = api.GetNeedUserData(user_id, token=state["token"], save=False)
|
||||
|
||||
play_data, playlog = settle.prepare(state, snapshot)
|
||||
play_data.map_distance_gain = int(gain)
|
||||
play_data.select_map_id = int(map_id)
|
||||
|
||||
settle.ensure_play_window(state)
|
||||
settle.upload(state, playlog)
|
||||
|
||||
patch = settle.patch(state, snapshot, play_data)
|
||||
resp = api.UpsertUserAll(
|
||||
user_id, state["login_id"],
|
||||
login_date_time=state["login_date_time"],
|
||||
auth_date_time=state["date_time"],
|
||||
token=state["token"],
|
||||
snapshot=snapshot,
|
||||
play=play_data,
|
||||
user_playlog_list=[playlog],
|
||||
patch=patch,
|
||||
verify_landing=bool(verify),
|
||||
auto_characters=False,
|
||||
place_id=who["place_id"], place_name=who["place_name"],
|
||||
region_id=who["region_id"], region_name=who["region_name"],
|
||||
client_id=who["client_id"],
|
||||
cooldown_sec=float(cooldown),
|
||||
dry_run=bool(dry_run),
|
||||
confirm=not dry_run,
|
||||
)
|
||||
one = {
|
||||
"returnCode": getattr(resp, "returnCode", None),
|
||||
"apiName": getattr(resp, "apiName", ""),
|
||||
}
|
||||
if verify and not dry_run:
|
||||
after = api.GetNeedUserData(user_id, token=state["token"], save=False)
|
||||
one["after"] = (progress(after).get(int(map_id)) or {}).get("distance")
|
||||
return one
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-route"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M6 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M18 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M7.5 17.5l6.5 -6.5" />
|
||||
<path d="M9 6l2 2" />
|
||||
<path d="M13 12l2 2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 521 B |
+204
-5
@@ -316,6 +316,7 @@
|
||||
.card[data-tab]{display:none;grid-column:1/-1}
|
||||
body[data-tab="home"] .card[data-tab="home"],
|
||||
body[data-tab="ticket"] .card[data-tab="ticket"],
|
||||
body[data-tab="map"] .card[data-tab="map"],
|
||||
body[data-tab="b50"] .card[data-tab="b50"],
|
||||
body[data-tab="risk"] .card[data-tab="risk"],
|
||||
body[data-tab="settings"] .card[data-tab="settings"]{display:block}
|
||||
@@ -556,6 +557,50 @@
|
||||
<div id="chargeList" class="picked"></div>
|
||||
</section>
|
||||
|
||||
<section class="card" data-tab="map">
|
||||
<h2>一键跑图</h2>
|
||||
<p class="desc">推进区域地图进度。走 UpsertUserAll 的 userMapList 差分 ——
|
||||
只回传 distance 变化的那一条。</p>
|
||||
<div class="row">
|
||||
<div style="flex:1 1 100%">
|
||||
<label>搜区域(名字或 mapId)</label>
|
||||
<input id="mapSearch" placeholder="比如 Phigros / Arcaea / 550060" oninput="debouncedRenderAreas()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div style="flex:1 1 100%">
|
||||
<label id="mapAreaLabel">选区域</label>
|
||||
<select id="mapArea" onchange="onPickArea()">
|
||||
<option value="">— 点「读进度」载入区域表 —</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mapInfo" class="picked"></div>
|
||||
<div class="row">
|
||||
<div style="flex:1 1 100%"><label>推进方式</label>
|
||||
<select id="mapMode" onchange="onPickArea();syncMapInputs()">
|
||||
<option value="fill" selected>推到终点(自动算还差多少)</option>
|
||||
<option value="delta">推指定距离(自己填)</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div style="flex:1 1 100%"><label id="mapDistanceLabel">距离(推指定距离时才用)</label>
|
||||
<input id="mapDistance" type="number" value="50000" min="1000" step="1000"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div style="flex:1 1 100%"><label>每段距离(0=自动)</label>
|
||||
<input id="mapStep" type="number" value="0" min="0" step="11000"></div>
|
||||
</div>
|
||||
<div class="check">
|
||||
<label><input id="mapDry" type="checkbox" checked> 只预检不发包</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button onclick="doRunmap()">开始跑图</button>
|
||||
<button class="ghost" onclick="loadRunmap()">读进度</button>
|
||||
</div>
|
||||
<div id="mapOut" class="picked"></div>
|
||||
</section>
|
||||
|
||||
<section class="card" data-tab="risk" style="border:0;background:transparent;padding:0">
|
||||
<div class="warncard">
|
||||
<b>⚠️ 高风险区</b>
|
||||
@@ -724,6 +769,8 @@
|
||||
<input id="dimRange" type="range" min="0" max="70" step="1" value="12"
|
||||
oninput="onLookInput('dim', this.value)">
|
||||
<div class="check"><label><input id="rainOn" type="checkbox" onchange="toggleRain(this.checked)"> 落雨效果(「漏的是给」往下飘)</label></div>
|
||||
<div class="check"><label><input id="giveImgOn" type="checkbox" onchange="toggleGive('img', this.checked)"> 发包成功弹「给」图</label></div>
|
||||
<div class="check"><label><input id="giveSndOn" type="checkbox" onchange="toggleGive('snd', this.checked)"> 发包成功播「给」音效</label></div>
|
||||
<button class="ghost" onclick="resetLook()">恢复默认外观</button>
|
||||
</section>
|
||||
|
||||
@@ -774,6 +821,7 @@
|
||||
<nav id="tabs">
|
||||
<button data-go="home" class="on" onclick="go('home')"><i class="ico" style="--m:url(assets/icons/home.svg)"></i>主页</button>
|
||||
<button data-go="ticket" onclick="go('ticket')"><i class="ico" style="--m:url(assets/icons/ticket.svg)"></i>票据</button>
|
||||
<button data-go="map" onclick="go('map')"><i class="ico" style="--m:url(assets/icons/map.svg)"></i>跑图</button>
|
||||
<button data-go="b50" onclick="go('b50')"><i class="ico" style="--m:url(assets/icons/b50.svg)"></i>B50</button>
|
||||
<button data-go="risk" onclick="go('risk')"><i class="ico" style="--m:url(assets/icons/risk.svg)"></i>风险</button>
|
||||
<button data-go="settings" onclick="go('settings')"><i class="ico" style="--m:url(assets/icons/settings.svg)"></i>设置</button>
|
||||
@@ -836,7 +884,7 @@ async function post(url, body){
|
||||
async function get(url){ return await (await fetch(url)).json(); }
|
||||
|
||||
/* ---------- 移动端外壳:分页 / 轻提示 ---------- */
|
||||
const TABS = ['home', 'ticket', 'b50', 'risk', 'settings'];
|
||||
const TABS = ['home', 'ticket', 'map', 'b50', 'risk', 'settings'];
|
||||
let tickerTimer = null;
|
||||
function ticker(msg, cls){
|
||||
const el = $('ticker');
|
||||
@@ -855,6 +903,7 @@ function go(tab){
|
||||
try { history.replaceState(null, '', '#' + tab); } catch(e) {}
|
||||
window.scrollTo(0, 0);
|
||||
if(tab === 'home') loadProfile();
|
||||
if(tab === 'map') syncMapInputs();
|
||||
if(tab === 'settings') syncSettings();
|
||||
}
|
||||
|
||||
@@ -1149,6 +1198,9 @@ function applyLook(o){
|
||||
// 落雨:默认开;只有显式存了 rain:false 才关(彩虹标题不在这个开关里)
|
||||
applyRain(o.rain !== false);
|
||||
if($('rainOn')) $('rainOn').checked = (o.rain !== false);
|
||||
// 「给」的图与音效:默认都开,显式 false 才关
|
||||
if($('giveImgOn')) $('giveImgOn').checked = (o.giveImg !== false);
|
||||
if($('giveSndOn')) $('giveSndOn').checked = (o.giveSnd !== false);
|
||||
}
|
||||
|
||||
const RAIN_CHARS = ['漏', '的', '是', '给'];
|
||||
@@ -1185,6 +1237,12 @@ function toggleRain(on){
|
||||
applyLook(o);
|
||||
ticker(on ? '落雨开了' : '落雨关了', 'ok');
|
||||
}
|
||||
function toggleGive(kind, on){
|
||||
const o = readLook();
|
||||
if(kind === 'img') o.giveImg = !!on; else o.giveSnd = !!on;
|
||||
saveLook(o);
|
||||
ticker((kind === 'img' ? '弹图' : '音效') + (on ? '开了' : '关了'), 'ok');
|
||||
}
|
||||
function onLookInput(field, value){
|
||||
const o = readLook();
|
||||
const v = parseInt(value, 10) || 0;
|
||||
@@ -1564,20 +1622,27 @@ function endWait(){
|
||||
$('wbar').style.width = '0%';
|
||||
$('wnum').textContent = '';
|
||||
}
|
||||
/* 发包成功:弹梗图 + 喊一声。图片和音效都在 html/assets 下 */
|
||||
/* 发包成功:弹梗图 + 喊一声。图片和音效都在 html/assets 下,两个都能在设置里单独关 */
|
||||
let giveAudio = null;
|
||||
function showGive(){
|
||||
const o = readLook();
|
||||
const wantImg = (o.giveImg !== false);
|
||||
const wantSnd = (o.giveSnd !== false);
|
||||
if(!wantImg && !wantSnd) return;
|
||||
const box = $('giveBox');
|
||||
if(!box) return;
|
||||
if(wantImg && box){
|
||||
box.classList.add('on');
|
||||
clearTimeout(showGive._t);
|
||||
showGive._t = setTimeout(() => box.classList.remove('on'), 1600);
|
||||
}
|
||||
if(wantSnd){
|
||||
try {
|
||||
if(!giveAudio){ giveAudio = new Audio('/assets/give.mp3'); giveAudio.volume = 1; }
|
||||
giveAudio.currentTime = 0;
|
||||
const pr = giveAudio.play();
|
||||
if(pr && pr.catch) pr.catch(() => {}); // 自动播放被拦就算了,图照弹
|
||||
} catch(e){}
|
||||
clearTimeout(showGive._t);
|
||||
showGive._t = setTimeout(() => box.classList.remove('on'), 1600);
|
||||
}
|
||||
}
|
||||
|
||||
async function postLong(url, body, title, sub, seconds){
|
||||
@@ -2066,6 +2131,140 @@ async function doBuyTicket(){
|
||||
if(!dry) loadCharge();
|
||||
}
|
||||
|
||||
/* ---------- 跑图 ---------- */
|
||||
let RUNMAP = null;
|
||||
async function loadRunmap(){
|
||||
const lab0 = $('mapAreaLabel');
|
||||
if(lab0) lab0.textContent = '选区域(正在读取进度…)';
|
||||
log('正在读进度,要拉一次完整快照,稍等…');
|
||||
const r = await get('/api/runmap');
|
||||
if(!r.ok){ log('读区域表失败:'+r.error, 'err'); if(lab0) lab0.textContent = '选区域(读取失败)'; return; }
|
||||
RUNMAP = r;
|
||||
renderAreas();
|
||||
const areas = r.areas || [];
|
||||
log(`区域表已载入:${areas.length} 个区域,已进过 ${r.entered} 个`, 'ok');
|
||||
if(r.selectMapId){
|
||||
const cur = areas.find(a => a.mapId === r.selectMapId);
|
||||
$('mapInfo').textContent = `机台当前选中:${cur ? cur.name : r.selectMapId}(${r.selectMapId})`;
|
||||
}
|
||||
}
|
||||
function renderAreas(){
|
||||
if(!RUNMAP) return;
|
||||
const kw = ($('mapSearch').value || '').trim().toLowerCase();
|
||||
const all = RUNMAP.areas || [];
|
||||
const areas = all.filter(a => {
|
||||
if(!kw) return true;
|
||||
if(String(a.mapId).indexOf(kw) >= 0) return true;
|
||||
if((a.name || '').toLowerCase().indexOf(kw) >= 0) return true;
|
||||
if((a.island || '').toLowerCase().indexOf(kw) >= 0) return true;
|
||||
return false;
|
||||
});
|
||||
const keep = $('mapArea').value;
|
||||
const byVer = {};
|
||||
areas.forEach(a => { const v = a.version || '其他'; (byVer[v] = byVer[v] || []).push(a); });
|
||||
let html = '<option value="">— 选一个区域 —</option>';
|
||||
Object.keys(byVer).sort().forEach(v => {
|
||||
html += `<optgroup label="${v} · ${byVer[v].length} 个">`;
|
||||
byVer[v].forEach(a => {
|
||||
let mark;
|
||||
if(a.cleared){
|
||||
mark = '✅ 已通关 ';
|
||||
} else if((a.current || 0) > 0){
|
||||
mark = '▶ 进行中 ';
|
||||
} else {
|
||||
mark = ' '; // 全角空格占位,让未开始的排在后面视觉上更整齐
|
||||
}
|
||||
const tag = a.collabo ? '〔联动〕' : '';
|
||||
html += `<option value="${a.mapId}">${mark}${a.name}${tag} · ${a.mapId}</option>`;
|
||||
});
|
||||
html += '</optgroup>';
|
||||
});
|
||||
$('mapArea').innerHTML = html;
|
||||
if(keep && areas.some(a => String(a.mapId) === keep)) $('mapArea').value = keep;
|
||||
// ★ select 被 nsUpgrade 包装成了自定义控件,改完 options 必须同步一次显示层
|
||||
nsSync('mapArea');
|
||||
const lab = $('mapAreaLabel');
|
||||
if(lab) lab.textContent = kw ? `选区域(筛出 ${areas.length} 个)` : `选区域(共 ${areas.length} 个)`;
|
||||
if(kw && areas.length === 1){
|
||||
$('mapArea').value = String(areas[0].mapId);
|
||||
nsSync('mapArea');
|
||||
onPickArea();
|
||||
}
|
||||
}
|
||||
let _mapTimer = null;
|
||||
function debouncedRenderAreas(){
|
||||
clearTimeout(_mapTimer);
|
||||
_mapTimer = setTimeout(renderAreas, 220);
|
||||
}
|
||||
function currentArea(){
|
||||
const id = parseInt($('mapArea').value, 10);
|
||||
if(!id || !RUNMAP) return null;
|
||||
return (RUNMAP.areas || []).find(a => a.mapId === id) || null;
|
||||
}
|
||||
function syncMapInputs(){
|
||||
const fill = $('mapMode').value === 'fill';
|
||||
const dist = $('mapDistance');
|
||||
const lab = $('mapDistanceLabel');
|
||||
if(dist){
|
||||
dist.disabled = fill;
|
||||
dist.style.opacity = fill ? '.45' : '1';
|
||||
dist.style.background = fill ? '#f2f4f7' : '';
|
||||
}
|
||||
if(lab) lab.textContent = fill ? '距离(推到终点时不用填)' : '距离(推指定距离时必填)';
|
||||
}
|
||||
function onPickArea(){
|
||||
syncMapInputs();
|
||||
const a = currentArea();
|
||||
if(!a){ $('mapInfo').textContent = ''; return; }
|
||||
const end = a.endDistance || 0, cur = a.current || 0;
|
||||
let txt = `${a.name} · mapId ${a.mapId} · ${a.version}`;
|
||||
if(a.collabo) txt += ' · 联动';
|
||||
if(a.infinity) txt += ' · 无限区域';
|
||||
if(a.cleared) txt += '\n✅ 这个区域已经通关了';
|
||||
if(end){
|
||||
txt += `\n终点 ${end.toLocaleString()} · 当前 ${cur.toLocaleString()} · ${a.percent}%`;
|
||||
if(a.remain != null) txt += ` · 还差 ${a.remain.toLocaleString()}`;
|
||||
} else {
|
||||
txt += `\n无终点(无限区域)· 当前 ${cur.toLocaleString()}`;
|
||||
}
|
||||
if(a.selected) txt += '\n★ 机台当前选中的就是它';
|
||||
$('mapInfo').textContent = txt;
|
||||
if(!end && $('mapMode').value === 'fill'){
|
||||
$('mapMode').value = 'delta';
|
||||
log('这个区域没有终点,已切到「推指定距离」', 'warn');
|
||||
}
|
||||
}
|
||||
async function doRunmap(){
|
||||
const a = currentArea();
|
||||
if(!a) return log('先选区域', 'err');
|
||||
const mode = $('mapMode').value;
|
||||
const distance = parseInt($('mapDistance').value || '0', 10);
|
||||
const step = parseInt($('mapStep').value || '0', 10);
|
||||
const dry = $('mapDry').checked;
|
||||
const end = a.endDistance || 0, cur = a.current || 0;
|
||||
if(mode === 'fill' && !end) return log('这个区域没有终点,改用「推指定距离」', 'err');
|
||||
if(mode === 'fill' && end && cur >= end) return log('已经是终点,不用跑', 'warn');
|
||||
if(mode === 'delta' && distance <= 0) return log('distance 要大于 0', 'err');
|
||||
const total = mode === 'fill' ? (end - cur) : distance;
|
||||
const seg = step > 0 ? step : Math.max(11000, Math.min(100000, Math.round(total / 8)));
|
||||
const batches = Math.ceil(total / seg);
|
||||
log(`跑图 ${a.name}(${a.mapId})· 推 ${total.toLocaleString()} · 分 ${batches} 段${dry?'(预检)':''}`);
|
||||
if(!dry && batches > 1) log(`要换 ${batches} 局,每局至少 60 秒,别关页面`, 'warn');
|
||||
const r = await postLong('/api/runmap',
|
||||
{mapId:a.mapId, mode, distance, step, dryRun:dry},
|
||||
'正在跑图', `推进 ${total.toLocaleString()},分 ${batches} 段。`, Math.max(60, batches * 9));
|
||||
if(!r.ok){ log('失败:'+r.error, 'err'); if(r.trace) log(r.trace,'dim'); return; }
|
||||
if(r.note){ log(r.note, 'warn'); return; }
|
||||
log(`回码 ${r.returnCode} · ${(r.before||0).toLocaleString()} → ${(r.after||0).toLocaleString()}`
|
||||
+ `(+${(r.done||0).toLocaleString()})· ${r.batches} 段`,
|
||||
r.failed ? 'warn' : 'ok');
|
||||
(r.errors || []).forEach(e => log(e, 'warn'));
|
||||
if(!dry && r.steps && r.steps.length){
|
||||
log(r.steps.map(s => `#${s.seq} +${s.gain} → ${s.after == null ? '?' : s.after}`).join(' '), 'dim');
|
||||
}
|
||||
if(!dry) loadRunmap();
|
||||
}
|
||||
|
||||
/* ---------- 道具 ---------- */
|
||||
const WAIT_SUB = '服务端要求一局至少 60 秒,每次发包都要等满这一分钟,别关页面。';
|
||||
async function doGrantPicked(kind, target){
|
||||
|
||||
Reference in new issue
Block a user