2 Commits
Author SHA1 Message Date
maipulse c0b9a10ae9 release: v1.0.3
一键跑图:自选区域推到终点;设置里可关「给」的弹图与音效
2026-11-04 19:32:34 +08:00
maipulse 874fe81039 chore: gitignore 排除打包生成的 android assets 2026-10-30 21:38:55 +08:00
9 changed files with 2506 additions and 28 deletions

No files matched your search

+4
View File
@@ -32,3 +32,7 @@ Thumbs.db
# 发版 token # 发版 token
maipulse/tools/.release-token maipulse/tools/.release-token
tools/.release-token tools/.release-token
# 打包时从 maipulse/ 复制进去的运行时副本,不入库
android/app/src/main/assets/
android/.gradle/
android/build/
+2 -2
View File
@@ -12,8 +12,8 @@ android {
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
// 和网页里的 APP_VERSION 对齐,升级时一起改 // 和网页里的 APP_VERSION 对齐,升级时一起改
versionCode 102 versionCode 103
versionName "1.0.2" versionName "1.0.3"
ndk { ndk {
abiFilters "arm64-v8a" abiFilters "arm64-v8a"
+30 -5
View File
@@ -736,6 +736,12 @@ class PlayData:
default_factory=lambda: config.PLAY_MUSIC_PLAY_COUNT_GAIN) default_factory=lambda: config.PLAY_MUSIC_PLAY_COUNT_GAIN)
map_distance_gain: int = field(default_factory=lambda: config.PLAY_MAP_DISTANCE_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) 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) ud["dateTime"] = int(auth_date_time)
if ud.get("banState") is None: if ud.get("banState") is None:
ud["banState"] = 0 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 ---------------- # ---------------- userExtend ----------------
ext_list = user_all.get("userExtend") or [] ext_list = user_all.get("userExtend") or []
@@ -1824,17 +1835,31 @@ def apply_play_effects(
if "userMapList" in user_all: if "userMapList" in user_all:
if play.map_distance_gain: if play.map_distance_gain:
baseline = user_all.get("_baseline_map") or [] baseline = user_all.get("_baseline_map") or []
map_id = None
ud = (user_all.get("userData") or [{}])[0] ud = (user_all.get("userData") or [{}])[0]
if isinstance(ud, dict): want = int(getattr(play, "select_map_id", 0) or 0)
map_id = ud.get("selectMapId") map_id = want or (ud.get("selectMapId") if isinstance(ud, dict) else None)
target = None target = None
for item in baseline: for item in baseline:
if isinstance(item, dict) and item.get("mapId") == map_id: if isinstance(item, dict) and item.get("mapId") == map_id:
target = copy.deepcopy(item) target = copy.deepcopy(item)
break break
if target is None and baseline and isinstance(baseline[0], dict): if target is None:
target = copy.deepcopy(baseline[0]) 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: if target is not None:
target["distance"] = int(target.get("distance", 0) or 0) + play.map_distance_gain target["distance"] = int(target.get("distance", 0) or 0) + play.map_distance_gain
user_all["userMapList"] = [target] user_all["userMapList"] = [target]
+59 -9
View File
@@ -37,6 +37,7 @@ from flask import Flask, jsonify, request, send_file # noqa: E402
import api # noqa: E402 import api # noqa: E402
from core import accounts, charge, items, repair, session, shop, unlock, b50, profile # 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 characters, mileage, history, recommend as recommend_mod # noqa: E402
from core import runmap # noqa: E402
app = Flask(__name__, static_folder=None) app = Flask(__name__, static_folder=None)
LOCK = threading.Lock() LOCK = threading.Lock()
@@ -370,9 +371,13 @@ def api_ds():
@app.post("/api/logout") @app.post("/api/logout")
@guarded @guarded
def api_logout(): 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("未登录") return fail("未登录")
resp = session.logout(S) 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)) return ok(returnCode=getattr(resp, "returnCode", None))
@@ -387,11 +392,11 @@ def api_shutdown():
""" """
info = {} info = {}
try: try:
if S.get("logged_in"): if S.get("logged_in") or S.get("token"):
resp = session.logout(S) resp = session.logout(S)
info["logout"] = getattr(resp, "returnCode", None) info["logout"] = getattr(resp, "returnCode", None)
else: else:
info["logout"] = "未登录,跳过" info["logout"] = "没有会话,跳过"
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
info["logoutError"] = str(exc) info["logoutError"] = str(exc)
threading.Timer(0.8, lambda: os._exit(0)).start() threading.Timer(0.8, lambda: os._exit(0)).start()
@@ -408,20 +413,24 @@ def api_reload():
登录态和会话都保住。只重载 core 包内的纯逻辑模块,不碰 api / Flask。 登录态和会话都保住。只重载 core 包内的纯逻辑模块,不碰 api / Flask。
""" """
import importlib import importlib
names = ["core.settle", "core.play", "core.items", "core.unlock", import pkgutil
"core.charge", "core.repair", "core.session", "core.shop", import core as _core_pkg
"core.b50", "core.profile", "core.characters", "core.mileage"] # ★ 自动发现 core 包下所有已加载的模块,别再手写列表:
# 漏一个(比如后加的 runmap)就会出现「改了代码、reload 报成功、实际没生效」。
names = sorted("core." + m.name for m in pkgutil.iter_modules(_core_pkg.__path__))
done = [] done = []
skipped = []
for name in names: for name in names:
mod = sys.modules.get(name) mod = sys.modules.get(name)
if mod is None: if mod is None:
skipped.append(name) # 没被 import 过的不用管
continue continue
try: try:
importlib.reload(mod) importlib.reload(mod)
done.append(name) done.append(name)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return fail("重载 %s 失败:%s" % (name, exc)) 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") @app.get("/api/state")
@@ -655,6 +664,47 @@ def api_unlock():
return ok(**result) 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") @app.post("/api/unstick")
@@ -874,8 +924,8 @@ def api_master():
# ---------------------------------------------------------------- 更新 # ---------------------------------------------------------------- 更新
APP_VERSION = "1.0.2" APP_VERSION = "1.0.3"
APP_BUILD = "2026-10-30" APP_BUILD = "2026-11-04"
@app.get("/api/version") @app.get("/api/version")
+254
View File
@@ -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
+19
View File
@@ -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

+211 -12
View File
@@ -316,6 +316,7 @@
.card[data-tab]{display:none;grid-column:1/-1} .card[data-tab]{display:none;grid-column:1/-1}
body[data-tab="home"] .card[data-tab="home"], body[data-tab="home"] .card[data-tab="home"],
body[data-tab="ticket"] .card[data-tab="ticket"], 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="b50"] .card[data-tab="b50"],
body[data-tab="risk"] .card[data-tab="risk"], body[data-tab="risk"] .card[data-tab="risk"],
body[data-tab="settings"] .card[data-tab="settings"]{display:block} body[data-tab="settings"] .card[data-tab="settings"]{display:block}
@@ -556,6 +557,50 @@
<div id="chargeList" class="picked"></div> <div id="chargeList" class="picked"></div>
</section> </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"> <section class="card" data-tab="risk" style="border:0;background:transparent;padding:0">
<div class="warncard"> <div class="warncard">
<b>⚠️ 高风险区</b> <b>⚠️ 高风险区</b>
@@ -724,6 +769,8 @@
<input id="dimRange" type="range" min="0" max="70" step="1" value="12" <input id="dimRange" type="range" min="0" max="70" step="1" value="12"
oninput="onLookInput('dim', this.value)"> 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="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> <button class="ghost" onclick="resetLook()">恢复默认外观</button>
</section> </section>
@@ -774,6 +821,7 @@
<nav id="tabs"> <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="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="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="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="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> <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(); } 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; let tickerTimer = null;
function ticker(msg, cls){ function ticker(msg, cls){
const el = $('ticker'); const el = $('ticker');
@@ -855,6 +903,7 @@ function go(tab){
try { history.replaceState(null, '', '#' + tab); } catch(e) {} try { history.replaceState(null, '', '#' + tab); } catch(e) {}
window.scrollTo(0, 0); window.scrollTo(0, 0);
if(tab === 'home') loadProfile(); if(tab === 'home') loadProfile();
if(tab === 'map') syncMapInputs();
if(tab === 'settings') syncSettings(); if(tab === 'settings') syncSettings();
} }
@@ -1149,6 +1198,9 @@ function applyLook(o){
// 落雨:默认开;只有显式存了 rain:false 才关(彩虹标题不在这个开关里) // 落雨:默认开;只有显式存了 rain:false 才关(彩虹标题不在这个开关里)
applyRain(o.rain !== false); applyRain(o.rain !== false);
if($('rainOn')) $('rainOn').checked = (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 = ['漏', '的', '是', '给']; const RAIN_CHARS = ['漏', '的', '是', '给'];
@@ -1185,6 +1237,12 @@ function toggleRain(on){
applyLook(o); applyLook(o);
ticker(on ? '落雨开了' : '落雨关了', 'ok'); 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){ function onLookInput(field, value){
const o = readLook(); const o = readLook();
const v = parseInt(value, 10) || 0; const v = parseInt(value, 10) || 0;
@@ -1564,20 +1622,27 @@ function endWait(){
$('wbar').style.width = '0%'; $('wbar').style.width = '0%';
$('wnum').textContent = ''; $('wnum').textContent = '';
} }
/* 发包成功:弹梗图 + 喊一声。图片和音效都在 html/assets 下 */ /* 发包成功:弹梗图 + 喊一声。图片和音效都在 html/assets 下,两个都能在设置里单独关 */
let giveAudio = null; let giveAudio = null;
function showGive(){ function showGive(){
const o = readLook();
const wantImg = (o.giveImg !== false);
const wantSnd = (o.giveSnd !== false);
if(!wantImg && !wantSnd) return;
const box = $('giveBox'); const box = $('giveBox');
if(!box) return; if(wantImg && box){
box.classList.add('on'); box.classList.add('on');
try { clearTimeout(showGive._t);
if(!giveAudio){ giveAudio = new Audio('/assets/give.mp3'); giveAudio.volume = 1; } showGive._t = setTimeout(() => box.classList.remove('on'), 1600);
giveAudio.currentTime = 0; }
const pr = giveAudio.play(); if(wantSnd){
if(pr && pr.catch) pr.catch(() => {}); // 自动播放被拦就算了,图照弹 try {
} catch(e){} if(!giveAudio){ giveAudio = new Audio('/assets/give.mp3'); giveAudio.volume = 1; }
clearTimeout(showGive._t); giveAudio.currentTime = 0;
showGive._t = setTimeout(() => box.classList.remove('on'), 1600); const pr = giveAudio.play();
if(pr && pr.catch) pr.catch(() => {}); // 自动播放被拦就算了,图照弹
} catch(e){}
}
} }
async function postLong(url, body, title, sub, seconds){ async function postLong(url, body, title, sub, seconds){
@@ -2066,6 +2131,140 @@ async function doBuyTicket(){
if(!dry) loadCharge(); 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 秒,每次发包都要等满这一分钟,别关页面。'; const WAIT_SUB = '服务端要求一局至少 60 秒,每次发包都要等满这一分钟,别关页面。';
async function doGrantPicked(kind, target){ async function doGrantPicked(kind, target){