This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.App/Admin/AdminWebServer.cs
T

4826 lines
349 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Text;
using System.Text.Json;
using Ananta.Server.Configuration;
using Ananta.Server.Handlers.Game;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.App.Admin;
/// <summary>
/// Embedded localhost admin panel (no auth by design — bind 127.0.0.1 only).
/// Configured via private-server.json → admin { enabled, host, port }.
/// </summary>
internal sealed class AdminWebServer(PrivateServerConfig config)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
// ulong 超过 2^53 在 JS 里会丢精度(客户端本地单位 id 就是 8.3e18 量级),
// 所以 /api/police/targets 把 uid 以字符串下发,这里允许回传字符串再解析成数字。
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
};
internal async Task RunAsync(CancellationToken cancellationToken)
{
if (!config.Admin.Enabled)
{
Console.WriteLine("[ADMIN] disabled by config (admin.enabled=false)");
return;
}
var prefix = $"http://{config.Admin.Host}:{config.Admin.Port}/";
var listener = new HttpListener();
listener.Prefixes.Add(prefix);
try
{
listener.Start();
}
catch (Exception ex)
{
Console.WriteLine($"[ADMIN] failed to listen on {prefix}: {ex.Message}");
return;
}
Console.WriteLine($"[ADMIN] panel at {prefix} (localhost only, no auth)");
using var registration = cancellationToken.Register(() => listener.Stop());
while (!cancellationToken.IsCancellationRequested)
{
HttpListenerContext ctx;
try
{
ctx = await listener.GetContextAsync();
}
catch (HttpListenerException) { break; }
catch (ObjectDisposedException) { break; }
_ = Task.Run(() => HandleAsync(ctx, cancellationToken), cancellationToken);
}
listener.Close();
}
private static async Task HandleAsync(HttpListenerContext ctx, CancellationToken ct)
{
var req = ctx.Request;
var res = ctx.Response;
try
{
var path = req.Url?.AbsolutePath ?? "/";
if (req.HttpMethod == "GET" && path == "/")
{
void_Write(res, "text/html; charset=utf-8", PageHtml);
return;
}
if (req.HttpMethod == "GET" && path == "/api/status")
{
void_WriteJson(res, StatusPayload());
return;
}
if (req.HttpMethod == "GET" && path == "/api/vehicles")
{
var snap = GameAdminBridge.GetSnapshot();
void_WriteJson(res, new
{
fleet = GameAdminBridge.FleetIds(),
flyingWater = GameAdminBridge.FlyingWaterVehicles(),
lastVehicleId = snap.LastAdminVehicleId,
lastUid = snap.LastSpawnedVehicleUid,
lastDriveState = snap.HasVehicleDriveState ? (byte?)snap.LastVehicleDriveState : null,
});
return;
}
// 读玩家坐标:站到「希望 NPC 面向的那个点」上打这个接口,就能反算出 facing。
if (req.HttpMethod == "GET" && path == "/api/player/pos")
{
var p = GameAdminBridge.PlayerPosition();
void_WriteJson(res, new
{
ok = p.Ok,
message = p.Message,
x = Math.Round(p.X, 3),
y = Math.Round(p.Y, 3),
z = Math.Round(p.Z, 3),
raid = p.Raid,
daySecond = p.DaySecond,
});
return;
}
if (req.HttpMethod == "GET" && path == "/api/settings")
{
void_WriteJson(res, new
{
path = PrivateServerConfigStore.ConfigPath,
restartGroups = RuntimeConfigEditor.RestartGroups,
values = RuntimeConfigEditor.ReadRaw(),
});
return;
}
if (req.HttpMethod == "POST" && path == "/api/settings")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<SettingsUpdateRequest>(body, JsonOptions)
?? new SettingsUpdateRequest();
if (cmd.Changes is null || cmd.Changes.Count == 0)
{
void_WriteJson(res, new { ok = false, message = "changes 为空" }, 400);
return;
}
var (ok, applied, error) = RuntimeConfigEditor.Apply(cmd.Changes);
void_WriteJson(res, new { ok, applied, message = error ?? $"已应用 {applied} 项" }, ok ? 200 : 400);
return;
}
if (req.HttpMethod == "POST" && path == "/api/reload-config")
{
// Manual trigger for the same reload the file watcher does on save. Useful when an
// editor writes the file in place without firing a change event.
var ok = PrivateServerConfigStore.Reload(out var msg);
var metro = PrivateServerConfigStore.Current.World.Content.MetroGadgets;
void_WriteJson(res, new
{
ok,
message = msg,
metroGadgets = new
{
enabled = metro.Enabled,
useTrainConfig = metro.UseTrainConfig,
seatsByCarriageTemplate = metro.SeatsByCarriageTemplate,
defaultCarriageSeats = metro.DefaultCarriageSeats,
},
}, ok ? 200 : 400);
return;
}
if (req.HttpMethod == "GET" && path == "/api/unknown-log")
{
var query = ParseQuery(req.Url?.Query);
var lines = query.TryGetValue("lines", out var raw) && int.TryParse(raw, out var n) ? Math.Clamp(n, 1, 500) : 100;
void_WriteJson(res, new { lines = UnknownMethodLogger.Tail(lines) });
return;
}
// ---- 骰子(DiceGame)试验台(2026-09-27 移植自 HyNB-v1.9)-----------------------
// 服务端→客户端那六条 Sync* 的载荷布局没有离线权威来源,字段顺序来自 IL2CPP dump,
// 但「枚举线宽」「complex vs struct」只能靠实机收敛。这几个端点让格式可以
// 不重启服务端地反复试:推包 → 看 Player.log / 客户端表现 → 调 hex → 再推。
if (path == "/api/dice/state")
{
var ds = GameAdminBridge.RequirePlayerSession();
void_WriteJson(res, new { ok = ds is not null, message = ds is null ? "没有在线的玩家会话" : "ok" });
return;
}
// /api/dice/start?gadget=<u64>&model=<u32>&rounds=&target=&first=
if (path == "/api/dice/start")
{
var ds = GameAdminBridge.RequirePlayerSession();
if (ds is null) { void_WriteJson(res, new { ok = false, message = "没有玩家会话" }, 400); return; }
var dq = ParseQuery(req.Url?.Query);
var gadget = QueryU64(dq, "gadget", 0);
var model = (uint)QueryU64(dq, "model", 0);
var rounds = (int)QueryU64(dq, "rounds", 3);
var target = (int)QueryU64(dq, "target", 0);
var first = (int)QueryU64(dq, "first", 0);
await GameRouter.PushDiceGameStartAsync(ds.Value.Session, gadget, model, rounds, target, first);
void_WriteJson(res, new { ok = true, gadget, model, rounds, target, first });
return;
}
// /api/dice/command?uid=<u64>&cmd=<int>&p=1,2,3&badge=<u32>&w=1|4
if (path == "/api/dice/command")
{
var ds = GameAdminBridge.RequirePlayerSession();
if (ds is null) { void_WriteJson(res, new { ok = false, message = "没有玩家会话" }, 400); return; }
var dq = ParseQuery(req.Url?.Query);
var uid = QueryU64(dq, "uid", 0);
var dcmd = (int)QueryU64(dq, "cmd", 1);
var badge = (uint)QueryU64(dq, "badge", 0);
// 默认 1:实机验证过命令枚举在线上是 1 字节(4 字节时客户端完全不触发)。
var width = (int)QueryU64(dq, "w", 1);
var dps = new List<int>();
if (dq.TryGetValue("p", out var rawP) && !string.IsNullOrWhiteSpace(rawP))
foreach (var part in rawP.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part, out var pv)) dps.Add(pv);
await GameRouter.PushDiceCommandAsync(ds.Value.Session, uid, dcmd, dps, badge, width);
void_WriteJson(res, new { ok = true, uid, cmd = dcmd, p = dps, badge, enumWidth = width });
return;
}
// /api/dice/raw?mid=<id>&hex=<hex> —— 直接把任意字节推给客户端,最自由的试验口
if (path == "/api/dice/raw")
{
var ds = GameAdminBridge.RequirePlayerSession();
if (ds is null) { void_WriteJson(res, new { ok = false, message = "没有玩家会话" }, 400); return; }
var dq = ParseQuery(req.Url?.Query);
var mid = (uint)QueryU64(dq, "mid", 0);
dq.TryGetValue("hex", out var dhex);
byte[] dbytes;
try { dbytes = System.Convert.FromHexString((dhex ?? "").Trim()); }
catch (Exception dex)
{
void_WriteJson(res, new { ok = false, message = "hex 解析失败:" + dex.Message }, 400);
return;
}
await GameRouter.PushDiceRawAsync(ds.Value.Session, mid, dbytes);
void_WriteJson(res, new { ok = true, mid, bytes = dbytes.Length });
return;
}
if (req.HttpMethod == "POST" && path == "/api/teleport")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<TeleportRequest>(body, JsonOptions) ?? new TeleportRequest();
if (!float.IsFinite(cmd.X) || !float.IsFinite(cmd.Y) || !float.IsFinite(cmd.Z))
{
void_WriteJson(res, new { ok = false, message = "x/y/z must be finite numbers" }, 400);
return;
}
var mode = (cmd.Mode ?? "instant").ToLowerInvariant();
var result = mode == "loading"
? await GameAdminBridge.TeleportWithLoadingAsync(cmd.X, cmd.Y, cmd.Z, cmd.Facing)
: await GameAdminBridge.TeleportInstantAsync(cmd.X, cmd.Y, cmd.Z, cmd.Facing);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/gadget/spawn")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<GadgetSpawnRequest>(body, JsonOptions) ?? new GadgetSpawnRequest();
if (cmd.PathId == 0)
{
void_WriteJson(res, new { ok = false, message = "pathId is required" }, 400);
return;
}
if (!float.IsFinite(cmd.X) || !float.IsFinite(cmd.Y) || !float.IsFinite(cmd.Z))
{
void_WriteJson(res, new { ok = false, message = "x/y/z must be finite numbers" }, 400);
return;
}
var result = await GameAdminBridge.SpawnGadgetAtAsync(
cmd.PathId, cmd.X, cmd.Y, cmd.Z, cmd.Yaw, cmd.IScale);
void_WriteJson(res, new { ok = result.Ok, message = result.Message, count = result.Count });
return;
}
if (req.HttpMethod == "POST" && path == "/api/basketball/npcfix")
{
// 街头篮球:点亮对手(红铁桶 42071029)被关掉的 SkinnedMeshRenderer。
// ⛑ 模型其实加载成功了,只是 body/body01 的 SMR 是 enabled=false,所以看不见。
var nr = await GameAdminBridge.FixBasketballNpcModelAsync();
void_WriteJson(res, new { ok = nr.Ok, message = nr.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/roster-schedule")
{
void_WriteJson(res, GameAdminBridge.RosterSchedule());
return;
}
if (req.HttpMethod == "POST" && path == "/api/vehicle/spawn")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<VehicleSpawnRequest>(body, JsonOptions) ?? new VehicleSpawnRequest();
if (cmd.VehicleId == 0)
{
void_WriteJson(res, new { ok = false, message = "vehicleId must be non-zero" }, 400);
return;
}
var result = await GameAdminBridge.SpawnVehicleAsync(cmd.VehicleId, cmd.Force);
void_WriteJson(res, new { ok = result.Ok, message = result.Message, uid = result.Uid });
return;
}
if (req.HttpMethod == "POST" && path == "/api/aether/isec-probe")
{
var result = await GameAdminBridge.AetherIsecProbeAsync();
void_WriteJson(res, new { ok = result.Ok, message = result.Message, rows = result.Rows });
return;
}
if (req.HttpMethod == "POST" && path == "/api/police/arrest")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PoliceArrestRequest>(body, JsonOptions) ?? new PoliceArrestRequest();
var result = await GameAdminBridge.ArrestNpcAsync(cmd.Uid, cmd.Teleport, cmd.Pull);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/police/prison")
{
void_WriteJson(res, GameAdminBridge.PolicePrisonStatus());
return;
}
if (req.HttpMethod == "POST" && path == "/api/police/prison")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PolicePrisonRequest>(body, JsonOptions) ?? new PolicePrisonRequest();
_ = ulong.TryParse(cmd.Uid, out var prisonUid);
var action = (cmd.Action ?? "").Trim().ToLowerInvariant();
var result = action == "release"
? await GameAdminBridge.PolicePrisonReleaseAsync()
: await GameAdminBridge.PolicePrisonCommitAsync(prisonUid);
void_WriteJson(res, new { ok = result.Ok, message = result.Message, action });
return;
}
if (req.HttpMethod == "POST" && path == "/api/police/escort")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PoliceEscortRequest>(body, JsonOptions) ?? new PoliceEscortRequest();
_ = ulong.TryParse(cmd.Uid, out var escortUid);
_ = ulong.TryParse(cmd.Vehicle, out var escortVehicle);
var action = (cmd.Action ?? "").Trim().ToLowerInvariant();
var result = action == "alight"
? await GameAdminBridge.PoliceEscortAlightAsync()
: await GameAdminBridge.PoliceEscortBoardAsync(escortUid, escortVehicle, cmd.Seat);
void_WriteJson(res, new { ok = result.Ok, message = result.Message, action });
return;
}
if (req.HttpMethod == "GET" && path == "/api/gatelua/status")
{
void_WriteJson(res, GameAdminBridge.GateLuaStatus());
return;
}
if (req.HttpMethod == "POST" && path == "/api/gatelua")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<GateLuaRequest>(body, JsonOptions) ?? new GateLuaRequest();
var result = await GameAdminBridge.RunClientLuaAsync(cmd.Script ?? "");
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/gatelua/probe")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<GateLuaRequest>(body, JsonOptions) ?? new GateLuaRequest();
var result = await GameAdminBridge.GateLuaProbeAsync(cmd.Script ?? "");
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/gatelua/units")
{
void_WriteJson(res, GameAdminBridge.GateLuaUnits());
return;
}
if (req.HttpMethod == "POST" && path == "/api/gatelua/fbtn")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<GateLuaButtonRequest>(body, JsonOptions) ?? new GateLuaButtonRequest();
var action = (cmd.Action ?? "add").Trim().ToLowerInvariant();
var result = await GameAdminBridge.GateLuaUnitButtonAsync(cmd.Uid, cmd.Text, action != "remove");
void_WriteJson(res, new { ok = result.Ok, message = result.Message, action });
return;
}
if (req.HttpMethod == "POST" && path == "/api/police/examine")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PoliceExamineRequest>(body, JsonOptions) ?? new PoliceExamineRequest();
var result = await GameAdminBridge.ExamineNpcAsync(cmd.Uid, cmd.FineCount, cmd.Teleport, cmd.Close, cmd.Pull, cmd.Wire);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/police/react")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PoliceReactRequest>(body, JsonOptions) ?? new PoliceReactRequest();
var result = await GameAdminBridge.PushPoliceExamReactionAsync(cmd.ReactionId);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
// ★ 捐赠端里希总入口(2026-10-02 逐字对齐捐赠端 AdminWebServer.cs:269-274)。
// 这张卡现在只剩这一条路:npc / examine / end-examine / dismiss / prison-*。
if (req.HttpMethod == "POST" && path == "/api/lixi/police")
{
var command = JsonSerializer.Deserialize<LixiPoliceRequest>(await ReadBodyAsync(req), JsonOptions) ?? new();
var result = await GameAdminBridge.LixiPoliceAsync(command.Action);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/police/fine")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PoliceFineRequest>(body, JsonOptions) ?? new PoliceFineRequest();
var result = await GameAdminBridge.PushPoliceFineRewardAsync(cmd.Money, cmd.JobExp);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/police/crimes")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PoliceCrimesRequest>(body, JsonOptions) ?? new PoliceCrimesRequest();
var result = await GameAdminBridge.PushAgentCrimesAsync(cmd.Uid, cmd.Fines, cmd.Wire);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/plate/push")
{
var body = await ReadBodyAsync(req);
var cmd = string.IsNullOrWhiteSpace(body)
? new GameAdminBridge.PlatePushOptions()
: JsonSerializer.Deserialize<GameAdminBridge.PlatePushOptions>(body, JsonOptions)
?? new GameAdminBridge.PlatePushOptions();
var result = await GameAdminBridge.PushPlateAsync(cmd);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/npclife/spoon")
{
var body = await ReadBodyAsync(req);
var cmd = string.IsNullOrWhiteSpace(body)
? new GameAdminBridge.NpcSpoonOptions()
: JsonSerializer.Deserialize<GameAdminBridge.NpcSpoonOptions>(body, JsonOptions)
?? new GameAdminBridge.NpcSpoonOptions();
var result = await GameAdminBridge.PushNpcSpoonAsync(cmd);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
// 时刻表 —— SyncFavorNpcSpoonAgentId 的硬前提(客户端没有时刻表会整帧丢弃 spoon)。
if (req.HttpMethod == "POST" && path == "/api/npclife/timetable")
{
var body = await ReadBodyAsync(req);
var cmd = string.IsNullOrWhiteSpace(body)
? new GameAdminBridge.NpcTimeTableOptions()
: JsonSerializer.Deserialize<GameAdminBridge.NpcTimeTableOptions>(body, JsonOptions)
?? new GameAdminBridge.NpcTimeTableOptions();
var result = await GameAdminBridge.PushNpcTimeTableAsync(cmd);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
// 科斯机甲:隐藏可控原型的接管 / 返回(2026-09-26,见 GameRouter.Kesi.cs)。
if (req.HttpMethod == "POST" && path == "/api/kesi/control")
{
var body = await ReadBodyAsync(req);
var cmd = string.IsNullOrWhiteSpace(body)
? new GameAdminBridge.KesiControlOptions()
: JsonSerializer.Deserialize<GameAdminBridge.KesiControlOptions>(body, JsonOptions)
?? new GameAdminBridge.KesiControlOptions();
var result = await GameAdminBridge.KesiControlAsync(cmd);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
// 兜底:直接给单位下 POI 动作(198 = 站姿_运动_跑步机跑步)。
if (req.HttpMethod == "POST" && path == "/api/npclife/poi")
{
var body = await ReadBodyAsync(req);
var cmd = string.IsNullOrWhiteSpace(body)
? new GameAdminBridge.NpcPoiOptions()
: JsonSerializer.Deserialize<GameAdminBridge.NpcPoiOptions>(body, JsonOptions)
?? new GameAdminBridge.NpcPoiOptions();
var result = await GameAdminBridge.PushNpcPoiActionAsync(cmd);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/police/targets")
{
var rows = GameAdminBridge.PoliceTargets();
void_WriteJson(res, new
{
ok = true,
count = rows.Count,
targets = rows.Select(t => new
{
uid = t.Uid.ToString(),
kind = t.Kind,
label = t.Label,
distance = t.Distance < 0 ? null : (float?)MathF.Round(t.Distance, 1),
// Distance >= 0 才有服务端坐标;-1(GM 召唤)/ -2(客户端瞄准)没有。
x = t.Distance < 0 ? null : (float?)MathF.Round(t.X, 2),
y = t.Distance < 0 ? null : (float?)MathF.Round(t.Y, 2),
z = t.Distance < 0 ? null : (float?)MathF.Round(t.Z, 2),
}).ToArray(),
});
return;
}
if (req.HttpMethod == "GET" && path == "/api/blackout/status")
{
var s = GameAdminBridge.BlackoutStatus();
void_WriteJson(res, new
{
online = s.Online,
running = s.Running,
controllerId = s.ControllerId.ToString(),
remainingSeconds = s.RemainingSeconds,
endsAt = s.EndsAt,
});
return;
}
if (req.HttpMethod == "POST" && path == "/api/blackout/start")
{
// Optional {x,y,z} landing-spot override; empty body ⇒ config decides (billboard by default).
var boBody = await ReadBodyAsync(req);
float? boX = null, boY = null, boZ = null;
if (!string.IsNullOrWhiteSpace(boBody))
{
var cmd = JsonSerializer.Deserialize<BlackoutStartRequest>(boBody, JsonOptions);
boX = cmd?.X; boY = cmd?.Y; boZ = cmd?.Z;
}
var result = await GameAdminBridge.StartBlackoutAsync(boX, boY, boZ);
void_WriteJson(res, new { ok = result.Ok, message = result.Message, id = result.Id.ToString() });
return;
}
if (req.HttpMethod == "POST" && path == "/api/blackout/stop")
{
var result = await GameAdminBridge.StopBlackoutAsync();
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/crowd/refresh")
{
var refreshBody = await ReadBodyAsync(req);
float? over = null;
if (!string.IsNullOrWhiteSpace(refreshBody))
{
var cmd = JsonSerializer.Deserialize<CrowdRefreshRequest>(refreshBody, JsonOptions);
if (cmd?.Density is > 0f) over = cmd.Density;
}
var result = await GameAdminBridge.RefreshCrowdAsync(over);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/crowd/probe")
{
var p = GameAdminBridge.CrowdProbe();
void_WriteJson(res, new
{
online = p.Online,
density = Math.Round(p.Density, 4),
plotName = p.PlotName,
landPlotType = p.LandPlotType,
hourFactor = Math.Round(p.HourFactor, 4),
hour = p.Hour,
explicitTime = p.ExplicitTime,
});
return;
}
if (req.HttpMethod == "POST" && path == "/api/vehicle/enter")
{
var result = await GameAdminBridge.EnterVehicleAsync();
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/vehicle/exit")
{
var result = await GameAdminBridge.ExitVehicleAsync();
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
// 自动驾驶巡航速度:GET 读当前值,POST 保存(有车在自驾就热应用)。
// 2026-09-30 移植自捐赠端 v0.5-2 的 ApplyAutonomousDriveSpeed。
if (req.HttpMethod == "GET" && path == "/api/vehicle/autodrive-speed")
{
var s = GameAdminBridge.AutonomousDriveSpeed();
void_WriteJson(res, new
{
savedKmh = s.SavedKmh,
effectiveKmh = MathF.Round(s.EffectiveKmh, 1),
activeAutoDrive = s.ActiveAutoDrive,
minKmh = s.MinKmh,
maxKmh = s.MaxKmh,
});
return;
}
if (req.HttpMethod == "POST" && path == "/api/vehicle/autodrive-speed")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<AutoDriveSpeedRequest>(body, JsonOptions) ?? new AutoDriveSpeedRequest();
if (!float.IsFinite(cmd.SpeedKmh))
{
void_WriteJson(res, new { ok = false, message = "speedKmh 不是有效数字。" });
return;
}
var result = await GameAdminBridge.SetAutonomousDriveSpeedAsync(cmd.SpeedKmh);
void_WriteJson(res, new { ok = result.Ok, message = result.Message, hotApplied = result.HotApplied });
return;
}
if (req.HttpMethod == "GET" && path == "/api/scenes")
{
void_WriteJson(res, new { presets = GameAdminBridge.ScenePresets() });
return;
}
if (req.HttpMethod == "POST" && path == "/api/donor-npc/spawn")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<DonorNpcSpawnRequest>(body, JsonOptions) ?? new DonorNpcSpawnRequest();
if (cmd.Items is not { Count: >= 1 } || cmd.Items.Any(i => i is null || i.NpcFormworkId == 0)
|| !float.IsFinite(cmd.XSpacing) || cmd.XSpacing < 0f
|| !float.IsFinite(cmd.ZSpacing) || cmd.ZSpacing < 0f)
{
void_WriteJson(res, new { ok = false, message = "provide at least one item with a non-zero npcFormworkId, a uint poiActionId, finite non-negative X/Z spacing, and maxPerRow of at least 1" }, 400);
return;
}
if (cmd.MaxPerRow < 1)
{
void_WriteJson(res, new { ok = false, message = "maxPerRow must be at least 1" }, 400);
return;
}
var result = await GameAdminBridge.SpawnDonorStaticNpcsAsync(cmd.Items, cmd.XSpacing, cmd.ZSpacing, cmd.MaxPerRow);
// Entity ids exceed Number.MAX_SAFE_INTEGER, so return them as strings — otherwise
// every id collapses to the same double in the browser and looks like a bug.
void_WriteJson(res, new { ok = result.Ok, message = result.Message, ids = result.Ids.Select(id => id.ToString()).ToArray() });
return;
}
if (req.HttpMethod == "GET" && path == "/api/civilians")
{
void_WriteJson(res, GameAdminBridge.CivilianPanelState());
return;
}
if (req.HttpMethod == "GET" && path == "/api/civilians/group")
{
var raw = req.QueryString["id"] ?? "";
int.TryParse(raw, out var gid);
void_WriteJson(res, GameAdminBridge.CivilianGroupDetail(gid));
return;
}
if (req.HttpMethod == "POST" && path == "/api/civilians/spawn-group")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<CivilianGroupRequest>(body, JsonOptions) ?? new CivilianGroupRequest();
var result = await GameAdminBridge.SpawnCivilianGroupAsync(cmd.GroupId);
// Entity ids exceed Number.MAX_SAFE_INTEGER: as raw JSON numbers every id collapses
// to the same double in the browser (all 30 look identical). Always stringify.
void_WriteJson(res, new { ok = result.Ok, message = result.Message, ids = result.Ids.Select(id => id.ToString()).ToArray() });
return;
}
if (req.HttpMethod == "GET" && path == "/api/civilians/districts")
{
void_WriteJson(res, GameAdminBridge.CivilianDistrictState());
return;
}
if (req.HttpMethod == "POST" && path == "/api/civilians/spawn-district")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<CivilianDistrictRequest>(body, JsonOptions) ?? new CivilianDistrictRequest();
var result = await GameAdminBridge.SpawnCiviliansByDistrictAsync(cmd.Radius, cmd.MaxTotal, cmd.Hour ?? -1);
void_WriteJson(res, new
{
ok = result.Ok,
message = result.Message,
detail = result.Detail,
ids = result.Ids.Select(id => id.ToString()).ToArray(),
});
return;
}
if (req.HttpMethod == "GET" && path == "/api/functional-npcs")
{
void_WriteJson(res, GameAdminBridge.FunctionalNpcState());
return;
}
if (req.HttpMethod == "GET" && path == "/api/enemy-territory")
{
void_WriteJson(res, GameAdminBridge.EnemyTerritoryState());
return;
}
if (req.HttpMethod == "POST" && path == "/api/enemy-territory/spawn")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<EnemyTerritoryRequest>(body, JsonOptions) ?? new EnemyTerritoryRequest();
var result = await GameAdminBridge.SpawnEnemyTerritoryAsync(cmd.Radius);
void_WriteJson(res, new
{
ok = result.Ok,
message = result.Message,
ids = result.Ids.Select(id => id.ToString()).ToArray(),
detail = result.Detail,
});
return;
}
// ---------------------------------------------------------------- 搜打撤(Extraction Shooter)
if (req.HttpMethod == "GET" && path == "/api/extraction/status")
{
void_WriteJson(res, GameAdminBridge.ExtractionState());
return;
}
if (req.HttpMethod == "POST" && path == "/api/extraction/options")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<ExtractionOptionsRequest>(body, JsonOptions)
?? new ExtractionOptionsRequest();
var result = GameAdminBridge.SetExtractionOptions(
cmd.FakeMatch, cmd.PreferredRaid, cmd.ExitToMainCity, cmd.ExitSettlement,
cmd.WheelSync, cmd.WheelWeaponData);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/extraction/start")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<ExtractionStartRequest>(body, JsonOptions)
?? new ExtractionStartRequest();
var result = await GameAdminBridge.StartExtractionAsync(cmd.RaidId);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/extraction/exit")
{
var result = await GameAdminBridge.ExitExtractionAsync();
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (path == "/api/faction/invasionspots")
{
void_WriteJson(res, GameAdminBridge.ListFactionInvasionSpots());
return;
}
if (req.HttpMethod == "POST" && path == "/api/faction/invade")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<FactionInvasionRequest>(body, JsonOptions)
?? new FactionInvasionRequest();
var result = await GameAdminBridge.RunFactionInvasionAsync(
cmd.EventId, cmd.Teleport, cmd.Summon, cmd.Occupy);
void_WriteJson(res, new { ok = result.Ok, message = result.Message, detail = result.Detail });
return;
}
if (req.HttpMethod == "POST" && path == "/api/extraction/wheel/republish")
{
var result = await GameAdminBridge.RepublishExtractionWheel();
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/extraction/republish")
{
var result = await GameAdminBridge.RepublishExtractionLoginFrame();
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
// 取证探针:把任意 methodId + 裸 hex 直接推给客户端,用来反推未知包的形状。
if (req.HttpMethod == "POST" && path == "/api/extraction/notify")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<ExtractionNotifyRequest>(body, JsonOptions)
?? new ExtractionNotifyRequest();
var result = await GameAdminBridge.PushExtractionNotify(cmd.MethodId, cmd.Hex);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/functional-npcs/spawn")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<FunctionalNpcRequest>(body, JsonOptions) ?? new FunctionalNpcRequest();
var result = await GameAdminBridge.SpawnFunctionalNpcsAsync(cmd.Radius, cmd.IncludeSemantic);
void_WriteJson(res, new
{
ok = result.Ok,
message = result.Message,
detail = result.Detail,
ids = result.Ids.Select(id => id.ToString()).ToArray(),
});
return;
}
if (req.HttpMethod == "GET" && path == "/api/weapons")
{
void_WriteJson(res, new { weapons = GameAdminBridge.WeaponCatalog() });
return;
}
if (req.HttpMethod == "POST" && path == "/api/weapons/grant")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<WeaponGrantRequest>(body, JsonOptions) ?? new WeaponGrantRequest();
var result = await GameAdminBridge.GrantWeaponAsync(cmd.TemplateId);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/weapons/grant-all")
{
var result = await GameAdminBridge.GrantAllWeaponsAsync();
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/timeweather")
{
void_WriteJson(res, GameAdminBridge.TimeWeatherStatus());
return;
}
if (req.HttpMethod == "POST" && path == "/api/timeweather/time")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<TimeSetRequest>(body, JsonOptions) ?? new TimeSetRequest();
var result = await GameAdminBridge.SetTimeAsync(cmd.Hour, cmd.Minute, cmd.Fix, cmd.Transition);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/timeweather/weather")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<WeatherSetRequest>(body, JsonOptions) ?? new WeatherSetRequest();
var result = await GameAdminBridge.SetWeatherAsync(cmd.WeatherId, cmd.Transition);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
} if (req.HttpMethod == "POST" && path == "/api/scene/switch")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<SceneSwitchRequest>(body, JsonOptions) ?? new SceneSwitchRequest();
if (!string.IsNullOrWhiteSpace(cmd.Preset))
{
var preset = GameAdminBridge.ScenePresets().FirstOrDefault(p =>
string.Equals(p.Key, cmd.Preset, StringComparison.OrdinalIgnoreCase));
if (preset is null)
{
void_WriteJson(res, new { ok = false, message = $"unknown preset '{cmd.Preset}'" }, 400);
return;
}
var result = await GameAdminBridge.SwitchSceneAsync(
preset.RaidId, preset.InstanceId, preset.UniverseId,
preset.X, preset.Y, preset.Z, preset.Facing);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
var custom = await GameAdminBridge.SwitchSceneAsync(
cmd.RaidId, cmd.InstanceId, cmd.UniverseId, cmd.X, cmd.Y, cmd.Z, cmd.Facing);
void_WriteJson(res, new { ok = custom.Ok, message = custom.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/timelines")
{
void_WriteJson(res, new { timelines = GameAdminBridge.TimelineCatalog() });
return;
}
if (req.HttpMethod == "POST" && path == "/api/timeline/play")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<TimelinePlayRequest>(body, JsonOptions) ?? new TimelinePlayRequest();
if (string.IsNullOrWhiteSpace(cmd.TimelineName))
{
void_WriteJson(res, new { ok = false, message = "timelineName must be non-empty" }, 400);
return;
}
var result = await GameAdminBridge.PlayTimelineAsync(cmd.TimelineName);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
// ---- 剧情对话(2026-10-02 从捐赠端 PrivateServerPor 移植)----
// SyncShowDialog(64967628) 只带 dialogId + beginDialog + 全零 param;
// 台词文本 / 镜头 / 语音都在客户端自己的 DialogConfig 里,服务端不搬内容。
if (req.HttpMethod == "GET" && path == "/api/story/dialogs")
{
var q = req.QueryString.Get("q");
var cut = req.QueryString.Get("cutscene");
bool? cutscene = cut switch { "1" or "true" => true, "0" or "false" => false, _ => null };
var limitRaw = req.QueryString.Get("limit");
var limit = int.TryParse(limitRaw, out var lv) ? lv : 200;
var stats = GameAdminBridge.StoryDialogStats();
void_WriteJson(res, new
{
dialogs = GameAdminBridge.StoryDialogCatalog(q, cutscene, limit),
total = stats.Total,
cutsceneTotal = stats.Cutscene,
});
return;
}
if (req.HttpMethod == "POST" && path == "/api/story/dialog")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<StoryDialogPlayRequest>(body, JsonOptions) ?? new StoryDialogPlayRequest();
if (cmd.DialogId == 0)
{
void_WriteJson(res, new { ok = false, message = "dialogId must be non-zero" }, 400);
return;
}
var result = await GameAdminBridge.PlayStoryDialogAsync(cmd.DialogId);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/story/dialog-voice")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<StoryDialogPlayRequest>(body, JsonOptions) ?? new StoryDialogPlayRequest();
if (cmd.DialogId == 0)
{
void_WriteJson(res, new { ok = false, message = "dialogId must be non-zero" }, 400);
return;
}
var result = await GameAdminBridge.PlayStoryDialogVoiceAsync(cmd.DialogId);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/npcs")
{
void_WriteJson(res, new { npcs = GameAdminBridge.NpcCatalog() });
return;
}
if (req.HttpMethod == "POST" && path == "/api/npc/spawn")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<NpcSpawnRequest>(body, JsonOptions) ?? new NpcSpawnRequest();
if (cmd.NpcCultivationId == 0)
{
void_WriteJson(res, new { ok = false, message = "npcCultivationId must be non-zero" }, 400);
return;
}
var result = await GameAdminBridge.SpawnNpcAsync(cmd.NpcCultivationId, cmd.X, cmd.Y, cmd.Z, cmd.Facing);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/items/grant-all")
{
var body = await ReadBodyAsync(req);
uint count = 999u;
try
{
var cmd = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(body, JsonOptions);
if (cmd is not null && cmd.TryGetValue("count", out var cEl) && cEl.TryGetUInt32(out var c) && c > 0)
count = Math.Min(c, 999999u);
}
catch { /* default count */ }
var result = await GameAdminBridge.GrantAllItemsAsync(count);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/currency/grant-all")
{
await ReadBodyAsync(req);
var result = await GameAdminBridge.GrantAllCurrencyAsync();
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/spirits")
{
void_WriteJson(res, new
{
roster = GameAdminBridge.SummonRoster()
.Select(s => new { spiritId = s.SpiritId, name = s.Name, unitId = s.UnitId })
});
return;
}
if (req.HttpMethod == "POST" && path == "/api/spirit/switch")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<SpiritSwitchRequest>(body, JsonOptions) ?? new SpiritSwitchRequest();
var result = await GameAdminBridge.SwitchSpiritAsync(cmd.SpiritId);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
// ---- 切人演出:按**动画**分(2026-10-03 纠正,见 Bridge 里 AdminSwitchShowEntry 的注释)----
// 主清单 = SwitchSpiritConfig 的 201 行(一行一出演出),角色是第二个下拉。
if (req.HttpMethod == "GET" && path == "/api/spirit/switch-shows")
{
var rows = GameAdminBridge.SwitchShowCatalog();
void_WriteJson(res, new
{
shows = rows,
total = rows.Count,
playableTotal = rows.Count(x => x.Playable),
// 0 = 还没进世界。前端用它在目标角色下拉里避开当前角色(选当前角色 = 静默 no-op)。
activeSpiritId = GameAdminBridge.ActiveSpiritId(),
});
return;
}
if (req.HttpMethod == "POST" && path == "/api/spirit/switch-show")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<SwitchShowPlayRequest>(body, JsonOptions) ?? new SwitchShowPlayRequest();
if (cmd.SwitchShowId == 0)
{
void_WriteJson(res, new { ok = false, message = "switchShowId must be non-zero" }, 400);
return;
}
var result = await GameAdminBridge.PlaySwitchShowAsync(cmd.SpiritId, cmd.SwitchShowId);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/metro/status")
{
void_WriteJson(res, GameAdminBridge.MetroStatus());
return;
}
if (req.HttpMethod == "POST" && path == "/api/metro/toggle")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<MetroToggleRequest>(body, JsonOptions) ?? new MetroToggleRequest();
void_WriteJson(res, GameAdminBridge.SetMetroEnabled(cmd.Enabled));
return;
}
if (req.HttpMethod == "POST" && path == "/api/metro/lines")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<MetroLinesRequest>(body, JsonOptions) ?? new MetroLinesRequest();
void_WriteJson(res, GameAdminBridge.ConfigureMetroLines(
cmd.Lines is { Count: > 0 } ? cmd.Lines : (cmd.Lines is null ? null : []),
cmd.PushMs,
cmd.TimeFactor,
cmd.Auto));
return;
}
if (req.HttpMethod == "POST" && path == "/api/metro/stop")
{
void_WriteJson(res, GameAdminBridge.StopMetro());
return;
}
if (req.HttpMethod == "GET" && path == "/api/wanted/status")
{
void_WriteJson(res, GameAdminBridge.WantedStatus());
return;
}
if (req.HttpMethod == "POST" && path == "/api/wanted/action")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<GameAdminBridge.WantedActionRequest>(body, JsonOptions)
?? new GameAdminBridge.WantedActionRequest();
void_WriteJson(res, await GameAdminBridge.WantedActionAsync(cmd));
return;
}
if (req.HttpMethod == "GET" && path == "/api/agents/hostile")
{
void_WriteJson(res, new { agents = GameAdminBridge.AgentSummonCatalog(true) });
return;
}
if (req.HttpMethod == "GET" && path == "/api/agents/other")
{
void_WriteJson(res, new { agents = GameAdminBridge.AgentSummonCatalog(false) });
return;
}
if (req.HttpMethod == "POST" && path == "/api/agent/spawn")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<AgentSpawnRequest>(body, JsonOptions) ?? new AgentSpawnRequest();
if (cmd.AgentId == 0)
{
void_WriteJson(res, new { ok = false, message = "agentId must be non-zero" }, 400);
return;
}
var result = await GameAdminBridge.SpawnAgentAsync(cmd.AgentId, cmd.X, cmd.Y, cmd.Z, cmd.Facing, cmd.Hostile);
void_WriteJson(res, new { ok = result.Ok, message = result.Message, uid = result.Uid });
return;
}
if (req.HttpMethod == "POST" && path == "/api/player/unlock")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PlayerUnlockRequest>(body, JsonOptions) ?? new PlayerUnlockRequest();
var result = await GameAdminBridge.ClearPlayerControlStatesAsync(cmd.Mode ?? "curated");
void_WriteJson(res, new { ok = result.Ok, message = result.Message, count = result.Count });
return;
}
if (req.HttpMethod == "POST" && path == "/api/player/revive")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PlayerReviveRequest>(body, JsonOptions) ?? new PlayerReviveRequest();
var result = await GameAdminBridge.RestorePlayerHealthAsync(cmd.UnitId ?? 0UL);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/player/vitals-lock")
{
var (ok, locked, hp, message) = GameAdminBridge.GetPlayerLifeLock();
void_WriteJson(res, new { ok, locked, hp, message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/player/vitals-lock")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PlayerVitalsLockRequest>(body, JsonOptions) ?? new PlayerVitalsLockRequest();
var result = GameAdminBridge.SetPlayerLifeLock(cmd.Enabled);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/player/unlock-auto")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<PlayerUnlockAutoRequest>(body, JsonOptions) ?? new PlayerUnlockAutoRequest();
var result = GameAdminBridge.SetAutoUnlock(cmd.Enabled);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/agent/spawn-grid")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<AgentGridRequest>(body, JsonOptions) ?? new AgentGridRequest();
if (cmd.AgentId == 0)
{
void_WriteJson(res, new { ok = false, message = "agentId must be non-zero" }, 400);
return;
}
var result = await GameAdminBridge.SpawnAgentGridAsync(cmd.AgentId, cmd.Hostile, cmd.X, cmd.Y, cmd.Z,
cmd.Distance, cmd.Facing, cmd.Rows, cmd.Cols, cmd.Spacing);
void_WriteJson(res, new
{
ok = result.Ok,
message = result.Message,
points = result.Points.Select(p => new { x = MathF.Round(p.X, 2), y = MathF.Round(p.Y, 2), z = MathF.Round(p.Z, 2), uid = p.Uid, ok = p.Ok, msg = p.Message }),
});
return;
}
if (req.HttpMethod == "POST" && path == "/api/gm/runutils")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<RunGmUtilsRequest>(body, JsonOptions) ?? new RunGmUtilsRequest();
var result = await GameAdminBridge.RunGmUtilsAsync(cmd.MethodName, cmd.Args);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "POST" && path == "/api/gm/command")
{
var body = await ReadBodyAsync(req);
var cmd = JsonSerializer.Deserialize<DoGmCommandRequest>(body, JsonOptions) ?? new DoGmCommandRequest();
var result = await GameAdminBridge.DoGmCommandAsync(cmd.Command, cmd.Args);
void_WriteJson(res, new { ok = result.Ok, message = result.Message });
return;
}
if (req.HttpMethod == "GET" && path == "/api/spawn/ids")
{
var kind = req.QueryString["kind"] ?? "enemy";
void_WriteJson(res, new { items = GameAdminBridge.SpawnIdCatalog(kind) });
return;
}
void_WriteJson(res, new { ok = false, message = "not found" }, 404);
}
catch (JsonException jex)
{
void_WriteJson(ctx.Response, new { ok = false, message = "bad request: " + jex.Message }, 400);
}
catch (Exception ex)
{
void_WriteJson(ctx.Response, new { ok = false, message = ex.Message }, 500);
}
}
private static object StatusPayload()
{
var s = GameAdminBridge.GetSnapshot();
return new
{
connected = s.Connected,
session = s.SessionId,
lastSeenUtc = s.LastSeenUtc,
unitId = s.UnitId,
templateId = s.TemplateId,
x = s.X, y = s.Y, z = s.Z,
facing = s.Facing,
hasTransform = s.HasTransform,
ready = s.Ready,
pendingTeleportId = s.PendingTeleportId,
raidId = s.ActiveRaidId,
instanceId = s.ActiveInstanceId,
universeId = s.ActiveUniverseId,
currentVehicleId = s.CurrentVehicleId,
currentVehicleSeat = s.CurrentVehicleSeat,
};
}
private static Dictionary<string, string> ParseQuery(string? query)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(query))
return result;
foreach (var part in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var eq = part.IndexOf('=');
if (eq < 0) { result[Uri.UnescapeDataString(part)] = ""; continue; }
result[Uri.UnescapeDataString(part[..eq])] = Uri.UnescapeDataString(part[(eq + 1)..]);
}
return result;
}
/// <summary>取一个 u64 查询参数(骰子试验台用;解析不出来就用默认值)。</summary>
private static ulong QueryU64(Dictionary<string, string> query, string key, ulong fallback)
=> query.TryGetValue(key, out var raw) && ulong.TryParse(raw, out var v) ? v : fallback;
private static async Task<string> ReadBodyAsync(HttpListenerRequest req)
{
using var reader = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8);
var text = await reader.ReadToEndAsync();
return string.IsNullOrWhiteSpace(text) ? "{}" : text;
}
private static void void_Write(HttpListenerResponse res, string contentType, string text, int status = 200)
{
var bytes = Encoding.UTF8.GetBytes(text);
res.StatusCode = status;
res.ContentType = contentType;
// 2026-10-02:面板每次改完都要重编译服务端,浏览器缓存旧 HTML/JS 会让「改了没生效」
// 变成假故障(旧按钮调的是已删的函数 ⇒ 点了毫无反应)。彻底禁用缓存。
res.AddHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
res.AddHeader("Pragma", "no-cache");
res.AddHeader("Expires", "0");
res.ContentLength64 = bytes.Length;
res.OutputStream.Write(bytes, 0, bytes.Length);
res.OutputStream.Close();
}
private static void void_WriteJson(HttpListenerResponse res, object payload, int status = 200)
=> void_Write(res, "application/json; charset=utf-8", JsonSerializer.Serialize(payload, JsonOptions), status);
/// <summary>
/// 面板「实时调参」的提交体:<c>changes</c> 是「点号路径 → JSON 字面量」,
/// 例如 <c>{"gameplay.aether.trafficTarget": 12}</c>。用 JsonNode 是为了保留
/// 数字/布尔/字符串的类型 —— 交给 <see cref="RuntimeConfigEditor"/> 做类型守恒。
/// </summary>
private sealed class SettingsUpdateRequest
{
public Dictionary<string, System.Text.Json.Nodes.JsonNode?>? Changes { get; set; }
}
private sealed class TeleportRequest
{
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public float? Facing { get; set; }
public string? Mode { get; set; }
}
/// <summary>
/// 刷单个物件(2026-10-03):<c>POST /api/gadget/spawn</c> —— 按 pathId 在世界坐标放一个 gadget。
/// ⛑ <c>iScale</c> 默认 0(0 ⇒ 客户端 1:1;100 ⇒ scale=(0,0,1) 空壳,见健身房器材那次)。
/// 街头篮球补篮筐用 <c>pathId=1086190276</c>(Gadgets_Common_BasketballRim)。
/// </summary>
private sealed class GadgetSpawnRequest
{
public int PathId { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public float Yaw { get; set; }
public int IScale { get; set; }
}
private sealed class VehicleSpawnRequest
{
public uint VehicleId { get; set; }
public bool Force { get; set; }
}
/// <summary>自动驾驶巡航速度(km/h,2026-09-30)。</summary>
private sealed class AutoDriveSpeedRequest
{
public float SpeedKmh { get; set; } = 43.2f;
}
/// <summary>
/// 正向逮捕(2026-09-25)。<c>uid</c> 为 0 时取最近一次 GM 召唤的 NPC。
/// ⛑ 这是<b>验证通道</b>不是玩法入口:官方逮捕挂在盘查面板(C# 侧)上,
/// 客户端何时发 AskPoliceExamInteract(63233964) 还没有实据。
/// </summary>
/// <summary>捐赠端 <c>/api/lixi/police</c> 的请求体(逐字对齐捐赠端 AdminWebServer.cs:551-554)。</summary>
private sealed class LixiPoliceRequest
{
public string Action { get; set; } = "";
}
private sealed class PoliceExamineRequest
{
public ulong Uid { get; set; }
/// <summary>
/// 塞几条罪名。<b>0 ⇒ 全部</b>(<c>PoliceFineCatalog</c> 里 <c>IsShow</c> 的行,当前 16 条)。
/// 2026-10-03 改:此前默认 3,盘查面板只认得 3 条案底;现在默认全发。
/// 想只发几条就显式传 1..16。
/// </summary>
public uint FineCount { get; set; } = 0;
/// <summary>true ⇒ 先把玩家挪到目标跟前再开盘查面板。
/// ⛑ 这条路 2026-09-25 已证伪(instant 被上报覆盖 / loading 反序列化失败),
/// 留着只是回滚用;日常请用 <see cref="Pull"/>。</summary>
public bool Teleport { get; set; }
/// <summary>true ⇒ 只发「收盘查面板」帧(面板是模态的,没这个就只能用重开客户端脱身)。
/// ⛑ 2026-09-25 修:之前这个字段反序列化出来了却<b>没传给</b> ExamineNpcAsync,
/// 等于关面板通道一直是死的。</summary>
public bool Close { get; set; }
/// <summary>true ⇒ 把目标 NPC 瞬移到玩家正前方 2.2 m 再开盘查面板(推荐)。</summary>
public bool Pull { get; set; }
/// <summary>罪名帧(SyncAgentCrimes 68251759)的线格式旋钮,见 PoliceCrimesRequest.Wire。</summary>
public string Wire { get; set; } = "a";
}
/// <summary>
/// <c>POST /api/police/react</c>:推一次「盘查反应」<c>SyncPoliceExamReaction</c>(64480222)。
/// ⛑ 必须在玩家点过某个选项之后再推 —— <c>OnReceiveReaction</c> 用的是
/// <c>self.PreOptionId</c>,没点过它是空的。
/// </summary>
private sealed class PoliceReactRequest
{
/// <summary><c>PoliceExamReactionConfig</c> 的 id。配置表只在客户端手里,
/// 服务端没有 ⇒ 先从小 id 往上试,看面板哪个有反应。</summary>
public uint ReactionId { get; set; } = 1;
}
/// <summary>
/// <c>POST /api/police/crimes</c>:单独下发「案底」(<c>SyncAgentCrimes</c> 68251759)。
/// 单独留一个口子是因为:盘查面板开着的时候也能补发,面板刷新就会读到,
/// 不用重新开一次面板 —— 调线格式时省很多轮。
/// </summary>
private sealed class PoliceCrimesRequest
{
public ulong Uid { get; set; }
/// <summary>罪名 id(<c>PoliceFineCatalog.Id</c>)。留空 ⇒ 发 RenderableFines 全部(16 条)。</summary>
public List<uint>? Fines { get; set; }
/// <summary>线格式旋钮:a(默认 [FF][Int7 count+1]) / b([FF][Raw7 count]) /
/// c([Int7 count+1] 无 marker) / d([FF][u32 count])。猜错了不用重启,换字母重发即可。</summary>
public string Wire { get; set; } = "a";
}
/// <summary>
/// <c>POST /api/police/exam-auto</c>:「盘查模式」开关 —— 打开后,玩家走近一个 NPC、
/// 准星对上他(客户端自己会发 <c>AskInteractCmd</c> cmd=33),盘查面板自动弹出;
/// <b>再看他一次即关闭</b>(toggle)。
///
/// ⛑ 默认关。走路时扫到谁都弹面板会很烦,而且 <c>spawn-group</c> 一次性放 60 人时
/// 客户端会给每个人发一轮 cmd=33 ⇒ 不关就是连弹。
/// ⛑ 卡住了随时 <c>POST /api/police/exam-close</c> 强制关,不用重开客户端。
/// </summary>
private sealed class PoliceExamAutoRequest
{
public bool Enabled { get; set; }
}
/// <summary><c>POST /api/police/exam-spawn</c>:AgentId 为 0 ⇒ 用默认市民(40954013 咖啡店店员)。</summary>
private sealed class PoliceExamSpawnRequest
{
public uint AgentId { get; set; }
}
/// <summary>
/// <c>POST /api/police/fine</c>:手动推一次罚款结果 ——
/// <c>ShowReceiveRewardDetail</c>(64145515) 且 <c>Reason = ItemReason.PoliceFine(180001)</c>。
///
/// ⛑⛑ 正常链路是玩家点「开罚单」⇒ C2S <c>AskPoliceFineNpc</c>(63548238) ⇒ 服务端自动回这一帧。
/// 留这个口子是为了<b>不等客户端也能验证线格式对不对</b>:客户端报
/// 「64145515 反序列化失败」就说明字节错了,改完重发即可,不用重启服务端。
/// ⛑ 但 <c>OnDropPoliceJobExp</c> 要求 <c>needShowFineResult</c> 为真(点过开罚单才置位),
/// 所以裸推只能验"反序列化过不过",要看到结果面板还是得先走一遍开罚单。
/// </summary>
private sealed class PoliceFineRequest
{
/// <summary>罚款金额(<c>RewardDetail.Money</c>,double)。</summary>
public double Money { get; set; } = 500;
/// <summary>警察职业经验(<c>JobExpInfo[11300003]</c>)。
/// ⛑ 必须 &gt; 0 —— <c>OnDropPoliceJobExp</c> 里 <c>Money&gt;0 且 JobExpInfo 没命中 Police</c>
/// 时<b>不会</b>弹结果面板。</summary>
public uint JobExp { get; set; } = 50;
}
/// <summary>里希牢房(2026-09-30):<c>POST /api/police/prison</c>。
/// <c>action</c> = <c>commit</c>(收押,带 uid)|<c>release</c>(带出,不用 uid)。</summary>
private sealed class PolicePrisonRequest
{
public string Action { get; set; } = "commit";
/// <summary>uid 以字符串收(面板那侧是大整数,JS Number 装不下),空 ⇒ 0。</summary>
public string Uid { get; set; } = "";
}
/// <summary>里希押送(2026-09-30):<c>POST /api/police/escort</c>。
/// <c>action</c> = <c>board</c>(押上车,带 uid/vehicle/seat)|<c>alight</c>(押下车)。</summary>
private sealed class PoliceEscortRequest
{
public string Action { get; set; } = "board";
public string Uid { get; set; } = "";
public string Vehicle { get; set; } = "";
public int Seat { get; set; } = 1;
}
/// <summary>GATE Lua(2026-09-30):<c>POST /api/gatelua</c> —— 把整段 Lua 下发到客户端执行。</summary>
private sealed class GateLuaRequest
{
public string Script { get; set; } = "";
}
/// <summary>GATE Lua F 按钮(2026-09-30):<c>POST /api/gatelua/fbtn</c>。
/// <c>action</c> = <c>add</c>|<c>remove</c>;uid 由服务端拆成 low32/high32 注入脚本。</summary>
private sealed class GateLuaButtonRequest
{
public string Action { get; set; } = "add";
public string Uid { get; set; } = "";
public string Text { get; set; } = "里希盘查";
}
/// <summary>嘉姆入侵:三步各自独立开关,方便蹚哪一步出问题。</summary>
private sealed class FactionInvasionRequest
{
public int EventId { get; set; }
public bool Teleport { get; set; } = true;
public bool Summon { get; set; } = true;
public bool Occupy { get; set; } = true;
}
private sealed class PoliceArrestRequest
{
public ulong Uid { get; set; }
/// <summary>true ⇒ 先把玩家挪到目标跟前(服务端自己算坐标),再发逮捕帧。
/// 行人是走动的,客户端上报包里的坐标是几十秒前的,不能拿来算落点。
/// ⛑ 同 PoliceExamineRequest:这条路已证伪,请用 <see cref="Pull"/>。</summary>
public bool Teleport { get; set; }
/// <summary>true ⇒ 把目标 NPC 瞬移到玩家正前方 2.2 m 再发逮捕帧(推荐)。</summary>
public bool Pull { get; set; }
}
private sealed class DonorNpcSpawnRequest
{
public List<DonorNpcSpawnItem>? Items { get; set; }
public float XSpacing { get; set; } = 1.5f;
public float ZSpacing { get; set; } = 1.5f;
public int MaxPerRow { get; set; } = 10;
}
private sealed class CrowdRefreshRequest
{
/// <summary>临时覆盖密度;留空/null 则按所在地块与时辰现算。</summary>
public float? Density { get; set; }
}
private sealed class BlackoutStartRequest
{
/// <summary>控制器落点 X;三个都给了才生效,否则按配置(默认刷在广告牌上)。</summary>
public float? X { get; set; }
public float? Y { get; set; }
public float? Z { get; set; }
}
private sealed class CivilianGroupRequest
{
/// <summary>Id of a group in Configs/CivilianGroups.json (1-based, G001 …).</summary>
public int GroupId { get; set; } = 1;
}
/// <summary>Land-plot citizen seeding (T0-8). Hour null = use the hour the world clock shows.</summary>
private sealed class CivilianDistrictRequest
{
public float Radius { get; set; } = 150f;
public int MaxTotal { get; set; } = 120;
public int? Hour { get; set; }
}
/// <summary>Functional-NPC seeding. Radius &lt;= 0 means the whole current scene.</summary>
private sealed class FunctionalNpcRequest
{
public float Radius { get; set; }
public bool IncludeSemantic { get; set; } = true;
}
private sealed class EnemyTerritoryRequest
{
public float Radius { get; set; }
}
private sealed class WeaponGrantRequest
{
public uint TemplateId { get; set; }
}
private sealed class TimeSetRequest
{
public int Hour { get; set; }
public int Minute { get; set; }
public bool Fix { get; set; } = true;
public int Transition { get; set; } = 5;
}
private sealed class WeatherSetRequest
{
public int WeatherId { get; set; }
public int Transition { get; set; } = 15;
}
private sealed class SceneSwitchRequest
{
public string? Preset { get; set; }
public uint RaidId { get; set; }
public ulong InstanceId { get; set; }
public uint UniverseId { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public float Facing { get; set; }
}
private sealed class StoryDialogPlayRequest
{
public uint DialogId { get; set; }
}
private sealed class TimelinePlayRequest
{
public string? TimelineName { get; set; }
}
private sealed class NpcSpawnRequest
{
public uint NpcCultivationId { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public float Facing { get; set; }
}
private sealed class SpiritSwitchRequest
{
public uint SpiritId { get; set; }
}
/// <summary>Body of <c>POST /api/spirit/switch-show</c>:指定「播哪条演出」+「切成谁」。</summary>
private sealed class SwitchShowPlayRequest
{
public uint SwitchShowId { get; set; }
public uint SpiritId { get; set; }
}
/// <summary>Body of <c>POST /api/metro/toggle</c>. Defaults to true so a body-less POST starts the dispatcher.</summary>
private sealed class MetroToggleRequest
{
public bool Enabled { get; set; } = true;
}
/// <summary>
/// Body of <c>POST /api/metro/lines</c>. <see cref="Lines"/> null keeps the current selection (that is
/// how the panel retunes only the period / time factor); an empty list turns every line off - which is
/// why the distinction between null and empty matters here.
/// </summary>
private sealed class MetroLinesRequest
{
public List<uint>? Lines { get; set; }
public int? PushMs { get; set; }
public double? TimeFactor { get; set; }
/// <summary>
/// true = 回到「跟随当前地区」(按 RaidConfig.LightRailLineIds 自动选线)。
/// 不带 lines、只带 auto 时使用;发了 lines 就转手动,直到再发一次 auto。
/// </summary>
public bool? Auto { get; set; }
}
private sealed class RunGmUtilsRequest
{
public string MethodName { get; set; } = string.Empty;
public List<string> Args { get; set; } = new();
}
private sealed class DoGmCommandRequest
{
public string Command { get; set; } = string.Empty;
public List<string> Args { get; set; } = new();
}
private sealed class AgentSpawnRequest
{
public uint AgentId { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public float Facing { get; set; }
public bool Hostile { get; set; }
}
private sealed class PlayerUnlockRequest
{
/// <summary>"curated" (hand-picked control states) or "sweep" (every id in 1..500).</summary>
public string? Mode { get; set; }
}
private sealed class PlayerReviveRequest
{
/// <summary>Unit to heal. Omit for the tracked player unit; pass the client-side entity id
/// (packets log: AskUnitMoveActionSimple.UnitId) when that one owns the HP bar.</summary>
public ulong? UnitId { get; set; }
}
private sealed class PlayerUnlockAutoRequest
{
public bool Enabled { get; set; }
}
private sealed class PlayerVitalsLockRequest
{
/// <summary>True keeps the player's HP above zero (never knocked out).</summary>
public bool Enabled { get; set; }
}
private sealed class AgentGridRequest
{
public uint AgentId { get; set; }
public bool Hostile { get; set; }
public float? X { get; set; }
public float? Y { get; set; }
public float? Z { get; set; }
public float Distance { get; set; } = 30f;
public float Facing { get; set; }
public int Rows { get; set; } = 2;
public int Cols { get; set; } = 3;
public float Spacing { get; set; } = 3f;
}
/// <summary>搜打撤面板的统一开关提交。<b>全部可为 null</b> —— null 表示这一项不改。</summary>
private sealed class ExtractionOptionsRequest
{
public bool? FakeMatch { get; set; }
public uint? PreferredRaid { get; set; }
public bool? ExitToMainCity { get; set; }
public bool? ExitSettlement { get; set; }
public string? WheelSync { get; set; } // off / incremental / full
public bool? WheelWeaponData { get; set; }
}
private sealed class ExtractionStartRequest
{
public uint RaidId { get; set; } = 23301277;
}
/// <summary>「任意 notify 探针」的提交体。Hex 可空 = 推空体。</summary>
private sealed class ExtractionNotifyRequest
{
public uint MethodId { get; set; }
public string? Hex { get; set; }
}
private const string PageHtmlTemplate = """
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ANANTA · GM Console</title>
<link rel="icon" type="image/png" href="data:image/png;base64,/*brand-icon*/">
<style>
:root{
--bg:#0d1117; --side-w:252px;
--card-1:#161d27; --card-2:#101721;
--line:#222b36; --line-soft:#1a212b;
--txt:#e6edf3; --txt-2:#9aa7b4; --txt-3:#69747f;
--acc:#2f7dff; --acc-2:#1a5fe0; --acc-glow:rgba(47,125,255,.38);
--ok:#3fb950; --warn:#f0a03c;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
color:var(--txt);font-size:13px;overflow:hidden;
background:linear-gradient(180deg,#0a0f15,#0d1117 35%,#070a0e);
}
.bg-glow{position:fixed;inset:0;pointer-events:none;
background:radial-gradient(1100px 520px at 72% -12%,rgba(47,125,255,.13),transparent 62%)}
.shell{position:relative;height:100vh;display:grid;grid-template-rows:56px 1fr 38px}
/* ── 顶栏 ───────────────────────────────────────── */
.topbar{position:relative;display:flex;align-items:center;justify-content:space-between;
padding:0 16px;border-bottom:1px solid var(--line);background:rgba(9,13,18,.86);backdrop-filter:blur(10px)}
.hamburger{width:44px;height:44px;margin-left:-4px;border-radius:11px;display:grid;place-items:center;cursor:pointer;
color:var(--txt-2);transition:.14s;flex:0 0 auto}
.hamburger:hover{background:#18212c;color:var(--txt);border-color:#3d4c60}
.hamburger svg{width:24px;height:24px;stroke:currentColor;fill:none;stroke-width:2.1;stroke-linecap:round}
/* 居中品牌:字体参考官网左上角 ANANTA —— 超粗 + 宽字距 + 大写感 */
.center-brand{position:absolute;left:50%;top:0;height:56px;transform:translateX(-50%);
display:flex;align-items:center;gap:11px;pointer-events:none}
.center-brand img{width:32px;height:32px;border-radius:50%;object-fit:cover;object-position:50% 28%;
border:1.5px solid #2b3a4d;box-shadow:0 0 0 3px rgba(47,125,255,.10)}
.center-brand .name{
font-family:"Arial Black","Segoe UI Black","Segoe UI",Inter,system-ui,sans-serif;
font-weight:900;font-size:17px;letter-spacing:2.2px;white-space:nowrap;
background:linear-gradient(180deg,#ffffff,#c9d6e6);-webkit-background-clip:text;background-clip:text;
-webkit-text-fill-color:transparent;text-shadow:0 2px 14px rgba(47,125,255,.22);
-webkit-text-stroke:.35px rgba(255,255,255,.35)}
.top-right{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--txt-3)}
.top-right .chip{border:1px solid var(--line);border-radius:20px;padding:4px 11px;
background:#101722;color:var(--txt-2)}
/* ── 主体 ───────────────────────────────────────── */
.main{position:relative;display:grid;grid-template-columns:var(--side-w) 1fr;min-height:0;
transition:grid-template-columns .18s ease}
body.collapsed{--side-w:64px}
.side{border-right:1px solid var(--line);background:rgba(8,11,16,.72);
display:flex;flex-direction:column;padding:14px 10px 10px;min-height:0;overflow:hidden}
.side .group{font-size:10px;color:var(--txt-3);letter-spacing:1.6px;padding:0 10px 10px;white-space:nowrap}
.nav-group{font-size:10px;color:var(--txt-3);letter-spacing:1.6px;
padding:12px 10px 6px;white-space:nowrap;display:flex;align-items:center;gap:8px}
.nav-group::after{content:"";flex:1 1 auto;height:1px;background:var(--line-soft)}
.nav-group:first-child{padding-top:2px}
/* 侧栏收起后只剩 64px:分组文字挤不下,隐藏掉,留那条细线当分隔 */
body.collapsed .nav-group{padding:11px 0 5px;justify-content:center}
body.collapsed .nav-group span{display:none}
.side .group.collapsed-title{display:none}
body.collapsed .side .group.collapsed-title{display:block;text-align:center;padding:0 0 10px;letter-spacing:1px}
body.collapsed .side .group:not(.collapsed-title){display:none}
.nav{display:flex;flex-direction:column;gap:5px;overflow:auto;overflow-x:hidden}
.nav::-webkit-scrollbar{width:6px}
.nav::-webkit-scrollbar-thumb{background:#1e2732;border-radius:4px}
.nav-item{display:flex;align-items:center;gap:11px;padding:0 12px;height:52px;border-radius:10px;
color:var(--txt-2);cursor:pointer;border:1px solid transparent;user-select:none;flex:0 0 auto;transition:.14s}
.nav-item svg{width:19px;height:19px;flex:0 0 auto;stroke:currentColor;fill:none;
stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.nav-item .txt{flex:1;font-size:13px;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.nav-item .arrow{opacity:.4;font-size:15px;line-height:1;flex:0 0 auto}
.nav-item:hover{background:#131a23;color:var(--txt)}
.nav-item.active{background:linear-gradient(90deg,#2f7dff,#1a5cd6);color:#fff;
box-shadow:0 8px 22px -10px var(--acc-glow);border-color:#4a92ff}
.nav-item.active .arrow{opacity:.85}
body.collapsed .nav-item{justify-content:center;padding:0}
body.collapsed .nav-item .txt,body.collapsed .nav-item .arrow{display:none}
.side .foot{margin-top:auto;padding:13px 8px 6px;border-top:1px solid var(--line-soft);
display:flex;align-items:center;gap:10px}
.side .foot img{width:44px;height:44px;border-radius:12px;object-fit:cover;object-position:50% 20%;
border:1px solid #33445a;box-shadow:0 0 0 3px rgba(47,125,255,.10);flex:0 0 auto}
.side .foot .gen{display:flex;flex-direction:column;gap:6px;min-width:0}
.side .foot .gen b{font-weight:800;white-space:nowrap;line-height:1.35;letter-spacing:.2px}
.side .foot .gen b:first-child{font-size:11px;color:#c8d2de}
.side .foot .gen b:last-child{font-size:12px;color:#c8d2de}
body.collapsed .side .foot{display:none}
/* ── 内容区(单页滚动) ─────────────────────────── */
.content{position:relative;overflow:auto;overflow-x:hidden;padding:6px 22px 60px;
scroll-behavior:smooth;min-width:0}
.content::-webkit-scrollbar{width:10px}
.content::-webkit-scrollbar-thumb{background:#1e2732;border-radius:6px;border:3px solid transparent;background-clip:content-box}
.sec{scroll-margin-top:14px;margin:0 0 26px;min-width:0}
.sec-head{position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:11px;
padding:11px 0 9px;margin-bottom:14px;border-bottom:1px solid var(--line-soft);
background:linear-gradient(180deg,rgba(11,15,21,.97) 62%,rgba(11,15,21,0));backdrop-filter:blur(6px)}
.sec-head .ic{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto;
background:linear-gradient(180deg,#2f7dff,#1a5cd6);box-shadow:0 6px 16px -8px var(--acc-glow)}
.sec-head .ic svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
.sec-head h1{font-size:19px;margin:0;font-weight:700;letter-spacing:.4px}
.sec-head .count{font-size:11px;color:var(--txt-3);border:1px solid var(--line);
border-radius:20px;padding:3px 10px;background:#101722}
.page-sub{font-size:12px;color:var(--txt-3);margin:0 0 14px}
.grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px;align-items:start}
/* 卡片 */
.card{position:relative;border:1px solid var(--line);border-radius:12px;padding:14px 16px 16px;
background:linear-gradient(158deg,#161d27,#111823 58%,#0f151e);overflow:hidden}
.card::after{content:"";position:absolute;left:16px;right:16px;top:0;height:1px;pointer-events:none;
background:linear-gradient(90deg,transparent,rgba(160,200,255,.22),transparent)}
.card>*{position:relative;z-index:1}
.card h2{font-size:13.5px;margin:0 0 10px;font-weight:650;letter-spacing:.2px;
display:flex;align-items:center;gap:8px;line-height:1.3}
.card h2 .ci{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;flex:0 0 auto;
background:#101923;border:1px solid #253140}
.card h2 .ci svg{width:12px;height:12px;stroke:#5aa0ff;fill:none;stroke-width:1.8;
stroke-linecap:round;stroke-linejoin:round}
.card .hint,.card .muted{font-size:11px;color:var(--txt-3);line-height:1.6;margin:-4px 0 10px}
.card .hint b,.card .muted b{color:var(--txt-2);font-weight:600}
.card hr{border:0;border-top:1px solid var(--line-soft);margin:12px 0}
/* 表单(裸标签也要兼容:现有卡片大量使用无 class 的 label/input/button) */
label{font-size:11px;color:var(--txt-3);margin-right:4px}
input,select,textarea{background:#0d141c;color:var(--txt);border:1px solid #23303e;border-radius:6px;
padding:5px 8px;font-size:12px;font-family:inherit;outline:none;transition:.14s;flex:0 0 auto}
input:focus,select:focus,textarea:focus{border-color:var(--acc);box-shadow:0 0 0 2px rgba(47,125,255,.16)}
input[type=number]{width:82px}
input[type=time]{width:96px}
input[type=checkbox],input[type=radio]{width:auto;padding:0;margin:0 5px 0 0;accent-color:var(--acc)}
select{min-width:120px}
textarea{width:100%;box-sizing:border-box;resize:vertical;font-family:Consolas,"Cascadia Mono",monospace}
/* ⛑ 定高 30px + inline-flex 居中:保证同一行的按钮高度绝对一致(emoji 不会把某个按钮顶高) */
button{background:#1a2331;border:1px solid #2b3746;color:#dbe4ee;border-radius:7px;
padding:0 10px;height:30px;line-height:1;display:inline-flex;align-items:center;justify-content:center;
font-size:11.5px;font-family:inherit;cursor:pointer;transition:.14s;box-sizing:border-box;
margin:4px 3px 0 0;white-space:nowrap}
button:hover{background:#212c3b;border-color:#3d4c60}
button:active{transform:translateY(1px)}
button.primary{background:linear-gradient(180deg,#3a86ff,#1f66e0);border-color:#4a92ff;color:#fff;
box-shadow:0 8px 18px -10px var(--acc-glow)}
button.primary:hover{filter:brightness(1.08)}
button.ghost{background:transparent}
/* 纯按钮行(整行只有按钮):能一行放就一行,放不下则平分整行,避免右边留半行空白。
.btns 由 JS 自动打(见 hydrateIcons 之后那几行)。 */
.btns{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:7px;margin-top:8px;align-items:stretch}
/* ⛑ 长按钮(如「启动(默认刷在玩家脚下)」)在 110px 轨道里 nowrap 会顶出格子、盖住相邻按钮。
这里允许换行 + 高度自适应,靠 grid 的 align-items:stretch 保证同一行按钮仍然等高。 */
.btns>button{width:100%;min-width:0;margin:0;height:auto;min-height:30px;
padding:5px 8px;white-space:normal;line-height:1.25}
/* 表单里的按钮也允许换行(nowrap 会让长文案顶出格子、盖住邻居) */
.form-field>.btns>button{white-space:normal}
.card>button+.hint,.card>.btns+.hint{margin-top:8px}
/* ⛑ 带 .row/.gd-row 的是「标签/徽标 + 按钮」混排行,继续用 flex,不能被网格拆散 */
.row.btns,.gd-row.btns{display:flex;flex-wrap:wrap;gap:6px;margin:7px 0}
.row.btns>button,.gd-row.btns>button{width:auto;min-width:max-content;max-width:100%;flex:1 1 auto}
/* ⛑⛑ 这里**绝不能**写 min-width:max-content —— 网格里的按钮会被撑成整行文本宽(CJK 的
max-content = 不换行全长),在 110px 轨道里溢出并盖住相邻按钮(Seymour「启动(默认刷在玩家
脚下)」盖住「复位」就是它)。max-content 只给上面的 flex 混排行用。 */
.btns>button{flex:1 1 auto;margin:0;max-width:100%}
.row.btns,.gd-row.btns{align-items:stretch}
/* 混在按钮行里的小文本徽标(如「总开关」+ 状态):与按钮同高、不抢宽度 */
.mbadge{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 10px;border-radius:7px;
background:rgba(255,255,255,.03);border:1px solid var(--line-soft);color:var(--txt-3);
font-size:11px;white-space:nowrap;flex:0 0 auto}
.mbadge b{font-weight:800}
.row{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:7px 0}
.row>label{display:flex;align-items:center;gap:5px;margin:0}
/* 混合行(label/input/select + button)里的按钮也参与分摊剩余宽度,行尾不再留空白 */
.row>button,.gd-row>button{flex:1 1 auto;margin:0}
.gd-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:7px 0}
.gd-row>label{font-size:11px;color:var(--txt-3);display:flex;align-items:center;gap:5px}
.gd-row>select,.gd-row>input{background:#0e1419;color:#cfd8dc;border:1px solid #23303a;border-radius:5px;padding:4px 6px}
.gd-row>input.num{width:64px}
.gd-row>select{min-width:200px}
/* 开关 / 复选 */
.sw{display:inline-flex;align-items:center;gap:9px;cursor:pointer;font-size:11.5px;color:var(--txt-2);margin:6px 0}
.sw input{display:none}
.sw .track{width:34px;height:19px;border-radius:19px;background:#232e3c;position:relative;transition:.18s;flex:0 0 auto}
.sw .track::after{content:"";position:absolute;top:2px;left:2px;width:15px;height:15px;border-radius:50%;
background:#7d8a99;transition:.18s}
.sw input:checked + .track{background:var(--acc);box-shadow:0 0 12px -2px var(--acc-glow)}
.sw input:checked + .track::after{left:17px;background:#fff}
.cb{display:inline-flex;align-items:center;gap:7px;cursor:pointer;font-size:11.5px;color:var(--txt-2);margin:6px 0}
.cb input{appearance:none;width:14px;height:14px;border:1px solid #33404f;border-radius:4px;
background:#0d141c;margin:0;cursor:pointer;position:relative;transition:.14s}
.cb input:checked{background:var(--acc);border-color:#4a92ff}
.cb input:checked::after{content:"";position:absolute;left:4px;top:1px;width:4px;height:8px;
border:2px solid #fff;border-top:0;border-left:0;transform:rotate(42deg)}
/* 日志 / 表格 */
pre{background:#070b10;border:1px solid var(--line-soft);border-radius:7px;padding:8px 10px;
font-family:Consolas,"Cascadia Mono",monospace;font-size:11px;line-height:1.55;color:var(--txt-2);
white-space:pre-wrap;max-height:170px;overflow:auto;margin:9px 0 0;min-height:32px}
pre:empty::before{content:"> 就绪 — 等待操作";color:var(--txt-3)}
table{border-collapse:collapse;font-size:12px}
table th,table td{border-bottom:1px solid var(--line-soft);padding:4px 6px;text-align:left}
/* 折叠说明 */
details.help{margin-top:9px}
details.help>summary{cursor:pointer;font-size:10.5px;color:var(--txt-3);user-select:none;list-style:none;
display:list-item}
details.help>summary::before{content:"▸ ";}
details.help[open]>summary::before{content:"▾ ";}
details.help>summary:hover{color:var(--txt-2)}
details.help p,details.help>div{font-size:10.5px;line-height:1.72;color:var(--txt-3);margin:6px 0 0}
details.help p b{color:var(--txt-2)}
details.help code,.card code{background:#0d141c;border:1px solid var(--line-soft);border-radius:4px;padding:0 4px;font-size:10px}
.warn,.card .warn{color:var(--warn)}
.ok{color:var(--ok)}
.pos{color:var(--ok)}
/* 跨列卡片 */
/* ── 卡片尺寸体系:12 列栅格,按内容量分级 ───────────────────── */
.card{height:auto;align-self:start}
.card.sm{grid-column:span 3}
.card.md{grid-column:span 4}
.card.lg{grid-column:span 6}
.card.xl{grid-column:span 8}
.card.full{grid-column:1 / -1}
/* 兼容旧类名(万一还有注入片段在用) */
.card.span2{grid-column:span 6}
.card.span4{grid-column:span 12}
/* 竖排分组:把若干张卡压成「一列」占 N 栏(角色、时间和天气里左侧那三张就用它)。
⛑ 里面的卡是 flex 子项(不是网格项),所以必须显式 width:100% 并把 grid-column 清掉,
否则 .card.xl{span 12} 那类规则会残留。 */
.grid>.col-stack{display:flex;flex-direction:column;gap:16px;min-width:0;grid-column:span 4}
.grid>.col-stack>.card{grid-column:auto;width:100%;min-width:0}
/* 4 栏竖排组(职业和独立玩法:三列各占 4 栏,正好 12 栏一整排) */
.grid>.col-stack.s4{grid-column:span 4}
/* 卡片内表单栅格:label/控件成组后按列排,不再全部挤一行 */
.form-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:8px;margin:7px 0;align-items:center}
.form-field.full{grid-column:1 / -1}
.form-field.half{grid-column:span 6}
.form-field.third{grid-column:span 4}
.form-field.two-third{grid-column:span 8}
.form-field{display:flex;align-items:center;gap:6px;min-width:0}
.form-field>label{display:flex;align-items:center;gap:5px;margin:0;flex:0 0 auto;white-space:nowrap}
/* label 自带控件(<label>UID<input></label>)时整个 label 要能撑开 */
.form-field>label:has(>input),.form-field>label:has(>select),
.form-field>label:has(>textarea){flex:1 1 auto;min-width:0;white-space:normal}
.form-field>label>input,.form-field>label>select,.form-field>label>textarea,
.form-field>input,.form-field>select,.form-field>textarea{flex:1 1 auto;min-width:0;width:auto}
/* ⛑⛑ 复选框/单选框**不能**跟着撑开:上面那条 flex:1 1 auto 会把方块拉成一条
(势力入侵卡的「传送过去 / 带两个随从 / 占领」就是这么被拉长的)。它们只吃自身尺寸,不参与分配。 */
.form-field>label>input[type=checkbox],.form-field>label>input[type=radio],
.form-field>input[type=checkbox],.form-field>input[type=radio]{flex:0 0 auto}
/* ⛑ .cb 的方块是 appearance:none 手画的,上面那条 width:auto 会把它压成 2px ⇒ 尺寸必须显式写回来 */
.form-field>label.cb>input{width:14px;height:14px}
/* .cb(复选框标签)本身也不抢满整列,保持「方块 + 文字」的实际宽度 */
.form-field>label.cb{flex:0 0 auto}
.form-field>.hint,.form-field>span,.form-field>b{flex:0 0 auto}
/* 按钮组在 form-grid 里要跨满整行 */
.btns.wide{grid-column:1 / -1}
@media (max-width:1500px){
.grid{gap:14px}
.card.sm{grid-column:span 4}
.card.md{grid-column:span 6}
.card.lg{grid-column:span 6}
.card.xl{grid-column:span 12}
.card.span2{grid-column:span 6}
.card.span4{grid-column:span 12}
/* 4 栏太窄了,竖排组改成整行 */
.grid>.col-stack{grid-column:1 / -1;gap:14px}
.grid>.col-stack.s4{grid-column:span 6}
}
@media (max-width:1050px){
.grid{grid-template-columns:repeat(6,minmax(0,1fr))}
.card.sm,.card.md,.card.lg,.card.xl,.card.full{grid-column:span 6}
.card.span2,.card.span4{grid-column:span 6}
.grid>.col-stack{grid-column:span 6}
.grid>.col-stack.s4{grid-column:1 / -1}
}
@media (max-width:700px){
.grid{grid-template-columns:1fr;gap:12px}
.card,.card.sm,.card.md,.card.lg,.card.xl,.card.full,
.card.span2,.card.span4{grid-column:1 / -1 !important}
/* 侧栏收到只剩图标,避免移动端横向溢出 */
.main{grid-template-columns:64px 1fr}
.nav-item{justify-content:center;padding:0}
.nav-item .txt,.nav-item .arrow,.nav-group span{display:none}
.side .group:not(.collapsed-title){display:none}
.content{padding:6px 12px 60px}
}
/* 跨列卡片内部可以再分列,避免一行拉太长 */
.split{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.split>.col{min-width:0}
.sub-h{font-size:11.5px;color:var(--txt-2);font-weight:600;margin:0 0 6px}
@media (max-width:900px){ .split{grid-template-columns:1fr} }
/* 右下角快捷菜单:默认常驻展开,9 个纯图标按钮(8 栏目 + 回到顶部) */
.dock{position:fixed;right:22px;bottom:22px;z-index:40;opacity:0;pointer-events:none;transition:opacity .22s}
.dock.show{opacity:1;pointer-events:auto}
.dock-panel{display:flex;flex-direction:column;gap:3px;padding:8px;border-radius:15px;
background:rgba(14,20,28,.97);border:1px solid #2b3746;backdrop-filter:blur(12px);
box-shadow:0 18px 40px -18px rgba(0,0,0,.95)}
.dock-item{position:relative;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;
color:var(--txt-2);cursor:pointer;border:1px solid transparent;transition:.14s}
.dock-item:hover{background:#18212c;color:var(--txt)}
.dock-item.active{background:linear-gradient(180deg,#2f7dff,#1a5cd6);color:#fff;border-color:#4a92ff;
box-shadow:0 6px 16px -8px var(--acc-glow)}
.dock-item svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:1.7;
stroke-linecap:round;stroke-linejoin:round}
/* 悬停时在左侧弹出名字(不占版面) */
.dock-item::after{content:attr(data-tip);position:absolute;right:calc(100% + 9px);top:50%;
transform:translateY(-50%) translateX(4px);white-space:nowrap;
background:#111823;border:1px solid #2b3746;border-radius:7px;padding:4px 9px;
font-size:12px;color:var(--txt-2);opacity:0;pointer-events:none;transition:.16s;
box-shadow:0 8px 20px -12px rgba(0,0,0,.9)}
.dock-item:hover::after{opacity:1;transform:translateY(-50%) translateX(0)}
.dock-sep{height:1px;background:var(--line);margin:5px 3px}
/* toast(原型:显示「点了哪个函数」)—— 放左下,避免和右下 dock 打架 */
.toast{position:fixed;left:22px;bottom:22px;z-index:30;max-width:330px;
background:rgba(16,23,32,.96);border:1px solid #2d3a49;border-radius:10px;padding:9px 13px;
font-size:11.5px;color:var(--txt-2);line-height:1.6;opacity:0;transform:translateY(8px);
transition:.22s;pointer-events:none;box-shadow:0 12px 30px -14px rgba(0,0,0,.9)}
.toast.show{opacity:1;transform:translateY(0)}
.toast code{color:#7fb4ff;font-family:Consolas,monospace}
/* ── 底栏 ───────────────────────────────────────── */
.statusbar{display:flex;align-items:center;gap:22px;padding:0 20px;font-size:11.5px;color:var(--txt-3);
border-top:1px solid var(--line);background:rgba(9,13,18,.9);backdrop-filter:blur(10px)}
.statusbar .dot{width:7px;height:7px;border-radius:50%;background:var(--ok);
box-shadow:0 0 9px var(--ok);display:inline-block;margin-right:7px;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
.statusbar .sb{display:flex;align-items:center;gap:6px}
.statusbar .sb b{color:var(--txt-2);font-weight:600}
.statusbar .tail{margin-left:auto;color:#3f4a57;letter-spacing:.4px}
/*metro-css*/
/*view-mode-css*/
</style></head><body class="gm-simple collapsed">
<div class="bg-glow"></div>
<div class="shell">
<!-- 顶栏:左=收起开关,中=头像+标题(居中),右=状态 chip -->
<header class="topbar">
<div class="hamburger" onclick="toggleSide()" title="收起 / 展开侧栏">
<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</div>
<div class="center-brand">
<img src="data:image/png;base64,/*brand-icon*/" alt="">
<span class="name">Ananta GM Console</span>
</div>
<div class="top-right">
<!--view-mode-card-->
<span class="chip">本地 127.0.0.1:17888</span>
</div>
</header>
<div class="main">
<!-- 侧栏 -->
<aside class="side">
<div class="group">MODULES</div>
<div class="group collapsed-title">GM</div>
<nav class="nav" id="nav"></nav>
<div class="foot">
<img src="data:image/png;base64,/*brand-icon*/" alt="">
<div class="gen"><b>Generated by</b><b>Doubao-Seed-2.1-Turbo</b></div>
</div>
</aside>
<!-- 内容:8 个 section 全在这一个滚动容器里 -->
<main class="content" id="content">
<section class="sec" id="sec-quick">
<div class="sec-head">
<span class="ic" data-ic="bolt"></span>
<h1>快捷功能</h1><span class="count">4 张卡</span>
</div>
<div class="grid">
<div class="card lg"><h2><i class="ci" data-ic="pin"></i>场景切换</h2>
<div class="form-grid"><div class="form-field full"><label>预设</label><select id="sf" style="flex:1 1 340px;min-width:0">
<option value="nova-spawn">Nova spawn (WorldMap_Release)</option>
<option value="lingyun-dinglan">定澜北路 — 凌云 (23300999)</option>
<option value="tafei-apartment">塔菲公寓</option>
<option value="zhangwang-zhengba">掌王争霸</option>
<option value="heilingjie-restaurant">黑领结餐厅</option>
<option value="bushou-dont-look-up">不要抬头(搜打撤)(23301277)</option>
<option value="extraction-final-battle">搜打撤 · 终局之战 (23301215)</option>
<option value="extraction-tomb-raid">搜打撤 · 古墓摸金 (23301311)</option>
<option value="tomb-of-terror">恐怖墓穴(踩空重新传送)(23301285)</option>
<option value="gold-hunt-entry">猎金游戏入口</option>
</select></div><div class="btns wide"><button class="primary" onclick="switchPreset()">传送到预设</button></div></div>
<hr>
<div class="form-grid"><div class="form-field third"><label>副本</label><input id="sr" type="number" value="23300999" style="flex:1 1 120px;width:auto;min-width:0"></div><div class="form-field third"><label>实例</label><input id="si" type="number" value="20001223" style="flex:1 1 120px;width:auto;min-width:0"></div><div class="form-field third"><label>宇宙</label><input id="su" type="number" value="76000888" style="flex:1 1 120px;width:auto;min-width:0"></div></div>
<div class="form-grid"><div class="form-field half"><label>X</label><input id="sx" type="number" step="any" value="-1638" style="flex:1 1 110px;width:auto;min-width:0"></div><div class="form-field half"><label>Y</label><input id="sy" type="number" step="any" value="18.025" style="flex:1 1 110px;width:auto;min-width:0"></div><div class="form-field half"><label>Z</label><input id="sz" type="number" step="any" value="1540.5" style="flex:1 1 110px;width:auto;min-width:0"></div><div class="form-field half"><label>朝向</label><input id="sfa" type="number" step="any" value="-6.4" style="flex:1 1 110px;width:auto;min-width:0"></div></div>
<button class="primary" onclick="switchCustom()">传送到自定义</button>
<button onclick="fillSceneCurrent()">填入当前位置</button>
<pre id="sres"></pre>
</div>
<div class="card lg"><h2><i class="ci" data-ic="box"></i>发放全道具</h2>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="grantAllWeapons()">发放全武器</button><button class="primary" onclick="grantAllItems()">发放全道具</button><button class="primary" onclick="grantAllCurrency()">发放全货币</button></div></div>
<div class="form-grid"><div class="form-field full"><label>每个道具的数量</label><input id="itcount" type="number" step="1" min="1" value="999" style="flex:1 1 120px;width:auto;min-width:0"></div></div>
<hr>
<div class="form-grid"><div class="form-field full"><label>搜索</label><input id="wfilter" type="text" placeholder="按名称或 ID 筛选…" oninput="filterWeapons()" style="flex:1 1 190px;width:auto;min-width:0"></div></div>
<div class="form-grid"><div class="form-field full"><label>武器</label><select id="wf" style="flex:1 1 190px"><option value="">— 由 /api/weapons 填充 —</option></select></div></div>
<div class="form-grid"><div class="form-field full"><label>或输入 ID</label><input id="wid" type="number" min="1" step="1" placeholder="自由输入武器 TemplateId" style="flex:1 1 190px;width:auto;min-width:0"></div></div>
<button onclick="grantWeapon()">授予武器</button>
<pre id="wres"></pre>
<details class="help"><summary>说明</summary>
<p><b>发放全武器</b>:解锁全部武器,游戏内通过武器轮盘装备。<br>
<b>发放全道具</b>:每个道具只占一个格子,重复点击会把已有格子补到上面填的数量。<br>
<b>发放全货币</b>:金币 / 钻石 / 绑定钻三种钱包货币各自<b>补到 999 万</b>(已 ≥ 该数的币种不会重复发放)。<br>
⛑️ <b>钱包货币不进背包</b>——塞进背包会让 HUD 与背包显示不一致,所以这三种是被道具发放主动跳过的,只能从「发放全货币」走钱包通道。<br>
三者都会写入玩家存档,重登后仍在。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="skull"></i>生成敌人(敌对单位)</h2>
<div class="hint">单位生成在<b>你点击那一刻的站位</b>上 —— 点完请走开两步、转身回头看,别站在原地判断成败。</div>
<div class="form-grid"><div class="form-field full"><label>搜索</label><input id="efilter" type="text" placeholder="按名称或ID筛选…" oninput="filterAg('e')" style="flex:1 1 190px"></div></div>
<div class="form-grid"><div class="form-field full"><label>敌人</label><select id="esel" style="flex:1 1 190px"><option value="">— 由 /api/agents 填充 —</option></select></div></div>
<div class="form-grid"><div class="form-field full"><label>或输入ID</label><input id="ecustom" type="number" min="1" step="1" placeholder="自由输入 Agent ID" style="flex:1 1 190px"></div></div>
<button class="primary" onclick="agLocal('e')">生成敌人</button>
<button onclick="agServer('e')">服务端直生成(敌人)</button>
<pre id="eres"></pre>
<details class="help"><summary>说明</summary>
<p><b>注意同名陷阱</b>:<code>黑金节奏手</code> 有 20+ 条同名记录,按名字选必错,只有精确 Id 可用。<br>
同名同模型的重复项已折叠为最小 ID,在「或输入ID」填被折叠的 ID 可精确生成。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="car"></i>生成载具</h2>
<div class="hint">车辆出现在身旁,走近按 <b>F</b> 上车 / 登机。</div>
<div class="hint">飞行 / 水上是<b>强制生成,绕过车队白名单</b>。</div>
<div class="form-grid"><div class="form-field full"><label>载具</label><select id="vwf" style="flex:1 1 190px"><option value="">— 由 /api/vehicles 填充 —</option></select></div></div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="spawnVehicle(true)">生成飞行/水上载具</button></div></div>
<hr>
<div class="form-grid"><div class="form-field half"><label>车队</label><select id="vf" style="flex:1 1 110px;min-width:0"><option value="">— 由 /api/vehicles 填充 —</option></select></div><div class="form-field half"><label>或输入 ID</label><input id="vi" type="number" value="0" style="flex:1 1 80px;width:auto;min-width:0;height:29px"></div></div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="spawnVehicle(false)">生成载具</button></div></div>
<div id="vstate" class="hint" role="status" aria-live="polite"></div>
<div class="form-grid"><div class="btns wide"><button onclick="enterVehicle()">进入上次生成的载具</button><button onclick="exitVehicle()">离开载具</button></div></div>
<pre id="vres"></pre>
<details class="help"><summary>说明</summary>
<p>两条生成路径走的是<b>同一条命令</b> <code>/api/vehicle/spawn</code>,只是飞行 / 水上多带一个 <code>force:true</code> 绕过车队白名单。<br>
「进入 / 离开」与下面的调试输出框对两种生成方式<b>共用</b>。<br>
飞行/水上载具需要合适地形(船要水面,飞机要开阔空域)。走近按 <b>F</b> 登机。</p></details>
</div>
</div>
</section>
<section class="sec" id="sec-schedule">
<div class="sec-head">
<span class="ic" data-ic="cal"></span>
<h1>角色、时间和天气</h1><span class="count">3 张卡</span>
</div>
<div class="grid">
<div class="col-stack">
<div class="card lg"><h2><i class="ci" data-ic="clock"></i>时间与天气</h2>
<div id="twstate" class="hint" role="status" aria-live="polite">加载中…</div>
<div class="form-grid"><div class="form-field third"><label>时间</label><input id="timeValue" type="time" step="60" value="12:00" aria-label="游戏时间(24小时制)"></div><div class="form-field third"><label>固定</label><input id="timeFix" type="checkbox" checked></div><div class="form-field third"><label>过渡</label><input id="tt" type="number" value="5" style="width:60px"></div><div class="btns wide"><button class="primary" onclick="setTime()">设置时间</button></div></div>
<div class="form-grid"><div class="form-field half"><label>天气</label><select id="ws" style="flex:1 1 180px;min-width:0"><option value="">— 由天气表填充 —</option></select></div><div class="form-field half"><label>过渡</label><input id="wt" type="number" value="15" style="width:60px"></div><div class="btns wide"><button class="primary" onclick="setWeather()">设置天气</button><button onclick="clearWeather()">清除覆盖</button></div></div>
<pre id="twres"></pre>
</div>
<div class="card md"><h2><i class="ci" data-ic="swap"></i>切换主控角色</h2>
<div class="form-grid"><div class="form-field full"><label>搜索</label><input id="swfilter" type="text" placeholder="按名称或 ID 筛选…" oninput="filterSwitchSpirits()" style="flex:1 1 190px"></div></div>
<div class="form-grid"><div class="form-field full"><label>角色</label><select id="swsel" onchange="document.getElementById('swid').value=this.value" style="flex:1 1 190px"><option value="">— 由 roster 填充 —</option></select></div></div>
<div class="form-grid"><div class="form-field full"><label>或输入 ID</label><input id="swid" type="text" placeholder="战斗灵 ID,如 15021029 弥弥古" style="flex:1 1 190px"></div></div>
<button class="primary" onclick="switchSpiritNow()">切换操控角色</button>
<pre id="swres"></pre>
<details class="help"><summary>说明</summary>
<p>服务端直切,<b>不切场景、不播换人演出</b>,目标直接出现在你当前位置。<br>
名单是 roster 全部角色,含 CanJoin=false 的剧情角色 —— 这些在 Z 轮盘里是灰的,只能从这里切。<br>
资源不全的角色切过去会 <b>T-pose 或无武器</b>,属正常,换回即可。</p></details>
</div>
</div>
<div class="card xl"><h2><i class="ci" data-ic="sprite"></i>角色日程</h2>
<div id="rsstate" class="hint" role="status" aria-live="polite">加载中…</div>
<button class="primary" onclick="loadRoster()">刷新</button>
<label class="cb"><input id="rsOnlyHere" type="checkbox" onchange="renderRoster()"><span>只看能落地的</span></label>
<table id="rstbl" style="width:100%;border-collapse:collapse;font-size:12px"></table>
<div class="hint">只列<b>官方有日程</b>的角色(没时刻表的 OC 占位行 / 两个主角不摆也不显示);角色要等你走近(默认 260m)才会被放出来。</div>
<details class="help"><summary>传送落点 / 状态判读</summary>
<p>「传送过去」会落在角色斜后方约 7 米并转向面向他,不会贴在脸上。
状态栏写「走近才出现」= 还没进 260 m 的放出范围。</p></details>
<pre id="rsres"></pre>
</div>
</div>
</section>
<section class="sec" id="sec-jobs">
<div class="sec-head">
<span class="ic" data-ic="pad"></span>
<h1>职业和独立玩法</h1><span class="count">8 张卡</span>
</div>
<div class="grid">
<div class="col-stack s4">
<div class="card md"><h2><i class="ci" data-ic="bulb"></i>Seymour 大停电</h2>
<div class="form-grid"><div class="form-field third"><label>落点 X</label><input id="boX" value="" placeholder="留空=玩家脚下" style="width:96px"></div><div class="form-field third"><label>Y</label><input id="boY" value="" placeholder="留空" style="width:96px"></div><div class="form-field third"><label>Z</label><input id="boZ" value="" placeholder="留空" style="width:96px"></div></div>
<div class="form-grid"><div class="btns wide"><button onclick="boUseOfficial()">官方停电牌·屏幕正中心</button><button onclick="boUseNearest()">填最近的广告牌</button><button onclick="boUseFoot()">退回牌子脚下</button><button onclick="boClear()">清空(用玩家脚下)</button><button onclick="boTeleport()">人传过去</button></div></div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="blackoutStart()">启动(默认刷在玩家脚下)</button><button onclick="blackoutStop()">复位</button><button onclick="blackoutStatus()">刷新状态</button></div></div>
<pre id="bo"></pre>
<details class="help"><summary>这条链怎么走 / 怎么看成功</summary>
<p>服务端只做三件事:<b>①</b> 发任务图 <code>SyncPlayerLoadTaskSpoonDebug(60002222, 34)</code>
<b>②</b> 用 <code>SyncGadgetAOIAddAndRemove</code> 放下控制器 <code>Gadgets_Hacker_Tetris</code>
(pathId <code>1166913245</code>,GraphId <code>36003094</code>)<b>③</b> 回答 <code>AskStart</code> 的 deadline、
在 <code>Report</code> 时推那一下 <code>Creation 56860922</code>。<br>
灭灯、广告牌换肤、俄罗斯方块 UI 全是<b>客户端原生图自己的事</b>,服务端不演。<br><br>
<b>这次特意没做 Lua 下发</b>:donor 靠 <code>SyncDoToLuaMaster(45437825)</code> 帮玩家按那个按钮,
我们没实现那条 GATE 通道。所以刷完控制器后服务端就停手,等客户端自己触发。<br><br>
<b class="warn">官方玩法 = 对着广告牌骇入</b>(2026-09-23 实测定论):走到广告牌前 → 鼠标中键骇入
<code>AskHack(44)</code> → 城市停电 → <b>在广告牌上</b>玩俄罗斯方块。所以控制器必须刷在
<b>广告牌坐标</b>上,骇入交互点才落在牌子本身。<br>
⛑️ <b>别刷在玩家脚下</b>:那会得到「距离恒为 0 ⇒ 站哪都能骇入」的假象(实测过)。<br>
⭐ <b>落点 = 屏幕正中心</b>(用户定):面板给的三个框是<b>控制器坐标</b>,
默认 2001.48 / 70 / 2287.69 = 官方牌 mall006a <b>屏幕</b>的中心。<b>人不要传那里</b> —— 70 m 高空;
「人传过去」永远走 <code>BO_SPOTS[*].l</code>(人行道点 1992.47,-0.01,2313.44)。<br>
⚠️ 屏幕在高空 ⇒ 骇入交互可能超出射程。按不到就点「退回牌子脚下」把控制器放回地面。<br>
⛔ donor §6.3 的 <code>Position=(0,0,0)</code> 已被实测否掉 —— 交互点会在 4 km 外。<br><br>
<b>结果读法</b>:<code>ok waiting-for-interact</code> = 控制器已被客户端 ack,现在轮到客户端出牌。
<code>controller-ack-timeout</code> = 20 秒内没 ack,去 <code>Player.log</code> 找 <code>deserialize failed</code>。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="siren"></i>通缉系统(警车版)</h2>
<div class="form-grid"><div class="form-field full"><label>星级</label><select id="wtLevel"><option value="1">1 星</option><option value="2">2 星</option><option value="3">3 星</option><option value="4">4 星</option><option value="5">5 星</option></select></div><div class="btns wide"><button class="primary" onclick="wantedSetLevel()">设为该星级并出警</button></div></div>
<div class="form-grid"><div class="btns wide"><button onclick="wantedAdd()">加犯罪值</button></div><div class="form-field half"><input id="wtPoints" value="25" style="width:60px"></div><div class="btns wide"><button onclick="wantedEscape()">开始逃脱</button></div><div class="form-field half"><input id="wtSeconds" value="10" style="width:50px"></div></div>
<div class="form-grid"><div class="btns wide"><button onclick="wantedArrest()">判定被捕</button><button onclick="wantedClear()">清除通缉</button><button onclick="wantedStatus()">刷新状态</button></div></div>
<pre id="wtOut"></pre>
<details class="help"><summary>这条链怎么走 / 目前能干什么</summary>
<p><b>涨犯罪值</b>(服务端自己记,不吃客户端任何犯罪上报):打中 NPC <code>+10</code>、击杀 <code>+60</code>;
阈值 <code>50/100/200/400/800</code> ⇒ <code>1~5</code> 星,星级一变就推
<code>SyncPlayerCrimeLevel(68841232)</code> 刷 HUD 星数。<br>
<b>出警</b>:按星级刷 <code>1~3</code> 台真警车(<code>81007029 / 81007078 / 81004027</code>),
落在玩家背后 <code>30~54 m</code>;<b>追人是客户端自己的车辆 AI</b>,
玩家在车上时额外发 <code>SyncPoliceDispatchVehicleChase(68586100)</code> 咬住那台车。<br>
<b>结算</b>:逃脱 <code>等级 × 5000</code> 入账,被捕 <code>等级 × 2500</code> 罚款。<br>
<b>自动判据</b>(服务端真信号):通缉持续满 <code>90 s</code> ⇒ 自动逃脱;玩家倒地 ⇒ 自动被捕。<br><br>
<span class="warn">⛔ 这一版没有步行警员</span>:捐赠端那半靠它自己的 crowd-agent 管线,本方没有,
而且它那份日志早于它自己的最后一次改动 ⇒ 那半<b>根本没被实机验证过</b>,不照抄。<br>
<span class="warn">⛔ 不接官方犯罪通道</span>:<code>SyncAgentCrimeAdd(68831313)</code> /
<code>ReportPoliceCrimeEvent(67146776)</code> / <code>GmAddCrimeValue(69946581)</code> 全都没碰,
代价是「只有服务端认可的行为会涨值」,好处是不会踩官方那套没摸清的帧时序。<br>
⛔ 也<b>没有犯罪任务链</b>(商店抢劫 / 偷车)—— 那半依赖本方完全没有的任务状态物件。</p>
<p><b>怎么验</b>:面板点「设为 3 星」⇒ 游戏里背后应当冒出 3 台警车并追过来;点「判定被捕」⇒
推 <code>SyncPoliceBeginArrest(68839124)</code> 并扣钱。日志搜 <code>[WANTED]</code>。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="pin"></i>势力入侵(嘉姆 · 占领地盘)</h2>
<div id="ivstate" class="hint" role="status" aria-live="polite">加载中…</div>
<div class="form-grid"><div class="form-field full"><label>入侵点</label><select id="ivspot" style="flex:1 1 260px"><option value="">— 加载中 —</option></select></div></div>
<div class="form-grid"><div class="form-field third"><label class="cb"><input id="ivTp" type="checkbox" checked><span>传送过去</span></label></div><div class="form-field third"><label class="cb"><input id="ivSum" type="checkbox" checked><span>带两个随从</span></label></div><div class="form-field third"><label class="cb"><input id="ivOcc" type="checkbox" checked><span>占领</span></label></div></div>
<button class="primary" onclick="runInvasion()">开始入侵</button>
<pre id="ivres"></pre>
<details class="help"><summary>说明</summary>
<p>官方那套「操控嘉姆占敌方地盘」拆成三步,本面板把它们串起来;
⛑ <b>不代切角色</b> —— 请先自己在角色界面切到嘉姆(spiritId 15020989)。<br>
<b>两个随从</b>(2026-10-01 改过)= TAB 轮盘<b>实际会下发</b>的那两个槽:
slot 10520000 → 40651922 晴、slot 10520017 → 40155016 盖斯;
原来那两个 40651920/40651921 的解锁条件是「成立犬魔社」,玩家没解锁,轮盘里压根不出现。<br>
⛑ <b>随从没有血条、不会主动出手</b>(模板无 ShowHPBar/InitWeaponId、attacks=none),
生成成功时只会站在你旁边 —— 日志里能看到 <code>AskInteractCmd</code> 就说明它出现了。<br>
<b>占领只是推一个包</b>:<code>SyncFactionInfluenceAreaOccupy</code>(64830440) = <code>areaId</code> + <code>occupy</code>
两个字段,地图上的变色由客户端自己画(<code>MapSubSystem_Gangster.lua</code>)。<br>
点位表:<code>ClientData/4229938/Configs/FactionInvasionSpots.json</code>,
由 ConfigDump_v3 的势力地盘/势力事件/任务事件三张表 join 导出。<br>
⛔ <b>边界</b>:本面板只做到「送到地方 + 放两个随从 + 推一个占领包」,
<b>没有</b>敌人波次、占领进度条、结算 —— 那套是客户端 lua 的状态机
(<code>MapSubSystem_Gangster.lua</code> / <code>GangsterAreaClientState.lua</code>),
主线服务端没有对应驱动。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="mech"></i>科斯机甲:原生控制</h2>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="kesiControl(true)">控制科斯机甲</button><button onclick="kesiControl(false)">返回原角色</button></div></div>
<pre id="kesiOut"></pre>
<details class="help"><summary>这条链怎么走 / 怎么用 / 怎么验</summary>
<p><b>它为什么进不了花名册</b>:FightSpiritConfig <code>15022030</code> "特摄战原型-科斯"
<code>IsPublish=false / CanJoin=false / Invisible=true</code> ⇒ GM 面板的换人下拉里选不到它,
客户端 roster 里也永远不会有它。这是<b>隐藏可控原型</b>,不是普通车辆。<br><br>
<b>服务端要补的三件事</b>(缺一不可,见 <code>GameRouter.Kesi.cs</code>):
① <code>SyncPlayerAddNewSpirit(64691088)</code> 增量补 SpiritViewData —— 登录包里没有它,
客户端 <code>SpiritManager.Id_to_Tid</code> 查不到 tid,后面所有和它相关的包会被静默丢掉;
② 科斯专属 loadout —— Sceneitem <code>80003500</code>"科斯冲锋枪"(<b>没有 WheelIcon</b>,
所以常规军械库过滤器会判掉它,必须单独建一份);
③ 走本服已在用的切换链路做身份移交,不另发明一套切人。<br>
<b>怎么用</b>:进游戏 → 下车 → 站室外空地 → 点「控制科斯机甲」;想退出时点「返回原角色」。<br>
<span class="warn">⛔ 需实机验证</span>:能确认的只有<b>服务端协议侧</b>齐了。模型异步加载、镜头、
机甲武器与技能面板还没用游戏画面确认过 —— 捐赠端自己也标了"尚需游戏内验证"。<br>
<b>怎么验</b>:服务端日志搜 <code>[KESI]</code>,成功应为 <code>enter=True ok=True</code>;
失败先看是不是 <code>缺少科斯原生武器/技能配置</code>,再看 <code>[SWITCH-MIN]</code>。</p></details>
</div>
</div>
<div class="col-stack s4">
<div class="card md" id="lixiCard"><h2><i class="ci" data-ic="users"></i>里希盘查</h2>
<p>原生盘查测试:切换里希,下车并退出机械狗骑乘/遥控,点「生成盘查测试 NPC」。等模型加载后站到 6 米内,点击“启动原生盘查”,<b>无需 F</b>。此入口仅选择本场景已生成的最近 4065053;地图市民与其他模型暂不开放。<br><b>⛑ 测试模板固定为 4065053,不要改</b>:客户端按 <code>AgentConfig.json → ExamType</code> 判定盘查资格,只有 <code>ExamType=2</code> 的模板才会挂原生盘查流程(40431099 等 <code>ExamType=0</code> 的模板面板能开但按键无反应)。</p>
<div class="btns wide"><button class="primary" onclick="lixiPolice('npc', this)">生成盘查测试 NPC(4065053)</button><button onclick="lixiPolice('examine', this)">启动原生盘查(最近测试 NPC)</button><button onclick="lixiPolice('end-examine', this)">结束盘查 / 解除本次互动</button></div>
<p>盘查走客户端 S002 原生流程,已接搜身/询问回复,但实际界面与双人动画仍需验证;卡住请「结束盘查」,不会删除 NPC 或召回支援。罚单判定、押送结案、直升机搜查未完整恢复,不发放虚构奖励。机械巡卫犬 / 武装支援本次未移植(依赖 HackerDevices 与友方随从子系统)。</p>
<pre id="lixiOut" role="status" aria-live="polite"></pre>
<div class="form-grid"><div class="form-field half"><b>🚓 押送(押上警车 / 押下车)</b></div><div class="form-field half"><label>犯人 UID<input id="escortUid" placeholder="上面盘查刷出来的那个" style="width:160px"></label></div><div class="form-field half"><label>车辆 UID<input id="escortVehicle" placeholder="GM 召唤的警车" style="width:160px"></label></div><div class="form-field half"><label>座位<input id="escortSeat" value="1" style="width:44px" title="0=主驾;犯人坐 1..座位数-1"></label></div><div class="btns wide"><button onclick="escortBtn('board')">押上车</button><button onclick="escortBtn('alight')">押下车</button></div></div>
<div class="form-grid"><div class="form-field half"><b>🚔 牢房(收押 / 带出)</b></div><div class="form-field half"><label>UID<input id="prisonUid" placeholder="收押时填;带出不用" style="width:160px"></label></div><div class="btns wide"><button onclick="prisonBtn('commit')">收押进牢房</button><button onclick="prisonBtn('release')">带出到门口</button><button class="ghost" onclick="prisonStatus()">刷新</button></div></div>
<pre id="prisonOut"></pre>
<details class="help"><summary>牢房这条链能做到哪一步 / 怎么验</summary>
<p><b>能做的</b>:把服务端账本里的 NPC(行人 / 地盘敌人 / 功能 NPC)移进牢房内坐标、
停掉它的 AI、改写服务端账本;带出时移回门口并按原值恢复 AI。<br>
<span class="warn">⛔ 做不了的</span>:① <b>门口的 F 交互按钮</b> —— 捐赠端靠
<code>SyncDoToLuaMaster</code>(45437825) 的 <b>GATE Lua 通道</b>下发
<code>InteractBtnMgr:AddBtn</code> —— <b>这条通道主线已经搭好了</b>
(见<b>「街头篮球」卡</b>里的「🧪 客户端 Lua(GATE 下发)」,10-04 已从本卡搬过去),
但按钮脚本本身还没接;② <b>原生押送过场</b>
—— 要客户端 <code>AskPoliceEscortInteract</code> 加捐赠端那整套
<code>PoliceRuntime</code> 状态机,主线没有。<br>
⇒ 所以入口就是<b>这里这两个按钮</b>(<code>POST /api/police/prison</code>)。<br>
<b>怎么用</b>:① 切成里希、回<b>主城</b>(实例 20001222);② 走到警局牢房门口
<b>4 m 内</b>(X 2379.89 / Z 1823.08);③ 用上面「生成一个 NPC 并盘查」刷个人,
把它的 uid 填进左边;④ 点「<b>收押进牢房</b>」。<br>
<b>怎么验</b>:服务端日志搜 <code>[POLICE-PRISON]</code>;本页「刷新」看
<code>当前收押 uid</code> 与 <code>离门口</code>。</p></details>
<details class="help"><summary>这条链怎么走 / 怎么用 / 怎么验</summary>
<p><b>主入口(推荐)</b>:切成<b>里希</b>(FightSpiritId <code>15021021</code>)→
点「<b>生成一个 NPC 并盘查</b>」⇒ 服务端在你正前方 3.5 m 生成一个市民
(默认 <code>40954013</code> 咖啡店店员,<code>EnemyClassType=9</code> 非敌对),
等 1.2 s 让客户端把单位建好,然后直接对它开盘查面板。<b>再点一次会先回收上一次生成的那个</b>。<br>
⛑ 为什么这条路最靠谱:<b>uid、坐标、时机全在服务端手里</b>,不依赖客户端任何事件 ⇒ 点一下必然出结果。<br>
<span class="warn">⛔ 退出只能用这里</span>:面板里<b>长按 F / ESC 都不做退出</b>了 ——
四版客户端按键钩子全部实机失败(12:2x 拍板放弃),游戏内钩子已停用。
⇒ 要关面板就点「<b>强制关闭面板</b>」,服务端会<b>连发两帧</b>收盘查帧防丢包。<br><br>
<b>怎么用(备选:扫描模式)</b>:① 切成<b>里希</b>;② 勾上面「<b>扫描模式</b>」;
③ 回游戏走到人多的地方 ⇒ 客户端每扫到一个附近的人就开盘查面板。<br>
<span class="warn">⛔ 关键事实</span>:<code>cmd=33</code> 的 <code>s1</code>
<b>不是"你准星对着的那个人"</b> —— 10:46:37 实机取证:那批 s1 是连号 13 个、每秒约 3 个,
玩家并没在瞄准谁。它是<b>客户端批量上报附近实体</b>。<br>
⇒ 所以"点一下装备 → 等你聚焦"这个模型<b>在协议层不成立</b>。现在改成由这个开关控制开/关。<br>
<span class="warn">⛔ 一次性</span>:弹一次后聚焦模式<b>自动关闭</b>,再想盘查必须再点一次按钮;
60 秒内没用上也自动失效(防"陈年武装")。<br><br>
<b>为什么不是按 F / 按 R / 说话</b>:
· <b>按 F</b> —— <code>AskPoliceExamInteract(63233964)</code> 实机 <b>0 条</b>;街头 NPC 没有交互位。
· <b>按 R</b> —— 客户端钩键两次实机都没打通,代码还在但别指望它。
· <b>说话(cmd=34)</b> —— 它同时是交互心跳,会周期自动发 ⇒ "有时候触发有时候不触发"。<br>
<b>召唤物不当盘查对象</b>:机械狗 / 无人机 / 蜘蛛的 uid 判成 <code>kind=summon</code> ⇒ 不开面板。<br>
<b>怎么验</b>:服务端日志搜 <code>[EXAM-AUTO]</code>;客户端 <code>Player.log</code>(grep 要 <code>-a</code>)搜 <code>[DRMK-EXAM]</code>
看 <code>SyncAgentPoliceExamData … opening POLICE_INQUIRY_PANEL</code>。</p></details>
</div>
<div class="card md" id="basketballCard"><h2><i class="ci" data-ic="play"></i>街头篮球</h2>
<p>B 档 QTE 投篮小游戏(<code>BasketballGameManager.CreateGameCs</code>):在玩家脚下开一局,
球架与双方模型全由<b>客户端自己</b>生成。<br>
<b>⛑ 篮筐</b>:服务端自动刷篮筐的功能<b>已删除</b>(10-04),现在场上只有判定点
(锚点 +<code>(0,+2.98,+1.61)</code>),投的是「虚空」。<br>
<b>⛑ 对手红铁桶(42071029)</b>:模型<b>其实加载成功</b>,但 <code>body</code> /
<code>body01</code> 两个 <code>SkinnedMeshRenderer</code> 是 <code>enabled=false</code>
⇒ 预设里已用 <code>coroutine.start</code> + <code>coroutine.wait</code> 在开局后
<b>1.5 s</b> 起轮询点亮,<code>Player.log</code> 搜
<code>[DRMK] basketball npc poll</code>。</p>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="basketballGame('start')">开始游戏</button><button onclick="basketballGame('stop')">结束游戏</button></div></div>
<pre id="basketballOut" role="status" aria-live="polite"></pre>
<details class="help"><summary>街头篮球怎么开 / 怎么验 / 坑在哪</summary>
<p><b>开一局</b>:点上面的「<b>开始游戏</b>」即可,在玩家<b>脚下</b>开局。
一局约 <b>14 秒</b>(00:0x 实测:开局到 <code>TriggerBasketballFinish</code> 只有 14 s)。<br>
<span class="warn">⛔ 别再看下拉</span>:GATE Lua 下拉里的「④ 街头篮球 — 在脚下开一局 /
⑤ 街头篮球 — 收摊」两项 <b>10-04 已移除</b>,现在只用本卡这两个按钮。<br>
<b>收摊</b>:点上面的「<b>结束游戏</b>」,只 <code>DestroyGame</code>
(自动刷篮筐已删,不再扫场景收 gadget)。<br><br>
<b>怎么验</b>:客户端 <code>Player.log</code>(grep 要 <code>-a</code>)搜
<code>[DRMK] basketball start</code> = 开局脚本到了;<code>[DRMK] basketball npc poll1 n=2
turned=2 off=0</code> = 对手已点亮。<br><br>
<span class="warn">⛔ 三个已证实的坑</span>:<br>
① <b>别信「模型没加载」</b> —— 模型一直在,只是网格被关;判据是
<code>SkinnedMeshRenderer.enabled</code>,不是节点存不存在。<br>
② <b>NpcNode 下 npc1..npc15 是空占位</b>,<code>children=0</code> 是<b>正常</b>的;
模型挂在 <code>baseUnit.ModelSlot.transform</code>,按篮架 <code>slotPosition</code> 摆位。<br>
③ <b>面板页面会被浏览器缓存</b> —— 改完面板必须 <b>Ctrl+F5</b>,否则跑的还是旧 JS
(判据:新 hook 不触发、旧 hook 触发)。<br>
<b>④ Lua 延时用 <code>coroutine.start</code> + <code>coroutine.wait</code></b>(实测可用);
裸 <code>wait</code> / <code>UpdateBeat.Add</code> 在本版是 nil。</p></details>
</div>
</div>
<div class="col-stack s4">
<div class="card md"><h2><i class="ci" data-ic="gun"></i>不要抬头!(搜打撤)</h2>
<div id="exstate" class="hint" role="status" aria-live="polite">加载中…</div>
<div class="form-grid"><div class="form-field half"><label class="cb"><input type="checkbox" id="exFakeMatch" onchange="exOptions()"><span>伪造匹配(游戏里点「开始」直接进图)</span></label></div><div class="form-field half"><label>目标 raid</label><select id="exRaid" onchange="exOptions()">
<option value="23301277">不要抬头!(23301277)</option>
<option value="23301215">终局之战 (23301215)</option>
<option value="23301311">古墓摸金 (23301311)</option>
</select></div></div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="exStart()">进图</button><button onclick="exExit()">退出副本回主城</button><button onclick="exRepublishLogin()">⟳ 重推登录帧</button></div></div>
<div style="margin:8px 0; padding-top:8px; border-top:1px dashed #333">
<div class="form-grid"><div class="form-field half"><label>下发方式</label><select id="exWheelSync" onchange="exOptions()">
<option value="full">full · 每次变动重发整表(默认)</option>
<option value="incremental">incremental · 只发变化的格</option>
<option value="off">off · 不下发(等同修复前)</option>
</select></div><div class="form-field half"><label class="cb"><input type="checkbox" id="exWheelData" onchange="exOptions()"><span>轮盘物品带 WeaponData</span></label></div><div class="btns wide"><button onclick="exWheelRepush()">⟳ 重推武器轮盘</button></div></div>
<div id="exWheelBox" style="max-height:220px; overflow:auto"></div>
</div>
<div style="margin:8px 0; padding-top:8px; border-top:1px dashed #333">
<div class="hint">任意 notify 探针(取证用)—— 直接把一条裸包按 methodId 推给客户端,hex 留空 = 推空体</div>
<div class="form-grid"><div class="form-field half"><label>methodId</label><input id="exNotifyMid" type="number" placeholder="68820459" style="width:120px"></div><div class="form-field half"><label>hex</label><input id="exNotifyHex" type="text" placeholder="可留空" style="width:280px;font-family:monospace"></div><div class="btns wide"><button onclick="exNotify()">推一条</button></div></div>
</div>
<pre id="exres"></pre>
<details class="help"><summary>说明</summary>
<p>整套<b>默认关</b>:<code>config/private-server.json</code> 里 <code>world.content.extraction.enabled=false</code>。
关着的时候所有搜打撤相关 handler 一律 ack-only 跳过。</p>
<p><b>进图两条路</b>:① 勾「伪造匹配」后在游戏里点开始(⛑ 开着时<b>所有</b>开局都会进来);② 直接点「进图」。两者走同一个 <code>SwitchSceneAsync</code>。</p>
<p><b>武器轮盘</b>:下标 0 = 拳头、1 = 私有武器、<b>下标 = CellX + 2</b>(第一格是 2)。
整表恒 9 槽(2 + 7),空槽必须是 null,下标 0 也不能空 —— 空了就切不回拳头。</p>
<p><b>验收顺序</b>:进图 → 开一个容器(应有按品质长短不同的读条)→ 把枪拖进背包 → 右键装备 →
看这张表里多出一行 → 游戏里 Tab / 滚轮能依次切到。服务端日志应有 <code>[EXTRACT] wheel snapshot pushed … slots=9</code>。</p>
<p class="warn">还没做的:① 撤离点负载没抓到包;② 结算帧只有 32 字节的空战斗数据;③ 容器内容不落盘,重登一天重洗。</p></details>
</div>
<div class="card md" id="volleyballCard"><h2><i class="ci" data-ic="play"></i>排球</h2>
<p>C 档 <b>零协议</b>小游戏(<code>VolleyballGameManager.CreateLuaGameCs</code>):在玩家脚下开一局,
<b>球场、球网、球、相机、4 个角色</b>(玩家 + 我方 AI + 对方 AI×2)全部由客户端 prefab 生成,
服务端<b>一个协议都不用发</b>,也不需要世界里摆任何 gadget / waypoint / NPC 实体。<br>
赛制:<b>6 分制</b>、需净胜 2 分;扣杀 / 传球走 QTE(完美 / 正常 / 过早 / 过晚 / 假动作五档)。<br>
<span class="warn">⛔ 4 个角色是「替换形象」</span>:本版客户端排球原生的 modelId
(<code>86952591~94</code>)<b>建不出模型</b> —— Lua 版
<code>GetDialogModel(modelId)</code> 与原生版
<code>GetCutSceneModel→LoadModel</code> <b>两条路都 NRE</b>。
预设已改走 <code>GetDialogModelByAgentId</code>,4 个角色<b>统一用玩家自己的形象</b>
(队伍靠 <code>curTeam</code> 和站位区分)。<br>
⛑ 试过给 4 个角色分不同形象(玩家 / Garm / Rin / Bansy),但只有玩家自己那个能建成
⇒ <code>loadFinishCount</code> 到不了 4 ⇒ <code>OnAllLoaded</code> 不触发
⇒ <b>局根本开不起来</b>,所以不再分。</p>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="volleyballGame('start')">开始游戏</button><button onclick="volleyballGame('stop')">结束游戏</button></div></div>
<pre id="volleyballOut" role="status" aria-live="polite"></pre>
<details class="help"><summary>排球怎么开 / 怎么验 / 坑在哪</summary>
<p><b>开一局</b>:点上面的「<b>开始游戏</b>」,在玩家<b>脚下</b>开局。<br>
<b>收摊</b>:点「<b>结束游戏</b>」。球场是 prefab 实例,
<code>DestroyLuaGame()</code> 会连带 <code>Destroy(rootNode)</code>,
<b>不像篮球那样需要额外收篮筐</b>。<br><br>
<b>怎么验</b>:客户端 <code>Player.log</code>(grep 要加 <code>-a</code>)搜
<code>[DRMK] volleyball start</code> = 脚本到了;
<code>[DRMK] volleyball poll1 game ok chars=4</code> = 球场已生成、4 个角色加载完。<br>
· <code>mgr=nil</code> ⇒ 客户端 lua 里没有 <code>gVolleyballGameMgr</code>(该玩法没打包进 4229938)。<br>
· 一直 <code>no game yet</code> ⇒ prefab
<code>VolleyballGameRootNodeCopy.prefab</code> 加载失败。<br><br>
<span class="warn">⛔ 两个已证实的坑</span>:<br>
① <b>参数必须传位置数组</b> —— 排球内部是
<code>Vector3.New(unpack(rootPosition))</code>,传
<code>{x=..,y=..,z=..}</code> 具名表会 unpack 出 nil ⇒ 直接报错。
篮球那边是 <code>position.x</code> 具名访问所以能过,<b>别把篮球的写法复制过来</b>。<br>
② <b>结算发不出去</b> —— 正常打完会发
<code>SetChallengeResult(63571710)</code>,主线
<code>Ananta.Handlers</code> 里没有这个 handler ⇒ 掉
<code>unknown-methods.log</code>。画面不受影响。</p></details>
</div>
</div>
</div>
</section>
<section class="sec" id="sec-task">
<div class="sec-head">
<span class="ic" data-ic="film"></span>
<h1>任务和动画播放</h1><span class="count">3 张卡</span>
</div>
<div class="grid">
<div class="card md"><h2><i class="ci" data-ic="play"></i>过场动画/时间轴播放</h2>
<div class="form-grid"><div class="form-field full"><label>搜索</label><input id="tlfilter" type="text" placeholder="按名称或分组筛选…" oninput="filterTimelines()" style="flex:1 1 190px"></div></div>
<div class="form-grid"><div class="form-field full"><label>时间轴</label><select id="tlsel" style="flex:1 1 190px"><option value="">— 由时间轴表填充 —</option></select></div></div>
<div id="tlinfo" class="hint" role="status" aria-live="polite"></div>
<button class="primary" onclick="playTimeline()">播放过场</button>
<pre id="tlres"></pre>
<details class="help"><summary>说明</summary>
<p class="warn">过场由客户端自己播(NPC、摄像机、视频都在游戏里)。播不出来多半是这条时间轴要求特定场景/位置,先传送过去再试。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="swap"></i>切人演出(按动画)</h2>
<div class="form-grid">
<div class="form-field full"><label>搜索演出</label><input id="shwfilter" type="text" placeholder="按地点 / 动作 / timeline 名筛选,例如 樱花大道 / 送人下车" oninput="renderSwitchShows()" style="flex:1 1 190px"></div>
</div>
<div class="form-grid">
<div class="form-field full"><label>范围</label><select id="shwkind" onchange="renderSwitchShows()" style="flex:1 1 190px"><option value="playable">可播(默认,排除带随从的行)</option><option value="all">全部 70 条(含带随从的 1 条)</option></select></div>
<div class="form-field full"><label>切成(目标角色)</label><select id="shwtarget" style="flex:1 1 190px"><option value="">— 先选上面的演出 —</option></select></div>
</div>
<div class="form-grid"><div class="form-field full"><label>切人动画</label><select id="shwsel" onchange="onSwitchShowPick()" style="flex:1 1 190px"><option value="">— 由切换演出表填充 —</option></select></div></div>
<div id="shwinfo" class="hint" role="status" aria-live="polite"></div>
<div class="row">
<button class="primary" onclick="playSwitchShow()">触发切人动画</button>
</div>
<pre id="shwres"></pre>
<details class="help"><summary>说明</summary>
<p>数据源是 <code>SwitchSpiritConfig.json</code>,<b>一行 = 一出切人演出</b>
—— 这才是官方的粒度:主下拉是动画(地点 · 动作 · timeline),第二个下拉才是「切成哪个角色」。</p>
<p>源文件 201 行,但 <code>ClientConfigRepository</code> 装载时按
<code>SwitchType=2 / RaidId=23300888 / 未作废 / Weight&gt;0 / TimeLine 非空</code>
过滤掉 131 条 ⇒ <b>这里能列出的是 70 条</b>(其中 1 条带随从,服务端补不出 ⇒ 默认范围排除)。</p>
<p>走 <code>SyncPreSwitchSpirit</code> 两段式:先推这条演出的配置,等客户端播完回报
<code>ReportPreSwitchSpiritFinish</code> 再真正换人,并落到该行的 <code>Position</code>
锚点(演出本身就有镜头位移,所以会跟着走到那里)。</p>
<p class="warn">① 目标角色下拉由这条演出的 <code>FightSpiritId</code> 填充 ——
<b>不是每个角色都能播每一条</b>,官方是按这种搭配编写的。选不到某个角色是正常的。<br>
② 「随从行」(<code>AgentId</code> 非空)服务端这条链路补不出陪衬角色,默认范围已排除;
切成后只是演出里少几个 NPC,不会崩。<br>
③ 演出会把你传送到它的锚点,可能几百米外 —— 这是原版行为。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="pad"></i>剧情对话 / 台词播放</h2>
<div class="form-grid">
<div class="form-field full"><label>搜索台词</label><input id="dlgfilter" type="text" placeholder="按说话人 / 台词内容 / ID 筛选,例如 塔菲" oninput="loadStoryDialogs()" style="flex:1 1 190px"></div>
</div>
<div class="form-grid">
<div class="form-field full"><label>只看带镜头的过场</label><select id="dlgcut" onchange="loadStoryDialogs()" style="flex:1 1 190px"><option value="">全部台词</option><option value="1">只看过场</option><option value="0">只看纯台词</option></select></div>
</div>
<div class="form-grid"><div class="form-field full"><label>台词</label><select id="dlgsel" onchange="onDlgPick()" style="flex:1 1 190px"><option value="">— 由台词索引填充 —</option></select></div></div>
<div id="dlginfo" class="hint" role="status" aria-live="polite"></div>
<div class="row">
<button class="primary" onclick="playStoryDialog()">播放台词(含镜头)</button>
<button onclick="playStoryDialogVoice()">只播语音</button>
</div>
<pre id="dlgres"></pre>
<details class="help"><summary>说明</summary>
<p>走 <code>SyncShowDialog</code>(64967628):服务端只发 <code>dialogId</code> +
<code>beginDialog</code> + 全零 <code>param</code>,<b>文本、镜头、语音都在客户端自己的
DialogConfig 里</b>,服务端不搬内容。索引共 <b id="dlgtotal">—</b> 条,其中带镜头过场
<b id="dlgcut2">—</b> 条。</p>
<p class="warn">索引里查不到的 id 也照样能下发(客户端有自己的表)。如果点了没反应,
多半是这条台词要求特定任务/场景上下文。</p></details>
</div>
</div>
</section>
<section class="sec" id="sec-traffic">
<div class="sec-head">
<span class="ic" data-ic="car"></span>
<h1>人流和车流</h1><span class="count">2 张卡</span>
</div>
<div class="grid">
<div class="card lg"><h2><i class="ci" data-ic="car"></i>车流调参(即时生效 · 免重启)</h2>
<div class="form-grid"><div class="form-field full"><label>预设</label><select id="tfPreset" onchange="applyTrafficPreset()">
<option value="">— 一键预设 —</option>
<option value="busy">热闹:24 台 · 开得快</option>
<option value="smooth">顺畅:少车 · 几乎不避让</option>
<option value="cautious">谨慎:早发现前车 · 少撞车</option>
<option value="unjam">防堵死:更早解锁 · 更早让行</option>
</select></div></div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="saveTraffic()">保存改动</button><button onclick="loadTraffic()">重新载入</button><button onclick="undoTraffic()">撤销未保存</button></div></div>
<div><span id="tfPath" style="font-size:11px;color:#8b96a2"></span></div>
<div id="tfList" style="margin-top:10px"></div>
<pre id="tfRes"></pre>
<details class="help"><summary>说明</summary>
<p>改完点「保存改动」:写回 <b>正在使用的那份</b> private-server.json(会自动同步一份到
<code>Ananta.Server/Ananta.App/bin/Debug/net8.0/config/</code>)并热重载,<b>不用重启服务端</b>,
车流下一 tick 就按新值跑。带 <span class="warn">⚠</span> 的项改坏了会让车不动或满街撞,改动前先看括号里的提示。</p></details>
</div>
<div class="card lg"><h2><i class="ci" data-ic="users"></i>人流调参(即时生效 · 需手动推送)</h2>
<div id="cfNow" class="hint" role="status" aria-live="polite">加载中…</div>
<div class="form-grid"><div class="form-field full"><label>预设</label><select id="cfPreset" onchange="applyCrowdPreset()">
<option value="">— 一键预设 —</option>
<option value="quiet">冷清:全城减半</option>
<option value="busy">热闹:全城翻倍</option>
<option value="even">抹平差异:不看地块</option>
</select></div></div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="saveCrowd()">保存改动</button><button onclick="loadCrowd()">重新载入</button><button onclick="undoCrowd()">撤销未保存</button><button class="primary" onclick="pushCrowd()">立即推送密度</button></div></div>
<div><span id="cfRes" style="font-size:11px;color:#8b96a2"></span></div>
<div id="cfList" style="margin-top:10px"></div>
<div id="cfTypes" style="margin-top:8px"></div>
<details class="help"><summary>说明</summary>
<p><b class="warn">★ 改完必须点「立即推送密度」</b>:人群<b>没有 tick 循环</b>,只在「进世界」和「移动
&gt;35 米」推包,原地改配置是没有第二个时机让它生效的。<br>
⛔ <b>硬前置</b>:<code>trafficEnabled</code>(车流总开关)必须是 <code>true</code> ——
<code>HasZoneGraph=true</code> 是走车辆 init 那条线发的,车流关着人群根本不会初始化。<br>
上方读数显示你脚下<b>此刻会算出</b>的密度,改参数后不用走到 35 米,点右边「重新载入」就能看到目标值怎么变。<br>
<code>crowdDensity</code> 超过 1 会不会被客户端 clamp 未验证,别一次拉到 4 以上。</p></details>
</div>
</div>
</section>
<section class="sec" id="sec-spawn">
<div class="sec-head">
<span class="ic" data-ic="user"></span>
<h1>生成和铺设NPC</h1><span class="count">7 张卡</span>
</div>
<div class="grid">
<div class="card md"><h2><i class="ci" data-ic="sprite"></i>生成NPC(角色)</h2>
<div class="form-grid"><div class="form-field full"><label>角色</label><select id="npsel" onchange="onNpcSelect()" style="flex:1 1 190px">
<option value="">— 选择角色 —</option>
<option value="40140030">40140030 辰宿</option>
<option value="40140031">40140031 昭昭</option>
<option value="40140032">40140032 嘉姆</option>
<option value="40140033">40140033 凛</option>
<option value="40140034">40140034 班茜</option>
<option value="40140035">40140035 塔菲</option>
<option value="40140036">40140036 狄菈</option>
<option value="40140037">40140037 亚兰</option>
<option value="40140038">40140038 梅卡妮卡</option>
<option value="40140039">40140039 蒂娜</option>
<option value="40140040">40140040 里希</option>
<option value="40140041">40140041 林檎</option>
<option value="40140042">40140042 赛默</option>
<option value="40140043">40140043 爱琳</option>
<option value="40140044">40140044 莱卡娅</option>
<option value="40140045">40140045 黛西贝尔</option>
<option value="40140046">40140046 响</option>
<option value="40140048">40140048 弥弥古</option>
<option value="40140049">40140049 伯纳德</option>
<option value="40140053">40140053 艾诺米</option>
<option value="40140054">40140054 白靖</option>
<option value="40140055">40140055 璎珑</option>
<option value="40140056">40140056 时夜</option>
<option value="40140077">40140077 柚枝</option>
<option value="40140148">40140148 方野</option>
<option value="40140158">40140158 午马</option>
<option value="40155001">40155001 程酿</option>
<option value="40155002">40155002 陆辛夷</option>
<option value="40155013">40155013 李扶风</option>
<option value="40155051">40155051 梦核男主</option>
<option value="40155061">40155061 幼年塔菲</option>
</select></div></div>
<div class="form-grid"><div class="form-field full"><label>或输入ID</label><input id="npcustom" type="number" min="1" step="1" placeholder="自由输入ID(如 15021042 / 40140076)" style="flex:1 1 190px"></div></div>
<div class="form-grid"><div class="form-field half"><label>X</label><input id="nx" type="number" step="any" value="0" placeholder="0=玩家前方3.5m"></div><div class="form-field half"><label>Y</label><input id="ny" type="number" step="any" value="0"></div><div class="form-field half"><label>Z</label><input id="nz" type="number" step="any" value="0"></div><div class="form-field half"><label>Facing</label><input id="nfa" type="number" step="any" value="0"></div></div>
<button class="primary" onclick="spawnNpc()">召唤角色</button>
<button onclick="fillNpcCurrent()">填入当前位置</button>
<pre id="nres"></pre>
<details class="help"><summary>说明</summary>
<p>生成的是<b>独立 NPC 实体</b>,不接管玩家控制。没出现就查服务端日志 <code>[NPC] spirit-spawn</code>。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="users"></i>生成市民(非敌对单位)</h2>
<div class="hint">坐标取玩家当下站位/朝向,<b>贴地、带完整受击反馈</b>。</div>
<div class="form-grid"><div class="form-field full"><label>搜索</label><input id="ofilter" type="text" placeholder="按名称或ID筛选…" oninput="filterAg('o')" style="flex:1 1 190px"></div></div>
<div class="form-grid"><div class="form-field full"><label>市民</label><select id="osel" style="flex:1 1 190px"><option value="">— 由 /api/agents 填充 —</option></select></div></div>
<div class="form-grid"><div class="form-field full"><label>或输入ID</label><input id="ocustom" type="number" min="1" step="1" placeholder="自由输入 Agent ID" style="flex:1 1 190px"></div></div>
<button class="primary" onclick="agLocal('o')">生成市民</button>
<pre id="ores"></pre>
<details class="help"><summary>说明</summary>
<p>同名同模型的重复项已折叠为最小 ID,在「或输入ID」填被折叠的 ID 可精确生成。<b>本地生成不读坐标</b>——直接用玩家脚下位置,不会浮空。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="users"></i>街头市民(预设整组)</h2>
<div id="cvstate" class="hint" role="status" aria-live="polite">加载中…</div>
<div class="form-grid"><div class="form-field full"><label>筛选题材</label><input id="cvfilter" type="text" placeholder="按题材筛选:居民 / 坐姿 / 学校 / 夜店 …" oninput="filterCivGroups()" style="flex:1 1 190px"></div></div>
<div class="form-grid"><div class="form-field full"><label>分组</label><select id="cvgroup" onchange="loadCivGroup()" style="flex:1 1 190px"><option value="">— 由分组索引填充 —</option></select></div></div>
<div class="form-grid"><div class="form-field full"><label>间距(米)</label><input id="cvspacing" type="number" min="1" max="20" step="0.1" value="2.6" style="width:100px"></div></div>
<button class="primary" onclick="spawnCivilians()">添加整组</button>
<pre id="cvres"></pre>
<details class="help"><summary>查看该组市民清单(最多 60 个)</summary><pre id="cvlist"></pre></details>
<details class="help"><summary>说明</summary>
<p><b>生成位置</b>:玩家当前站位前方 3 米起的一块方阵,槽位随机打乱,想摆在哪条街上就走到那条街再点。<br>
<b>★ 系统行为</b>:排在最后十几组的是对话 / 伙伴 AI / 阵营交互之类的系统动作,刷出来大概率没反应或姿势怪,属正常现象。<br>
<b>不持久化</b>:重登 / 换场景这批市民由客户端自行清掉,想再来一批重新点即可。</p></details>
</div>
<div class="card sm"><h2><i class="ci" data-ic="pin"></i>按地块铺市民(真实街区)</h2>
<div id="cdstate" class="hint" role="status" aria-live="polite">加载中…</div>
<div class="form-grid"><div class="form-field third"><label>半径(米)<input class="num" id="cdRadius" type="number" min="20" max="600" step="10" value="150"></label></div><div class="form-field third"><label>人数上限<input class="num" id="cdMax" type="number" min="1" max="600" step="10" value="120"></label></div><div class="form-field third"><label>时段<select id="cdHour"><option value="-1">跟随游戏时钟</option></select></label></div></div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="spawnDistrictCivilians()">在这周围铺人</button></div></div>
<pre id="cdres"></pre>
<details class="help"><summary>说明</summary>
<p>按城市里<b>真实的地块</b>(住宅区 / 商业区 / 学院区…)把市民放到<b>人行道上</b>,而不是排在角色面前。<br>
「半径」是离你多远以内的地块参与;人多时按距离由近到远分配,远的会先被砍掉。<br>
「时段」决定每个地块放多少人——数据来自该地块自带的一天 24 小时人流曲线。选「跟随游戏时钟」就用当前游戏里的时间。<br>
想先看效果可以把时段固定成 <b>18</b>。占地块一样:<b>不持久化</b>。</p></details>
</div>
<div class="card sm"><h2><i class="ci" data-ic="ticket"></i>功能 NPC(店员 / 售票 · 固定点位)</h2>
<div id="fnstate" class="hint" role="status" aria-live="polite">加载中…</div>
<div class="form-grid"><div class="form-field full"><label>半径(米,0 = 整张图)<input class="num" id="fnRadius" type="number" min="0" max="600" step="50" value="150"></label></div></div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="spawnFunctionalNpcs()">铺设功能 NPC</button></div></div>
<pre id="fnres"></pre>
<details class="help"><summary>说明</summary>
<p>把<b>带 F 交互</b>的 NPC 放到<b>它本来该在的位置</b>:店员站在自己店门口,摩天轮售票员站在摩天轮下。<br>
<b>默认不用点</b>:进城后跟随循环自己跑 —— 走近 130 m 自动出现、走远 230 m 自动撤走,全程无需操作。<br>
坐标来自 <code>ShopConfig.Position</code> / 城市玩法点 / 室内场景锚点,并已吸附到最近的人行道站位。<br>
<b>服务端只负责把人放下去</b> —— 按 F 之后干什么是<b>客户端 lua</b> 的事,服务端不参与、也不扣钱。<br>
点位文件:<code>ClientData/4229938/Configs/FunctionalNpcSpots.json</code>,手动改坐标即时生效。</p></details>
</div>
<div class="card sm"><h2><i class="ci" data-ic="skull"></i>地盘敌人(帮派势力 / 营地)</h2>
<div id="etstate" class="hint" role="status" aria-live="polite">加载中…</div>
<div class="form-grid"><div class="form-field full"><label>半径(米,0 = 默认 90 m)<input class="num" id="etRadius" type="number" min="0" max="600" step="50" value="90"></label></div></div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="spawnEnemyTerritory()">铺设地盘敌人</button></div></div>
<pre id="etres"></pre>
<details class="help"><summary>说明</summary>
<p>把帮派分子放到<b>官方地盘坐标</b>上 —— 犬魔社、谜面帮、电视脑、黑金组、龙治团、焱风联合会…<br>
<b>默认不用点</b>:进城后跟随循环自己跑,走近 90 m 出现、走远 160 m 撤走。<br>
<b>坐标来源</b>:<code>FactionInfluenceAreaConfig</code> 的 24 个地盘 × <code>FactionAreaPointConfig</code> 的 85 个角点,
Y 吸附到最近的人行道站位(dump 只给了 XZ)。<br>
<b>行为</b>:走 <code>SpawnAgentAsync</code>,<code>forbidAetherAI=true</code> ⇒ 出生后由<b>客户端行为树</b>接管,
所以它们<b>不像市民那样沿人行道走</b>,而是在地盘附近待机、玩家靠近才进入战斗。<br>
想让城市「有人晃但不追着你打」:配置 <code>Aether.EnemyTerritoryEngage=false</code>。<br>
点位文件:<code>Configs/EnemyTerritorySpots.json</code>,改完重启服务端生效。</p></details>
</div>
<div class="card sm"><h2><i class="ci" data-ic="cpu"></i>批量生成氛围NPC</h2>
<div class="hint">一行一个 NPC:<b>AgentConfig ID</b>,后面可选跟一个 <b>POI 动作 ID</b>(可自行拖动调整高度)。</div>
<textarea id="dnpcItems" rows="3" placeholder="40130001, 0" spellcheck="false" style="display:block;width:100%;height:68px;box-sizing:border-box;resize:vertical;overflow:auto;font-family:Consolas,monospace">40130709, 2</textarea>
<div class="form-grid"><div class="form-field third"><label>X spacing</label><input id="dnpcXSpacing" type="number" min="0" step="any" value="1.5"></div><div class="form-field third"><label>Z spacing</label><input id="dnpcZSpacing" type="number" min="0" step="any" value="1.5"></div><div class="form-field third"><label>Max per row</label><input id="dnpcMaxPerRow" type="number" min="1" step="1" value="10"></div></div>
<button id="dnpcSpawn" class="primary" onclick="spawnDonorNpcs()">生成氛围NPC</button>
<div class="hint">NPC 摆在你面前的玩家相对网格里。</div>
<pre id="dnpcResult"></pre>
<details class="help"><summary>说明</summary>
<p class="warn">没出现就查服务端日志:要有 <code>[AETHER] donor init sent</code> 和 <code>[NPC] STATIC_PEDESTRIAN_SPAWN_SENT</code> 两条才算通。</p></details>
</div>
</div>
</section>
<section class="sec" id="sec-transit">
<div class="sec-head">
<span class="ic" data-ic="bus"></span>
<h1>公共交通</h1><span class="count">1 张卡</span>
</div>
<div class="grid">
/*metro-card*/
</div>
</section>
<section class="sec" id="sec-misc">
<div class="sec-head">
<span class="ic" data-ic="box"></span>
<h1>杂项功能</h1><span class="count">4 张卡</span>
</div>
<div class="grid">
<div class="col-stack">
<div class="card md"><h2><i class="ci" data-ic="debug"></i>玩家状态(每2秒刷新)</h2>
<div id="status">连接中…</div>
<details class="help"><summary>字段说明</summary>
<p>位置 / 朝向 · 单位 / 模板 / 就绪 / 变换 / 待传送 · 副本 / 实例 / 宇宙 · 载具 / 座位 · 会话 / 最后在线。<br>
由轮询 <code>/api/status</code> 得到,未连接时显示「无玩家会话(请先登录游戏客户端)」。</p></details>
</div>
<div class="card"><h2><i class="ci" data-ic="term"></i>客户端 Lua(GATE 下发)</h2>
<div class="hint">把整段 Lua 原样发到<b>客户端进程</b>里执行。结果只看客户端
<code>Player.log</code>(grep 要 <code>-a</code>)里的
<code>print_debug</code> / <code>print_error</code>。<br>
⛑ 街头篮球的预设已从本下拉移除 —— 那两个按钮在<b>「职业和独立玩法 → 街头篮球」</b>卡里。</div>
<div class="form-grid">
<div class="form-field third"><label>GATE</label><b id="gateLuaBadge" style="color:#ff7b72">—</b></div>
<div class="form-field two-third"><select id="gateLuaPreset" onchange="gateLuaPreset()">
<option value="">(选一个预设…)</option>
<option value="probe">① 探针 — 打印到 Player.log</option>
<option value="globals">② 探测客户端全局</option>
<option value="fbtn">③ 挂 F 按钮模板</option>
</select></div>
<div class="btns wide"><button class="primary" onclick="gateLuaRun()">执行</button><button class="ghost" onclick="gateLuaStatus()">刷新</button></div>
</div>
<textarea id="gateLuaScript" rows="9" spellcheck="false"
placeholder="在这里写 Lua,原样发到客户端进程里执行。结果看客户端 Player.log 的 print_debug / print_error。"></textarea>
<pre id="gateLuaOut"></pre>
<div class="form-grid">
<div class="form-field half"><label>回执探针</label><span class="gm-tip" style="opacity:.7">GATE 通不通的唯一硬判据</span></div>
<div class="btns wide"><button onclick="gateLuaProbe()">跑一次(服务端日志应出现 DrmkLuaRx)</button></div>
</div>
<div class="form-grid">
<div class="form-field half"><label>目标 NPC</label><select id="gateLuaUnit"><option value="">(点右边刷新加载)</option></select></div>
<div class="form-field half"><label>按钮文字</label><input id="gateLuaBtnText" value="里希盘查"></div>
<div class="btns wide"><button class="primary" onclick="gateLuaFBtn('add')">挂上 F</button><button onclick="gateLuaFBtn('remove')">摘掉 F</button><button class="ghost" onclick="gateLuaUnits()">刷新列表</button></div>
</div>
<div class="hint">⛑ 只对<b>服务端账本里</b>的 NPC 有效(GM 召唤 / 地盘敌人 / 功能 NPC)。
街上随机路人是<b>客户端本地实体</b>,服务端拿不到它们的 uid,挂不了。</div>
</div>
</div>
<div class="card md"><h2><i class="ci" data-ic="shield"></i>无敌模式</h2>
<div class="hint">两件事:① <b>解除控制状态</b> —— 被打成不能动 / 不能出招时解开;
② <b>锁定生命值</b> —— 照常掉血但最低保留 1 点,不会倒地不会死(默认开)。</div>
<div class="form-grid"><div class="btns wide"><button class="primary" onclick="unlockPlayer('curated')">解除控制状态</button><button onclick="unlockPlayer('sweep')">暴力清空</button></div></div>
<label class="sw"><input id="unlockauto" type="checkbox" checked onchange="setUnlockAuto(this.checked)">
<span class="track"></span><span>自动解除(敌人每次出手后 2.2s / 5s 各清一次)</span></label>
<pre id="unlockres"></pre>
<details class="help"><summary>说明</summary>
<p>根因:敌人施法是我们伪造的,命中与硬直由<b>客户端本地结算</b>(我们从不下发伤害),官服那一步由服务端在结算里解除,我们没人解除 → 永久卡住。<br>
「精选」发 24 个明确的控制/施法锁状态;若无效再用「暴力清空」。<br>
<b>自动解除</b>默认开启:客户端没有「玩家被击中」的上报包,只能从<b>发过施法指令</b>这一刻起算。</p></details>
<hr>
<label class="sw"><input id="lifelock" type="checkbox" checked onchange="setLifeLock(this.checked)">
<span class="track"></span><span>锁定生命值(不会死,但仍会掉血)</span></label>
<div class="form-grid"><div class="btns wide"><button onclick="refreshLifeLock()">刷新状态</button></div></div>
<pre id="lifelockres"></pre>
<details class="help"><summary>说明</summary>
<p>敌人每次施法命中由服务端结算并记账(<code>[PLAYER-HIT]</code>),扣的就是服务端权威血量,
<b>每次挨打 -100</b>(满血 1000,10 下倒地)。<br>
<b>「锁定生命值」只控制能不能归零</b>,不控制掉不掉血 —— 这是有意的:锁定状态下你依然能看到战斗反馈和血条压力。<br>
取消勾选后可归零 → 倒地 → 服务端计时 <b>8 秒</b>后自动回满并清除倒地状态(不走官服送医流程)。<br>
⛑ 只锁<b>生死</b>,不锁硬直 / 击退 —— 该被打断照样被打断,想解开用上面的解除按钮。</p></details>
</div>
<div class="card md"><h2><i class="ci" data-ic="car"></i>自动驾驶巡航速度</h2>
<div class="hint">调的是<b>服务端下发给 Cruise 任务的 MaxSpeed</b>(车自己开时的目标速度),不影响玩家手动驾驶。</div>
<div class="form-grid"><div class="form-field full"><label>速度 km/h</label><input id="adsKmh" type="number" step="0.1" value="43.2" style="flex:1 1 120px;width:auto;min-width:0"></div><div class="btns wide"><button onclick="loadAutoDriveSpeed()">读取</button><button class="primary" onclick="saveAutoDriveSpeed()">保存并应用</button></div></div>
<div id="adsState" class="hint" role="status" aria-live="polite"></div>
<pre id="adsRes"></pre>
<details class="help"><summary>说明</summary>
<p>值写进 <code>config/autonomous-driving.json</code>(<b>不动</b> <code>private-server.json</code>,删掉这个文件就回到主配置的 <code>cruiseSpeed</code>)。<br>
<b>正在自动驾驶时保存 = 热应用</b>:先 <code>Abort(4)</code> 掉旧巡航任务,再用新速度重建,<b>路线不变</b>;没在开车则下次开启生效。<br>
<code>已保存</code> 但写着「下次开启生效」是正常的 —— 那只说明当前没有在自驾的车。<br>
⛑ 若车速被道路限速压住,是 <code>DrivingFlags</code> 里的 <code>DFAdjustCruiseSpeedBasedOnRoadSpeed(16384)</code>
在起作用,把它从 <code>gameplay.vehicleNavigation.cruiseDrivingFlags</code> 里去掉(改成 187)即可。</p></details>
</div>
</div>
</section>
<section class="sec" id="sec-debug">
<div class="sec-head">
<span class="ic" data-ic="term"></span>
<h1>调试信息</h1><span class="count">1 张卡</span>
</div>
<div class="grid">
<div class="card full"><h2><i class="ci" data-ic="term"></i>控制台</h2>
<button onclick="loadUnknown()">刷新</button>
<pre id="u"></pre>
<details class="help"><summary>说明</summary>
<p>列出客户端发来、服务端没有对应 handler 的方法(<code>logs/unknown-methods.log</code> 的尾部)。
⛑ 日志里 0 次只能证明「客户端没发」,<b>不等于接住了</b>。</p></details>
</div>
</div>
</section>
</main>
</div>
<!-- 底栏(只剩在线状态 + 标语;原「在线玩家 / 版本 / 运行时间」三项已删) -->
<footer class="statusbar">
<span class="sb"><i class="dot"></i><b>Server Online</b></span>
<span class="tail">Better Control&nbsp; · &nbsp;A Greater Ananta</span>
</footer>
<!-- 右下角快捷菜单:默认常驻,9 个纯图标按钮(8 栏目 + 回到顶部) -->
<div class="dock" id="dock">
<div class="dock-panel">
<div id="dockItems"></div>
<div class="dock-sep"></div>
<div class="dock-item" onclick="toTop()" data-tip="回到顶部">
<svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
</div>
<script>
/* ── 外壳:栏目导航 / 侧栏 / 右下角快捷菜单 / 图标注入 / 按钮自适应 ────────── */
const ICONS = {
bolt: '<path d="M13 2.5 4.8 13.6H11l-1 7.9 8.2-11.1H12z" fill="currentColor" stroke="none"/>',
cal: '<rect x="3" y="4.5" width="18" height="16" rx="2.5"/><path d="M8 2.5v4M16 2.5v4M3 9.5h18"/>',
pad: '<rect x="2" y="6.5" width="20" height="11" rx="5.5"/><path d="M7 10v4M5 12h4"/><circle cx="15.5" cy="10.6" r="1.1" fill="currentColor"/><circle cx="18" cy="13.4" r="1.1" fill="currentColor"/>',
car: '<path d="M4.5 16.5V12l1.8-3.8h11.4L19.5 12v4.5"/><path d="M3 16.5h18"/><circle cx="7.6" cy="17.6" r="1.5"/><circle cx="16.4" cy="17.6" r="1.5"/>',
user: '<circle cx="12" cy="8" r="3.6"/><path d="M5 20.2c0-3.7 3.1-6.4 7-6.4s7 2.7 7 6.4"/>',
film: '<rect x="3" y="4.5" width="18" height="15" rx="2.5"/><path d="M7 4.5v15M17 4.5v15M3 12h18"/><path d="M10.5 8.6 13.2 10l-2.7 1.4z" fill="currentColor"/>',
bus: '<rect x="4" y="3.5" width="16" height="13" rx="2.5"/><path d="M4 10.5h16M9.5 3.5v7M14.5 3.5v7"/><circle cx="8" cy="18.4" r="1.5"/><circle cx="16" cy="18.4" r="1.5"/>',
term: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M7.5 9.5 10.5 12l-3 2.5M13 15.2h4"/>',
pin: '<path d="M12 21.4s6.4-6.1 6.4-10.9a6.4 6.4 0 1 0-12.8 0C5.6 15.3 12 21.4 12 21.4z"/><circle cx="12" cy="10.2" r="2.3"/>',
box: '<path d="M3.6 8.2 12 4l8.4 4.2v7.6L12 20l-8.4-4.2z"/><path d="M3.6 8.2 12 12.4l8.4-4.2M12 12.4V20"/>',
skull: '<path d="M12 3.2c4.3 0 7.3 2.9 7.3 6.8v3.1c0 .9-.6 1.3-1.4 1.5l-1 .3V18c0 .8-.6 1.4-1.4 1.4H8.5c-.8 0-1.4-.6-1.4-1.4v-3.1l-1-.3c-.8-.2-1.4-.6-1.4-1.5V10c0-3.9 3-6.8 7.3-6.8z"/><circle cx="9.4" cy="11" r="1.25" fill="currentColor"/><circle cx="14.6" cy="11" r="1.25" fill="currentColor"/>',
swap: '<path d="M4 8.4h12.6l-3.2-3.2M20 15.6H7.4l3.2 3.2"/>',
plane: '<path d="M10.6 13.4 3.2 11 21 3.8l-3.9 17.4-4.8-6.1z"/><path d="M10.7 13.4 21 3.8"/>',
shield: '<path d="M12 3.2 4.9 6v6c0 4.3 3 7.5 7.1 8.8 4.1-1.3 7.1-4.5 7.1-8.8V6z"/><path d="m9.1 12 2.2 2.2 3.7-3.8"/>',
clock: '<circle cx="12" cy="12" r="8.6"/><path d="M12 7.2V12l3.4 2.2"/>',
cog: '<circle cx="12" cy="12" r="3.2"/><path d="M19.4 14.4a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-2.9 1.2v.17a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 7 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 3.4 14a1.7 1.7 0 0 0-1.63-1.7H1.6a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 3.4 7a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 7 3.4a1.7 1.7 0 0 0 1.7-1.63V1.6a2 2 0 1 1 4 0v.09A1.7 1.7 0 0 0 14 3.4a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 7v.17a1.7 1.7 0 0 0 1.63 1.7h.17a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.7 1.53z" transform="translate(0.4 0.6) scale(0.94)"/>',
users: '<path d="M16 20.2c0-3.2-2.5-5.7-5.6-5.7S4.8 17 4.8 20.2"/><circle cx="10.4" cy="8" r="3.6"/><path d="M17.6 4.6a3.6 3.6 0 0 1 0 6.8M18.6 14.6c1.6.8 2.6 2.3 2.6 4"/>',
sprite: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 9h.01M9.5 14.5c1 1 4 1 5 0"/>',
ticket: '<path d="M3.5 8.2V6a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v2.2a2.6 2.6 0 0 0 0 7.6V18a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2v-2.2a2.6 2.6 0 0 0 0-7.6z"/><path d="M12 7v10" stroke-dasharray="2 2.4"/>',
cpu: '<rect x="7" y="7" width="10" height="10" rx="2"/><path d="M10 3.2V7M14 3.2V7M10 17v3.8M14 17v3.8M3.2 10H7M3.2 14H7M17 10h3.8M17 14h3.8"/>',
debug: '<circle cx="10.8" cy="10.8" r="7.2"/><path d="m16.4 16.4 4.2 4.2M8.6 12.6h4.4M10.8 10.4v4.4"/>',
play: '<path d="M7 4.8 19 12 7 19.2z" fill="currentColor" stroke="none"/>',
bulb: '<path d="M9.4 17.6h5.2M10.4 20.6h3.2"/><path d="M12 3.4a5.8 5.8 0 0 0-3.4 10.5c.6.5.9 1.2.9 1.9v.8h5v-.8c0-.7.3-1.4.9-1.9A5.8 5.8 0 0 0 12 3.4z"/>',
siren: '<path d="M6.8 19.4h10.4M8 19.4a4 4 0 0 1 8 0"/><path d="M12 15.4V7.6M9 9.4l-1.4 2M15 9.4l1.4 2"/><circle cx="12" cy="5.6" r="1.8"/>',
gun: '<path d="M3.5 9.5h13l3.2 4.2h-8.4l-.8 2.4H7.1l.6-2.4H3.5z"/><path d="M8.4 16.1 7.6 20.4"/>',
mech: '<rect x="4" y="8.5" width="16" height="10" rx="2.5"/><path d="M12 8.5V4.4M9.6 4.4h4.8"/><circle cx="9" cy="13.4" r="1.2"/><circle cx="15" cy="13.4" r="1.2"/><path d="M4 11.4h16"/>',
film2: '<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M8 5v14M16 5v14M3 12h18"/>'
};
/* 9 个栏目 —— 顺序必须等于 DOM 里 section 自上而下的顺序
(curSectionByScroll / VISUAL_ORDER 都依赖它) */
const MODULES = [
{ id:'quick', name:'快捷功能', icon:'bolt' },
{ id:'schedule', name:'角色、时间和天气', icon:'cal' },
{ id:'jobs', name:'职业和独立玩法', icon:'pad' },
{ id:'task', name:'任务和动画播放', icon:'film' },
{ id:'traffic', name:'人流和车流', icon:'car' },
{ id:'spawn', name:'生成和铺设NPC', icon:'user' },
{ id:'transit', name:'公共交通', icon:'bus' },
{ id:'misc', name:'杂项功能', icon:'box' },
{ id:'debug', name:'调试信息', icon:'term' }
];
/* 导航分组:纯视觉分组(2026-10-02 用户定)。只决定侧栏里怎么分段显示、
以及 dock 的图标顺序;业务含义不变。
⛑ MODULES 的顺序 = DOM 自上而下的顺序,改分组时两边要一起改(见上方注释)。
杂项功能(misc)是 2026-10-02 新建的空壳栏目,暂时 0 张卡,等指定再把卡搬进来。 */
const NAV_GROUPS = [
{ title:'常用', ids:['quick'] },
{ title:'玩法系统', ids:['schedule','jobs','task'] },
{ title:'世界系统', ids:['traffic','spawn','transit'] },
{ title:'其他系统', ids:['misc','debug'] }
];
const SECTION_ORDER = NAV_GROUPS.reduce((a, g) => a.concat(g.ids), []);
/* ⛑ tie-break 必须用「页面真实自上而下的顺序」,不能用 SECTION_ORDER ——
导航是分组显示的,顺序和 DOM 里的 section 顺序并不一致,
两个 section 同时可见时按分组顺序取会取错(实测滚到底会停在 transit)。 */
const VISUAL_ORDER = [].slice.call(document.querySelectorAll('section.sec'))
.map(el => el.id.replace('sec-', ''));
/* ── 外壳逻辑 ─────────────────────────────────────────────────── */
let active = MODULES[0].id;
function renderNav(){
const byId = {}; MODULES.forEach(m => byId[m.id] = m);
document.getElementById('nav').innerHTML = NAV_GROUPS.map(g =>
`<div class="nav-group"><span>${g.title}</span></div>` +
g.ids.map(id => {
const m = byId[id]; if (!m) return '';
return `
<div class="nav-item ${m.id===active?'active':''}" data-mod="${m.id}" title="${m.name}">
<svg viewBox="0 0 24 24">${ICONS[m.icon]}</svg>
<span class="txt">${m.name}</span><span class="arrow">›</span>
</div>`;
}).join('')).join('');
document.querySelectorAll('.nav-item').forEach(el =>
el.onclick = () => jumpTo(el.dataset.mod));
/* 右下角快捷菜单 = 同一份栏目的纯图标按钮版(顺序跟随分组) */
document.getElementById('dockItems').innerHTML =
SECTION_ORDER.map(id => byId[id]).filter(Boolean).map(mod => `
<div class="dock-item ${mod.id===active?'active':''}" data-mod="${mod.id}" data-tip="${mod.name}">
<svg viewBox="0 0 24 24">${ICONS[mod.icon]}</svg>
</div>`).join('');
document.querySelectorAll('.dock-item[data-mod]').forEach(el =>
el.onclick = () => jumpTo(el.dataset.mod));
}
/* 只切 class,不整块 innerHTML 重绘 —— 重绘会把侧栏自己的滚动位置弹回顶部 */
function paintActive(){
document.querySelectorAll('.nav-item[data-mod]').forEach(el =>
el.classList.toggle('active', el.dataset.mod === active));
document.querySelectorAll('.dock-item[data-mod]').forEach(el =>
el.classList.toggle('active', el.dataset.mod === active));
}
/* Active 项被滚出侧栏可视区时,只滚侧栏本身,绝不碰主内容区 */
function syncNavActive(id){
const nav = document.getElementById('nav');
const item = nav && nav.querySelector('.nav-item[data-mod="' + id + '"]');
if (!nav || !item) return;
const top = item.offsetTop, bot = top + item.offsetHeight;
if (top < nav.scrollTop) nav.scrollTop = top - 8;
else if (bot > nav.scrollTop + nav.clientHeight) nav.scrollTop = bot - nav.clientHeight + 8;
}
function setActive(id){
if (id === active) return;
active = id; paintActive(); syncNavActive(id);
}
function jumpTo(id){
active = id; paintActive(); syncNavActive(id);
const el = document.getElementById('sec-' + id);
if (el) el.scrollIntoView({ behavior:'smooth', block:'start' });
}
function toggleSide(){
document.body.classList.toggle('collapsed');
toast(document.body.classList.contains('collapsed') ? '侧栏已收起(再点一次展开)' : '侧栏已展开');
}
function toTop(){
document.getElementById('content').scrollTo({ top:0, behavior:'smooth' });
}
/* 滚动同步:① 当前 section 自动高亮(IntersectionObserver,比 scrollTop 反查稳)
② 滚过 260px 才显示右下角 dock */
const content = document.getElementById('content');
const secVisible = new Set();
let secObserver = null;
/* 兜底:环境没有 IntersectionObserver 时,退回 scrollTop 反查(改版前的老逻辑) */
function curSectionByScroll(){
const y = content.scrollTop + 90;
let cur = MODULES[0].id;
MODULES.forEach(m => {
const el = document.getElementById('sec-' + m.id);
if (el && el.offsetTop <= y) cur = m.id;
});
return cur;
}
/* ⛑ active 的唯一入口:IO 回调和 scroll 兜底都走这里,避免两条路径互相覆盖 */
function resolveActive(){
/* 已经滚到底:最后一个 section 通常太矮、顶部进不了判定带,直接强制选中它 */
if (content.scrollTop + content.clientHeight >= content.scrollHeight - 4) {
setActive(VISUAL_ORDER[VISUAL_ORDER.length - 1] || MODULES[MODULES.length - 1].id);
return;
}
if (secObserver) {
/* 多个 section 同时可见时,取视觉顺序上最靠前的那个 */
for (const id of VISUAL_ORDER) { if (secVisible.has(id)) { setActive(id); return; } }
return;
}
setActive(curSectionByScroll());
}
if (typeof IntersectionObserver === 'function') {
secObserver = new IntersectionObserver(entries => {
entries.forEach(e => {
const id = e.target.id.replace('sec-', '');
if (e.isIntersecting) secVisible.add(id); else secVisible.delete(id);
});
resolveActive();
}, { root: content, rootMargin: '-10% 0px -60% 0px', threshold: 0 });
MODULES.forEach(m => {
const el = document.getElementById('sec-' + m.id);
if (el) secObserver.observe(el);
});
}
let ticking = false;
content.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
resolveActive();
const d = document.getElementById('dock');
if (content.scrollTop > 260) d.classList.add('show');
else d.classList.remove('show');
ticking = false;
});
}, { passive:true });
let toastTimer = null;
function toast(html){
const el = document.getElementById('toast');
el.innerHTML = html; el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 2400);
}
/* 卡片标题图标:静态 HTML 里只写 <i class="ci" data-ic="pin"></i>,这里统一注入 svg */
function hydrateIcons(){
document.querySelectorAll('[data-ic]').forEach(el => {
const p = ICONS[el.dataset.ic];
if (p) el.innerHTML = '<svg viewBox="0 0 24 24">' + p + '</svg>';
});
}
hydrateIcons();
/* 按钮自适应宽度(统一按「大停电」卡那种排法):
① 卡片里**连续的裸 button** 自动包进一个 .btns 容器;
② 整行只有 button 的 .row / .gd-row 也补上 .btns。
效果:同一行的按钮平分整行宽度 —— 不会出现「按钮挤在左边、右边空一片」。 */
function groupButtons(){
document.querySelectorAll('.card').forEach(card => {
let run = [];
const flush = () => {
if (!run.length) return;
const wrap = document.createElement('div');
wrap.className = 'btns';
run[0].before(wrap);
run.forEach(b => wrap.appendChild(b));
run = [];
};
[].slice.call(card.children).forEach(el => {
if (el.tagName === 'BUTTON') run.push(el);
else flush();
});
flush();
});
document.querySelectorAll('.row, .gd-row').forEach(el => {
const kids = [].slice.call(el.children);
if (kids.length && kids.every(k => k.tagName === 'BUTTON')) el.classList.add('btns');
});
}
groupButtons();
renderNav();
/* 卡片里有的是异步渲染出来的(renderTraffic / renderCrowd / 角色日程表),
补跑两次按钮自适应,让后生成的按钮行也吃满宽度 */
setTimeout(groupButtons, 900);
setTimeout(groupButtons, 2600);
/* Small fetch wrapper used by the metro card (ported from the donor panel). Everywhere else in
this panel calls fetch() directly; the card's own JS was written against this helper, so rather
than rewrite 500 lines of it, keep the helper it expects. */
async function api(path, opts){ const r=await fetch(path, opts); return await r.json(); }
let cur=null;
async function status(){
const r=await fetch('/api/status'); const s=await r.json(); cur=s;
document.getElementById('status').innerHTML = s.connected
? `<span class="pos">位置=(${s.x.toFixed(2)}, ${s.y.toFixed(2)}, ${s.z.toFixed(2)}) 朝向=${s.facing.toFixed(2)}</span><br>单位=${s.unitId} 模板=${s.templateId} 就绪=${s.ready} 变换=${s.hasTransform} 待传送=${s.pendingTeleportId}<br>副本=${s.raidId} 实例=${s.instanceId} 宇宙=${s.universeId}<br>载具=${s.currentVehicleId} 座位=${s.currentVehicleSeat}<br>会话=${s.session} 最后在线=${s.lastSeenUtc}`
: '无玩家会话(请先登录游戏客户端)';
}
async function vehicles(){
const r=await fetch('/api/vehicles'); const v=await r.json();
const sel=document.getElementById('vf'); sel.innerHTML='';
(v.fleet||[]).forEach(id=>{const o=document.createElement('option');o.value=id;o.text=id;sel.appendChild(o);});
document.getElementById('vstate').textContent='上次载具='+v.lastVehicleId+' 上次UID='+v.lastUid+' 上次驾驶状态='+(v.lastDriveState??'(暂无)');
const fwsel=document.getElementById('vwf'); fwsel.innerHTML='';
(v.flyingWater||[]).forEach(w=>{const o=document.createElement('option');o.value=w.templateId;o.text=w.category+':'+w.name+' ('+w.templateId+',座位='+w.seatCount+')';fwsel.appendChild(o);});
}
/* 自动驾驶巡航速度(2026-09-30)。读取只填输入框并显示「当前生效值 / 是否在自驾」;
保存一定落盘,返回里的 hotApplied 说明有没有对当前这辆车热应用。 */
async function loadAutoDriveSpeed(){
try{
const r=await fetch('/api/vehicle/autodrive-speed'); const s=await r.json();
document.getElementById('adsKmh').value=(s.savedKmh??s.effectiveKmh).toFixed(1);
document.getElementById('adsState').textContent=
'当前生效 '+s.effectiveKmh.toFixed(1)+' km/h'
+(s.savedKmh===null?'(未单独设置,用主配置 cruiseSpeed)':'(已单独设置)')
+' · '+(s.activeAutoDrive?'有车正在自动驾驶 → 保存会热应用':'当前没有在自动驾驶的车 → 下次开启生效')
+' · 范围 '+s.minKmh+'–'+s.maxKmh;
}catch(e){ document.getElementById('adsState').textContent='读取失败: '+e; }
}
async function saveAutoDriveSpeed(){
const kmh=parseFloat(document.getElementById('adsKmh').value);
if(!isFinite(kmh)){ document.getElementById('adsRes').textContent='请填一个有效数字。'; return; }
try{
const r=await fetch('/api/vehicle/autodrive-speed',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({speedKmh:kmh})});
const j=await r.json();
document.getElementById('adsRes').textContent=(j.ok?'✔ ':'✘ ')+(j.message||'');
loadAutoDriveSpeed();
}catch(e){ document.getElementById('adsRes').textContent='保存失败: '+e; }
}
/* 场景切换卡的「填入当前位置」:和已删掉的传送卡同一个语义 —— 把玩家实时坐标写进 x/y/z/朝向。
⛑️ cur 来自 /api/status 的轮询,未连接时静默返回(不弹窗、不写半截值)。 */
function fillSceneCurrent(){
if(!cur||!cur.connected) return;
const g=id=>document.getElementById(id);
g('sx').value=cur.x.toFixed(2); g('sy').value=cur.y.toFixed(2); g('sz').value=cur.z.toFixed(2);
if(cur.facing!==undefined && cur.facing!==null) g('sfa').value=Number(cur.facing).toFixed(2);
}
/* 生成载具:车队/自由 ID 与 飞行·水上 共用这一条命令 /api/vehicle/spawn。
force=true 走飞行·水上(绕过车队白名单),force=false 走车队白名单。
两者共用同一个调试输出框 vres 和「进入 / 离开载具」。 */
async function spawnVehicle(force){
const fw=!!force;
const id = fw ? +document.getElementById('vwf').value
: (+document.getElementById('vi').value || +document.getElementById('vf').value);
if(!id){ vres.textContent = fw ? '请先选一个飞行 / 水上载具。'
: '请先选一个车队载具,或在「或输入 ID」里填载具 ID。'; return; }
const r=await fetch('/api/vehicle/spawn',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({vehicleId:id, force:fw})});
vres.textContent=JSON.stringify(await r.json(),null,2); vehicles(); status();
}
async function exitVehicle(){
const r=await fetch('/api/vehicle/exit',{method:'POST'});
vres.textContent=JSON.stringify(await r.json(),null,2); status();
}
async function enterVehicle(){
const r=await fetch('/api/vehicle/enter',{method:'POST'});
vres.textContent=JSON.stringify(await r.json(),null,2); status();
}
async function wantedStatus(){
const r=await fetch('/api/wanted/status'); const j=await r.json();
const el=document.getElementById('wtOut');
if(!j.online){ el.textContent='无玩家会话(请先登录游戏客户端) '+(j.note||''); return; }
el.textContent = [
'星级='+j.wantedLevel+' 犯罪值='+j.crimeValue+' 追捕中='+j.chaseActive+' 警车='+j.policeVehicleCount+' 已持续='+j.pursuitElapsedSeconds+'s',
'逃脱='+j.escapeCount+' 被捕='+j.arrestCount+' 奖励累计='+j.rewardTotal+' 罚款累计='+j.penaltyTotal,
'超时='+j.pursuitTimeoutSeconds+'s 每星车数='+j.vehiclesPerLevel+' 自动逃脱='+j.autoEscapeEnabled+' 倒地即捕='+j.arrestOnPlayerDown,
'打中='+j.npcHitCount+' 击杀='+j.npcKillCount+' 砸车='+j.vehicleHitCount+' 毁车='+j.vehicleDestroyCount,
'最后事件='+(j.lastIncident||'-')+' 最后结果='+(j.lastOutcome||'-')+' 最后同步='+(j.lastSync||'-')
].join(String.fromCharCode(10));
}
async function wantedPost(obj){
const el=document.getElementById('wtOut'); el.textContent='…';
const r=await fetch('/api/wanted/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)});
const j=await r.json();
if(j.ok===false){ el.textContent='失败:'+(j.error||JSON.stringify(j)); return; }
el.textContent=JSON.stringify(j,null,1);
}
function wantedSetLevel(){ wantedPost({action:'setlevel', level:+document.getElementById('wtLevel').value}); }
function wantedAdd(){ wantedPost({action:'addviolation', points:+document.getElementById('wtPoints').value}); }
function wantedEscape(){ wantedPost({action:'escape', seconds:+document.getElementById('wtSeconds').value}); }
function wantedArrest(){ wantedPost({action:'arrest'}); }
function wantedClear(){ wantedPost({action:'clear'}); }
/* ── 里希盘查(2026-10-02 逐字对齐捐赠端 AdminWebServer.cs:1080-1088)──
⛑⛑ 2026-10-02 重写旧的并发门闩 —— 它曾把整张卡锁死:
旧写法 = document.querySelectorAll('#lixiCard button') 拿的是**整张卡所有按钮**
(盘查 / 押送 / 牢房 / GATE Lua / 挂 F 全在里面),先 `some(b=>b.disabled)` 一票否决、
再把它们全部 disabled。于是只要有**一次** lixiPolice 请求慢或没返回(警察 Gate 被后台
重试任务占住时常见),卡片里**每一个**按钮都变成 disabled ⇒ 再点直接 `return`,
**连一行提示都不输出** —— 表现就是"所有按键点了全都没反应"。
现在:只锁被点的那一个按钮 + 15s 超时(AbortController)+ 每一次点击都必有回显。 */
async function lixiPolice(action, btn){
const out=document.getElementById('lixiOut');
out.textContent='… 正在提交 '+action+' …';
if(btn)btn.disabled=true;
const ctl=new AbortController();
const timer=setTimeout(()=>ctl.abort(),15000);
try{
const res=await fetch('/api/lixi/police',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({action}),signal:ctl.signal});
const data=await res.json();
out.textContent=(res.ok&&data.ok?'✅ ':'❌ ')+(data.message||('HTTP '+res.status));
}catch(e){
out.textContent = e.name==='AbortError'
? '❌ 超时 15s:服务端没响应(警察 Gate 可能被后台重试占住)。请重登后再试,或看服务端日志里的 LIXI-POLICE。'
: '❌ 请求失败:'+e.message;
}finally{ clearTimeout(timer); if(btn)btn.disabled=false; }
}
/* ── 里希牢房(2026-09-30)──
只做「位置管理 + 账本/AI 托管」,入口就是这个卡片里的两个按钮。
⛑ 玩家必须在主城(实例 20001222)且站在牢房门口 4 m 内,服务端才会执行。 */
/* 统一的 15s 超时 POST —— 2026-10-02 加:没有超时时,服务端一旦不返回按钮就永远停在
"…",用户只能刷新页面。任何失败都必须落到 <pre> 里,不能静默。 */
async function adminPost(url, body, outId){
const el=document.getElementById(outId);
el.textContent='… 正在提交 '+(body.action||'')+' …';
const ctl=new AbortController();
const timer=setTimeout(()=>ctl.abort(),15000);
try{
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(body),signal:ctl.signal});
const j=await r.json();
el.textContent=(r.ok&&j.ok?'✅ ':'❌ ')+(j.message||JSON.stringify(j));
return j;
}catch(e){
el.textContent = e.name==='AbortError'
? '❌ 超时 15s:服务端没响应。检查服务端是否还活着,再看日志。'
: '❌ 请求失败:'+e.message;
return null;
}finally{ clearTimeout(timer); }
}
async function prisonStatus(){
const el=document.getElementById('prisonOut');
try{
var r=await fetch('/api/police/prison');
const j=await r.json();
if(!j.ok){ el.textContent='查询失败:'+JSON.stringify(j); return; }
el.textContent = [
'开关=' + (j.enabled ? '开' : '关(gameplay.policePrison.enabled=false)')
+ ' 场景OK=' + (j.sceneOk ? '是' : '否(要回主城实例 20001222)'),
'当前收押 uid=' + (j.detained === '0' ? '(空)' : j.detained) + ' 账本=' + (j.ledger || '-'),
'车上押着 uid=' + (j.escortAgent === '0' ? '(空)' : j.escortAgent)
+ ' 车=' + (j.escortVehicle === '0' ? '-' : j.escortVehicle) + ' 座位=' + j.escortSeat,
'离牢房门口=' + (j.entranceMeters == null ? '未知(服务端还没收到过移动上报)' : j.entranceMeters + ' m') + '(≤4 m 才能操作)',
'门口=' + j.entrance + ' 牢内=' + j.inside
].join(String.fromCharCode(10));
}catch(e){ el.textContent='刷新失败:'+e.message; }
}
async function prisonBtn(action){
await adminPost('/api/police/prison',
{action:action, uid:(document.getElementById('prisonUid').value||'').trim()}, 'prisonOut');
prisonStatus();
}
/* 押送:直接发 SyncUnitVehicleStatus(4) + SyncUnitBoardingInfo 把犯人绑到座位,
没有原生押送过场(那个要 GATE Lua 通道)。绑上后犯人跟着车走。 */
async function escortBtn(action){
await adminPost('/api/police/escort',
{action:action, uid:(document.getElementById('escortUid').value||'').trim(),
vehicle:(document.getElementById('escortVehicle').value||'').trim(),
seat:parseInt(document.getElementById('escortSeat').value||'1',10)||1}, 'prisonOut');
prisonStatus();
}
/* ── GATE Lua 通道(2026-09-30)──
SyncDoToLuaMaster(45437825) 必须发在 **GATE 那条连接**上,
客户端收到后交给内置的 gCsToLuaHandler.GmRunLua 执行。
⛑ 结果不回传 —— 要看客户端 Player.log 里的 print_debug / print_error。 */
const GATE_LUA_PRESETS = {
probe: [
"local ok, err = pcall(function()",
" print_debug('[DRMK] gate lua ok: ' .. tostring(os.time()))",
" local u = gCS.MyPlayerManager.PlayerUnit",
" print_debug('[DRMK] myPid=' .. tostring(u and u.Pid))",
"end)",
"if not ok then print_error('[DRMK] failed: ' .. tostring(err)) end"
].join(String.fromCharCode(10)),
globals: [
"local ok, err = pcall(function()",
" local names = {'gCS','gPoliceJobManager','gClientToGameSceneDelegate','gMapSystem',",
" 'L50','LX6','C_NewMapPanelStore','ulong'}",
" for _, n in ipairs(names) do",
" print_debug('[DRMK] global ' .. n .. ' = ' .. type(_G[n]))",
" end",
" print_debug('[DRMK] InteractBtnMgr = ' .. tostring(type(L50)=='table'",
" and type(L50.L50App)=='table' and type(L50.L50App.L50Game)=='table'",
" and type(L50.L50App.L50Game.InteractBtnMgr) or 'nil'))",
"end)",
"if not ok then print_error('[DRMK] failed: ' .. tostring(err)) end"
].join(String.fromCharCode(10)),
fbtn: [
"-- 挂 F 按钮所需的三个 API 是否齐全(诊断用;真正挂按钮请用上面的下拉)",
"local ok, err = pcall(function()",
" local parts = {'L50','L50App','L50Game','InteractBtnMgr'}",
" local t = _G",
" for _, p in ipairs(parts) do",
" if type(t) ~= 'table' then print_error('[DRMK] missing at ' .. p); return end",
" t = t[p]",
" end",
" print_debug('[DRMK] InteractBtnMgr=' .. type(t))",
" if type(t) == 'table' then",
" print_debug('[DRMK] AddBtn=' .. type(t.AddBtn) .. ' RemoveBtnByType=' .. type(t.RemoveBtnByType))",
" end",
" print_debug('[DRMK] LX6=' .. type(_G.LX6))",
" if type(_G.LX6) == 'table' then",
" local I = _G.LX6.Interact",
" print_debug('[DRMK] LX6.Interact=' .. type(I))",
" if type(I) == 'table' then",
" print_debug('[DRMK] UnitBtnInfo=' .. type(I.UnitBtnInfo) .. ' BtnTargetType=' .. type(I.BtnTargetType))",
" if type(I.UnitBtnInfo) == 'table' then print_debug('[DRMK] UnitBtnInfo.New=' .. type(I.UnitBtnInfo.New)) end",
" end",
" end",
" print_debug('[DRMK] SceneDataMgr.GetUnit=' .. type(gCS and gCS.SceneDataMgr and gCS.SceneDataMgr.GetUnit))",
" local u = gCS and gCS.MyPlayerManager and gCS.MyPlayerManager.PlayerUnit",
" print_debug('[DRMK] myPid=' .. tostring(u and u.Pid))",
"end)",
"if not ok then print_error('[DRMK] failed: ' .. tostring(err)) end"
].join(String.fromCharCode(10)),
/* ── 街头篮球 QTE(B 层,2026-10-03;21:3x 修 .x 报错)──
客户端自己 Instantiate 球架(PoiGameConfig.Basket_BallStand)+ 拉起双方对话模型,
服务端一个协议都不用发。
⛑⛑ 取玩家坐标**必须用 `PlayerUnit.LocalPosition`**:`PlayerUnit.Position` 虽然存在
(userdata)但**没有 `.x`**,一访问就抛 "field or property x does not exist"
⇒ 整段 pcall 挂掉 ⇒ 表现就是「点了什么都没发生」(21:27 实测)。
实测 LocalPosition 与 PlayerObj.transform.position 数值完全相同(同一坐标系,
可直接当 Unity 世界坐标用)。
⛑ 日志用 print_error 不用 print_debug:本版客户端 print_debug **不写** Player.log。
⛑ npcId 必须是客户端 AgentConfig 里存在的 templateId,否则命中率配置取 nil
⇒ CheckAllModelsLoaded() 永远 false ⇒ 卡在全屏空面板。 */
basketball: [
"-- [DRMK] 街头篮球开局:球架 + 双方模型全由客户端自己生成",
"-- 换对手/难度改这三行。PoiGameBasketballConfig 按 index 遍历(TaskId ~= 传值即命中,",
"-- 是反混淆写法),taskId 传什么都拿得到一份配置,不会炸",
"local TASK_ID = 60005233",
"local NPC_ID = 42071029",
"local YAW = 0",
"local FALLBACK = {x = 1653.3, y = 17.88, z = 1597.43}",
"local ok, err = pcall(function()",
" local pos = FALLBACK",
" local u = gCS and gCS.MyPlayerManager and gCS.MyPlayerManager.PlayerUnit",
" if u then",
" local okP, p = pcall(function() return u.LocalPosition end)",
" if okP and p then",
" local okX, x = pcall(function() return p.x end)",
" if okX and x then pos = {x = p.x, y = p.y, z = p.z} end",
" end",
" end",
" local h = math.rad(YAW) * 0.5",
" gBasketballGameManager:CreateGameCs(TASK_ID, NPC_ID,",
" {x = pos.x, y = pos.y, z = pos.z},",
" {x = 0, y = math.sin(h), z = 0, w = math.cos(h)}, nil)",
" print_error(string.format('[DRMK] basketball start task=%d npc=%d at (%.1f, %.1f, %.1f)', TASK_ID, NPC_ID, pos.x, pos.y, pos.z))",
"",
" -- ⛑⛑ 对手(红铁桶 42071029)模型:其实加载成功了,但 body / body01 两个",
" -- SkinnedMeshRenderer 是 enabled=false(因为 args.TargetUnit =",
" -- LocalUnitMgr:GetNpcByTemplateId(42071029) 为 nil ⇒ 模型停在「未上场」态),",
" -- 所以玩家看得见球、看不见对手。这里把 SMR 点亮即可。",
" -- ⛑ 只开 SkinnedMeshRenderer:Capsule AO Body / TouchBendProxy 是辅助体,开了出怪块。",
" -- ⛑⛑ 延时用 coroutine.start + coroutine.wait(00:1x 实测 3s / 6s 都正常触发)。",
" -- 别用 wait / UpdateBeat.Add —— 本版是 nil;也别依赖浏览器 setTimeout —— 页面会被缓存。",
" local function fixNpcSMR()",
" local g = nil",
" pcall(function() g = gBasketballGameManager.currentGame end)",
" if not g then return -1, 0, 0 end",
" local nc = nil",
" pcall(function() nc = g.npcCharacter end)",
" if not nc then return -2, 0, 0 end",
" local tr = nil",
" pcall(function() tr = nc.baseUnit.ModelSlot.transform end)",
" if not tr then return -3, 0, 0 end",
" local rs = nil",
" pcall(function() rs = tr:GetComponentsInChildren(typeof(UnityEngine.SkinnedMeshRenderer)) end)",
" if not rs then return -4, 0, 0 end",
" local n, turned, off = rs.Length, 0, 0",
" for i = 0, rs.Length - 1 do",
" local okR, r = pcall(function() return rs[i] end)",
" if okR and r then",
" local okE, e = pcall(function() return r.enabled end)",
" if okE and e == false then",
" pcall(function() r.enabled = true end)",
" local ok2, e2 = pcall(function() return r.enabled end)",
" if ok2 and e2 then turned = turned + 1 else off = off + 1 end",
" end",
" end",
" end",
" return n, turned, off",
" end",
" coroutine.start(function()",
" for i = 1, 14 do",
" coroutine.wait(1.5)",
" local okF, n, turned, off = pcall(fixNpcSMR)",
" if okF then",
" print_error(string.format('[DRMK] basketball npc poll%d n=%d turned=%d off=%d', i, n, turned, off))",
" if n and n > 0 and off == 0 then break end",
" else",
" print_error('[DRMK] basketball npc poll' .. i .. ' err=' .. tostring(n))",
" end",
" end",
" end)",
"end)",
"if not ok then print_error('[DRMK] basketball start failed: ' .. tostring(err)) end"
].join(String.fromCharCode(10)),
/* ── 街头篮球收摊(2026-10-04 精简)──
2026-10-04 之前这里还带一段「全场景 FindObjectsOfType(typeof(Transform))
扫一遍找 'Gadgets_Common_BasketballRim' 再 Destroy」的逻辑 —— 因为篮筐是
服务端刷的 gadget,DestroyGame 不带走它,得手动收,否则每局堆一个。
⛑ 但**自动刷篮筐功能已在 10-04 删除**(Bridge 的 BasketballRimPathId /
SpawnBasketballHoopAsync / RemoveBasketballHoopAsync、MiscAcks 的
TriggerBasketballFinish handler、路由 /api/basketball/hoop、面板 JS 的
2500ms hook 全清了)⇒ 场上不可能再有残留篮筐,那段全场景扫描成了死代码,
而且每收一次摊就全场景扫一遍 Transform(几万个对象,会卡一帧)。故移除。 */
basketballStop: [
"-- [DRMK] 街头篮球收摊:只关局(篮筐自动刷已删,无需再扫场景收 gadget)",
"-- ⛑ print_error 才写 Player.log,print_debug 在本版客户端不写",
"local ok, err = pcall(function()",
" pcall(function() gBasketballGameManager:DestroyGame() end)",
" print_error('[DRMK] basketball destroyed')",
"end)",
"if not ok then print_error('[DRMK] basketball destroy failed: ' .. tostring(err)) end"
].join(String.fromCharCode(10)),
/* ── 排球(C 档·零协议纯客户端玩法,2026-10-04)──
入口:`gVolleyballGameMgr:CreateLuaGameCs(taskId, position, rotation)`
球场(球网 / 球 / 相机 / 4 个角色)**全在客户端 prefab 里**
(`Res/MiniGame/Prefab/VolleyballGame/VolleyballGameRootNodeCopy.prefab`),
服务端**一个协议都不用发**,也不需要世界里摆任何 gadget / waypoint / NPC 实体。
对比篮球:篮球 Spoon 节点要 `Waypoint` + `NpcId` + `NpcPid`(外部引用),
排球节点只要 `Position` + `Rotation` ⇒ 没有「篮筐要自己刷 / 对手模型出不来」那两个坑。
⛑⛑ 与篮球最大的区别:排球内部是 `Vector3.New(unpack(rootPosition))`
⇒ position / rotation **必须传位置数组** `{x,y,z}` / `{x,y,z,w}`;
传具名表 `{x=..,y=..,z=..}` 会 unpack 出 nil ⇒ `Vector3.New(nil,nil,nil)` 直接报错。
⛑ 坐标取 `PlayerUnit.LocalPosition`(`PlayerUnit.Position` 没有 `.x`,一访问就抛)。
⛑ 日志用 print_error(本版 print_debug 不写 Player.log)。
⛑ 延时用 coroutine.start + coroutine.wait(实测可用),别依赖浏览器 setTimeout。 */
volleyball: [
"-- [DRMK] 排球开局:球场 / 球网 / 球 / 相机全由客户端 prefab 生成,服务端零协议",
"-- ⛑⛑ 本版客户端(4229938)排球原生的 4 个 modelId(86952591~94)**建不出模型**:",
"-- Lua 版 C_VolleyballGame 走 GetDialogModel(modelId) ⇒ InitNonSelfStateMachine NRE",
"-- 原生版 L18.VolleyballGame 走 GetCutSceneModel→LoadModel(modelId) ⇒ 同样 NRE",
"-- 而 GetDialogModelByAgentId(agentId) 实测**可用**(篮球 / 保龄球都走这条)。",
"-- ⇒ 这里 monkey-patch C_VolleyballGame 的 CreateCharacter / InitCharacters,",
"-- 改用 agentId 通道。代价:4 个角色用角色形象(玩家 / Garm / Rin / Bansy),",
"-- 不是排球专用模型 —— 但至少能把局开起来。",
"-- ⛑ cardId 必传:AI 角色原版传 nil,而 GetDialogModelByAgentId 第 7 参要 number,",
"-- 传 nil 直接报 bad argument #7 ⇒ 剩下 3 个角色全建不出来(11:46 实测)。",
"local TASK_ID = 60005233",
"local YAW = 0",
"-- ⛑⛑ 四个角色统一用**玩家自己的 agentId**。11:46 实测:换成 Garm/Rin/Bansy 的",
"-- agentId 时只有玩家自己那个建成(chars=1),另外 3 个加载失败(疑似未解锁角色取不到模型)",
"-- ⇒ loadFinishCount 到不了 4 ⇒ OnAllLoaded 不触发 ⇒ **局根本开不起来**(无面板、无球场)。",
"-- 代价:4 个人长得一样,但队别靠 curTeam + 站位区分,玩法能跑。",
"local AGENTS = nil",
"local FALLBACK = {1653.3, 17.88, 1597.43}",
"local ok, err = pcall(function()",
" local mgr = gVolleyballGameMgr",
" print_error('[DRMK] volleyball mgr=' .. type(mgr))",
" if type(mgr) ~= 'table' then return end",
" local M = C_VolleyballGame",
" local Team = mgr.Team",
" local SexType = UX.Game.SexType",
" local UMM = nil",
" pcall(function() UMM = LX6.Units.UnitModelManager end)",
" -- 玩家自己的 agentId(FightSpiritConfig 取不到就落回 40140030)",
" local myAgent = 40140030",
" pcall(function()",
" local sid = gBattleSpiritMgr.currentSpiritTemplateId",
" local cfg = LTConfig.FightSpiritConfig.GetConfig(sid)",
" if cfg and cfg.AgentId then myAgent = cfg.AgentId end",
" end)",
" AGENTS = {myAgent, myAgent, myAgent, myAgent}",
" print_error('[DRMK] volleyball agentId=' .. tostring(myAgent))",
"",
" function M:CreateCharacter(team, sexType, agentId, rebornPivot, callback, cardId)",
" local cid = cardId",
" if not cid then pcall(function() cid = gBattleSpiritMgr.currentSpiritTemplateId end) end",
" if not cid then cid = 15020967 end",
" gCS.UnitsManager:GetDialogModelByAgentId(function (baseUnit, _)",
" if UMM then",
" UMM.ShowOrHideAllBindItemAndWeaponRender(baseUnit, false)",
" UMM.SetShadow(baseUnit, true)",
" end",
" baseUnit:SetDynamicBone(true, true)",
" local characterNode = baseUnit.ModelSlot.transform",
" characterNode:SetParent(self.charactersNode)",
" characterNode.gameObject:SetActive(false)",
" local character = C_VolleyballCharacter.new(baseUnit.ModelSlot.gameObject, self)",
" character.curTeam = team",
" character.rebornPivot = rebornPivot",
" local pivotSetGo = GameObject.Instantiate(self.characterPivotSetPrefab, Vector3.zero, Quaternion.identity, characterNode)",
" pivotSetGo:SetActive(true)",
" character.pivotSet = pivotSetGo.transform",
" character.launchHandPivot = baseUnit.ModelSlot.handl",
" character.animator = character.transform:GetChild(0):GetComponent(typeof(UnityEngine.Animator))",
" character.animator.runtimeAnimatorController = self.animatorController",
" local playerNode = characterNode.transform:Find('player')",
" character.rootMotion = playerNode:GetOrAddComponent(typeof(L18.VolleyballGame.VolleyballRootMotion))",
" table.insert(self.allCharacters, character)",
" if callback then callback(character) end",
" self.loadFinishCount = self.loadFinishCount + 1",
" print_error('[DRMK] volleyball char loaded ' .. tostring(self.loadFinishCount) .. '/4')",
" if self.loadFinishCount == 4 and self.charAllLoadedCb then self.charAllLoadedCb() end",
" end, sexType, agentId, false, true, true, cid)",
" end",
"",
" function M:InitCharacters()",
" self.teamChars[Team.My] = {}",
" self.teamChars[Team.Op] = {}",
" self.loadFinishCount = 0",
" self.allCharacters = {}",
" self.loadOp = gResourceManager:LoadAssetWithCallBack(self.animatorControllerPath, typeof(UnityEngine.RuntimeAnimatorController), function (loadOp)",
" if not self.hasDestroy then",
" self.animatorController = loadOp.asset",
" self:CreatePlayerCharacter(Team.My, SexType.UnKnow, AGENTS[1], self.rebornPivotList[0])",
" self:CreateAICharacter(Team.My, SexType.UnKnow, AGENTS[2], self.rebornPivotList[1])",
" self:CreateAICharacter(Team.Op, SexType.UnKnow, AGENTS[3], self.rebornPivotList[2])",
" self:CreateAICharacter(Team.Op, SexType.UnKnow, AGENTS[4], self.rebornPivotList[3])",
" end",
" end)",
" end",
" print_error('[DRMK] volleyball patched -> agentId channel')",
"",
" local u = gCS and gCS.MyPlayerManager and gCS.MyPlayerManager.PlayerUnit",
" local pos = FALLBACK",
" if u then",
" local okP, p = pcall(function() return u.LocalPosition end)",
" if okP and p and p.x then pos = {p.x, p.y, p.z} end",
" end",
" local h = math.rad(YAW) * 0.5",
" -- ⛑ 先把上一局清干净再开:CreateLuaGame 里若 curLuaGame~=nil 或 hasDestroy==false,",
" -- 会走 DestroyLuaGame + Timer.New(LoadLuaGame,1),那条路径实测抛",
" -- event.lua:96 assertion failed ⇒ 局开不起来(11:47 实测)。手动清掉绕开它。",
" if mgr.curLuaGame ~= nil then",
" print_error('[DRMK] volleyball: 先收掉上一局')",
" pcall(function() mgr:DestroyLuaGame() end)",
" end",
" mgr.hasDestroy = true",
" -- ⛑⛑ 位置数组,不是具名表:见上面注释",
" mgr:CreateLuaGameCs(TASK_ID, pos, {0, math.sin(h), 0, math.cos(h)})",
" print_error(string.format('[DRMK] volleyball start task=%d at (%.1f, %.1f, %.1f)',",
" TASK_ID, pos[1], pos[2], pos[3]))",
" -- 轮询确认 prefab 是否真的 Instantiate 出来、4 个角色是否加载完",
" coroutine.start(function()",
" for i = 1, 10 do",
" coroutine.wait(1.5)",
" local okS, g = pcall(function() return mgr.curLuaGame end)",
" if not okS or not g then",
" print_error('[DRMK] volleyball poll' .. i .. ' no game yet')",
" else",
" local okC, n = pcall(function() return (g.allCharacters and #g.allCharacters) or -1 end)",
" local chars = (okC and n) or -1",
" print_error('[DRMK] volleyball poll' .. i .. ' game ok chars=' .. tostring(chars))",
" if type(chars) == 'number' and chars >= 4 then break end",
" end",
" end",
" end)",
"end)",
"if not ok then print_error('[DRMK] volleyball start failed: ' .. tostring(err)) end"
].join(String.fromCharCode(10)),
volleyballStop: [
"-- [DRMK] 排球收摊:关掉本局 + 关两个面板 + 交还玩家控制",
"-- ⛑ 球场是 prefab 实例,DestroyLuaGame → curLuaGame:DestroyGame() 里会",
"-- Destroy(rootNodeTrans.gameObject),**不用额外收 gadget**(跟篮球的篮筐不同)",
"-- ⛑ 直接调 DestroyLuaGame 不会走 OnGameEnd ⇒ 不发 SetChallengeResult",
"-- (该协议 MethodId 63571710 在主线 Ananta.Handlers 里没有 handler,发了也会掉",
"-- unknown-methods.log,所以走这条路反而是干净的)",
"local ok, err = pcall(function()",
" local mgr = gVolleyballGameMgr",
" if type(mgr) ~= 'table' then",
" print_error('[DRMK] volleyball stop: mgr missing')",
" return",
" end",
" pcall(function() mgr:DestroyLuaGame() end)",
" print_error('[DRMK] volleyball destroyed')",
"end)",
"if not ok then print_error('[DRMK] volleyball destroy failed: ' .. tostring(err)) end"
].join(String.fromCharCode(10))
};
async function gateLuaStatus(){
const el=document.getElementById('gateLuaOut');
try{ var r=await fetch('/api/gatelua/status'); }catch(e){ el.textContent='请求失败:'+e; return; }
const j=await r.json();
if(!j.ok){ el.textContent='查询失败:'+JSON.stringify(j); return; }
document.getElementById('gateLuaBadge').textContent = j.bound ? ('已绑定 gate=' + j.gate) : '未绑定';
document.getElementById('gateLuaBadge').style.color = j.bound ? '#7ee787' : '#ff7b72';
el.textContent = j.message || '';
}
function gateLuaPreset(){
const k = document.getElementById('gateLuaPreset').value;
document.getElementById('gateLuaScript').value = GATE_LUA_PRESETS[k] || '';
}
/* 「执行」:把 #gateLuaScript 里的脚本(预设由 gateLuaPreset 填进去)POST 给 /api/gatelua。
之前这个按钮没有对应函数,点下去会直接抛 gateLuaRun is not defined。 */
async function gateLuaRun(){
const el=document.getElementById('gateLuaOut'); el.textContent='…';
const script=document.getElementById('gateLuaScript').value||'';
if(!script.trim()){ el.textContent='❌ 脚本为空:先在上面的下拉框选一个预设,或手动填 Lua。'; return; }
const r=await fetch('/api/gatelua',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({script:script})});
const j=await r.json();
el.textContent=(j.ok?'✅ ':'❌ ')+(j.message||JSON.stringify(j));
gateLuaStatus();
if (document.getElementById('gateLuaPreset').value === 'basketball'){
/* 对手(红铁桶 42071029):模型其实加载成功了,但 body/body01 的
SkinnedMeshRenderer 是 enabled=false ⇒ 玩家看不见。这里补一刀点亮。
⛑ 只是兜底:basketball 预设里已经用 coroutine.start + coroutine.wait 自带轮询点亮,
本段依赖页面是新的(面板 HTML 会被浏览器缓存,见 §4 ⑤)。
幂等(只开当前为 false 的),所以 6s / 12s 各来一次防止模型加载比预期慢。 */
[6000, 12000].forEach(function(ms){
setTimeout(async function(){
try{
const nr=await fetch('/api/basketball/npcfix',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const nj=await nr.json();
el.textContent += String.fromCharCode(10)
+ (nj.ok ? '🪣 对手模型已请求点亮(' : '⚠️ 对手模型请求失败(') + (nj.message||'') + ')';
}catch(e){
el.textContent += String.fromCharCode(10) + '⚠️ 对手模型请求异常:' + e;
}
}, ms);
});
}
}
/* 街头篮球卡的两个按钮:直接把对应预设 Lua 丢给 /api/gatelua,不再走下拉框。
⛑ GATE_LUA_PRESETS.basketball / basketballStop 仍在这里被引用(只是下拉里不再显示)。
⛑ 拿不到预设 = 页面是旧缓存,提示 Ctrl+F5(别去查代码)。
⛑⛑⛑ 这两个函数必须在**顶层作用域**(10-04 修):此前它们被插在 gateLuaRun 的
catch(e){ ... } 块内部 ⇒ 变成**局部函数** ⇒ onclick="basketballGame('start')"
抛 basketballGame is not defined ⇒ 按钮点了毫无反应(画面、日志都静悄悄)。
node --check 查不出这种错:语法合法,只是作用域不对。 */
async function basketballGame(action){
const el=document.getElementById('basketballOut');
el.textContent='… 正在'+(action==='start'?'开始':'结束')+'…';
const key=(action==='start')?'basketball':'basketballStop';
const script=(typeof GATE_LUA_PRESETS!=='undefined'&&GATE_LUA_PRESETS[key])||'';
if(!script){ el.textContent='❌ 找不到预设 '+key+' —— 页面是旧缓存,请 Ctrl+F5 硬刷新'; return; }
try{
const r=await fetch('/api/gatelua',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({script:script})});
const j=await r.json();
el.textContent=(j.ok?'✅ ':'❌ ')+(j.message||JSON.stringify(j));
}catch(e){ el.textContent='❌ 请求失败:'+e; }
}
/* 排球卡的两个按钮:直接把对应预设 Lua 丢给 /api/gatelua。
⛑ 拿不到预设 = 页面是旧缓存,提示 Ctrl+F5(别去查代码,见 §4 ⑤)。 */
async function volleyballGame(action){
const el=document.getElementById('volleyballOut');
el.textContent='… 正在'+(action==='start'?'开始':'结束')+'…';
const key=(action==='start')?'volleyball':'volleyballStop';
const script=(typeof GATE_LUA_PRESETS!=='undefined'&&GATE_LUA_PRESETS[key])||'';
if(!script){ el.textContent='❌ 找不到预设 '+key+' —— 页面是旧缓存,请 Ctrl+F5 硬刷新'; return; }
try{
const r=await fetch('/api/gatelua',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({script:script})});
const j=await r.json();
el.textContent=(j.ok?'✅ ':'❌ ')+(j.message||JSON.stringify(j))
+ String.fromCharCode(10) + '(结果看客户端 Player.log:grep -a "[DRMK] volleyball")';
}catch(e){ el.textContent='❌ 请求失败:'+e; }
}
async function gateLuaProbe(){
const el=document.getElementById('gateLuaOut'); el.textContent='…';
const r=await fetch('/api/gatelua/probe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({script:''})});
const j=await r.json();
el.textContent=(j.ok ? '✅ ' : '❌ ') + (j.message || JSON.stringify(j))
+ String.fromCharCode(10) + String.fromCharCode(10)
+ '现在去服务端 console-latest.log 里找 DrmkLuaRx:'
+ String.fromCharCode(10)
+ ' grep -a DrmkLuaRx logs/console-latest.log'
+ String.fromCharCode(10) + String.fromCharCode(10)
+ '· 出现 ⇒ 客户端执行了 Lua,问题在按钮脚本本身;'
+ String.fromCharCode(10)
+ '· 不出现 ⇒ 客户端压根没跑 Lua,是 wire / GATE 路由问题。';
gateLuaStatus();
}
async function gateLuaUnits(){
const el=document.getElementById('gateLuaOut');
try{ var r=await fetch('/api/gatelua/units'); }catch(e){ el.textContent='请求失败:'+e; return; }
const j=await r.json();
const sel=document.getElementById('gateLuaUnit');
if(!j.ok){ el.textContent='查询失败:'+JSON.stringify(j); sel.innerHTML='<option value="">(无)</option>'; return; }
sel.innerHTML = (j.count===0)
? '<option value="">(账本里没有 NPC —— 先用「在面前生成 NPC 并盘查」刷一个)</option>'
: '<option value="">(选一个…)</option>' + (j.units||[]).map(function(u){
var d = (u.distance==null) ? '?' : (u.distance+'m');
return '<option value="'+u.uid+'">'+u.ledger+' '+u.uid.slice(-6)+' '+d
+ ' ('+u.x.toFixed(0)+','+u.y.toFixed(0)+','+u.z.toFixed(0)+')</option>';
}).join('');
el.textContent='账本里 '+j.count+' 个 NPC' + (j.count>60 ? '(只列最近 60 个)' : '') + '。';
}
async function gateLuaFBtn(action){
const el=document.getElementById('gateLuaOut'); el.textContent='…';
const uid=(document.getElementById('gateLuaUnit').value||'').trim();
if(uid===''){ el.textContent='❌ 先在下拉里选一个 NPC(右边「刷新列表」)。'; return; }
const text=(document.getElementById('gateLuaBtnText').value||'').trim();
const r=await fetch('/api/gatelua/fbtn',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({action:action, uid:uid, text:text})});
const j=await r.json();
el.textContent=(j.ok ? '✅ ' : '❌ ') + (j.message || JSON.stringify(j))
+ String.fromCharCode(10) + '→ 现在回游戏走近那个 NPC 看有没有 F;'
+ '没有就把 Player.log 里 [DRMK] 开头的行发我。';
gateLuaStatus();
}
window.__rs = [];
async function loadRoster(){
const r = await fetch('/api/roster-schedule'); const j = await r.json();
window.__rs = j.rows || [];
document.getElementById('rsstate').textContent =
(j.connected ? ('时钟 ' + j.clock + (j.explicitTime ? '(面板固定)' : '(本地)') + ' raid=' + j.raid) : '无玩家会话(请先登录游戏客户端)')
+ ' 常驻=' + (j.enabled ? '开' : '关') + ' 有日程角色=' + ((j.rows || []).length);
renderRoster();
}
function renderRoster(){
const rows = window.__rs || [];
const only = document.getElementById('rsOnlyHere') && document.getElementById('rsOnlyHere').checked;
let h = '<tr style="text-align:left;border-bottom:1px solid #555"><th>角色</th><th>Tag</th><th>在干什么</th><th>坐标</th><th>距你</th><th>状态</th><th></th></tr>';
rows.forEach(function(x, i){
if (only && !x.placeable) return;
const coord = x.placeable ? (x.x.toFixed(0) + ', ' + x.y.toFixed(0) + ', ' + x.z.toFixed(0)) : '—';
const dist = x.distance >= 0 ? (x.distance + 'm') : '—';
let st = x.isCurrent ? '你正在操控' : (x.onField ? '已在场上' : (x.placeable ? '走近才出现' : '该时段无法落地'));
const btn = x.placeable
? '<button onclick="gotoRoster(' + i + ')">传送过去</button>'
: '';
h += '<tr style="border-bottom:1px solid #333">'
+ '<td>' + x.name + '</td>'
+ '<td>' + x.tag + '</td>'
+ '<td>' + (x.performance || '—') + ' <span style="opacity:.55">' + x.mode + '</span></td>'
+ '<td>' + coord + '</td>'
+ '<td>' + dist + '</td>'
+ '<td>' + st + '</td>'
+ '<td>' + btn + '</td></tr>';
});
document.getElementById('rstbl').innerHTML = h;
}
async function gotoRoster(i){
const x = (window.__rs || [])[i];
if (!x) return;
const r = await fetch('/api/teleport', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({x: x.gotoX, y: x.gotoY, z: x.gotoZ, facing: x.gotoFacing, mode: 'instant'})});
const j = await r.json();
document.getElementById('rsres').textContent =
'传送到 ' + x.name + '(' + x.performance + ')附近约 7m:' + (j.ok ? '成功' : ('失败 ' + j.message));
}
async function loadUnknown(){
const r=await fetch('/api/unknown-log?lines=60'); const j=await r.json();
document.getElementById('u').textContent=(j.lines||[]).join('\n')||'(空)';
}
async function blackoutStatus(){
const r=await fetch('/api/blackout/status'); const j=await r.json();
document.getElementById('bo').textContent = j.online
? '进行中=' + j.running + ' 控制器=' + j.controllerId + ' 剩余=' + j.remainingSeconds + 's 结束=' + (j.endsAt||'—')
: '无玩家会话(请先登录游戏客户端)';
}
// b = 广告牌 GameObject 世界坐标(**不能站**:有的是楼体内部,有的在半空)
// l = 最近的人行道 lane 点(有真实 Y,能站)—— 传送一律用 l
// 数据由 _gen_blackout_spots.py 从 CivilianDistricts.json 生成,
// 落盘在 ClientData/4229938/Configs/BlackoutBillboardSpots.json。
// b = 广告牌 GameObject 世界坐标(物体原点:有的在楼体内,有的在半空,**不能直接站**)
// c = 屏幕正中心 —— **控制器刷这里**(2026-09-24 用户定)
// l = 最近的人行道 lane 点(有真实 Y)—— **人永远传 l**
// ⭐ 为什么要把控制器放到 c:小游戏相机只"转身"(SetFreeLookCameraDirection),不会飞过去;
// 面板拿到的 lookAtPivot 就是**控制器自己的位置**。所以控制器刷在屏幕上 = 相机瞄屏幕上,
// 而且顺带解决了 pivot 陈旧的问题 —— 客户端只认这一局见过的第一个控制器,
// 所有控制器坐标一致时"记错了也没关系"。
// ⛑️ 只有 mall006a 的 c 是量出来的(它就是客户端两个小游戏 store 写死的 lookAt
// Vector3.New(2001.479, 70, 2287.694));别的牌子 c 暂用物体原点,相机大概率瞄空。
const BO_SPOTS=[
{n:'curve017a (1868,1591)', b:[1868,12,1591], c:[1868,12,1591], screen:false, l:[1875.62,0.05,1605.64]},
{n:'curve017a-1 (1710,1585)', b:[1710,9,1585], c:[1710,9,1585], screen:false, l:[1717.11,0.05,1584.81]},
{n:'curve017a-2 (1709,1591)', b:[1709,9,1591], c:[1709,9,1591], screen:false, l:[1711.61,0.05,1593.79]},
{n:'mall006a_45_racing 官方牌', b:[2001,-1,2288], c:[2001.479,70,2287.694], screen:true, l:[1992.47,-0.01,2313.44]},
{n:'zhaopai29a papaya', b:[1968,13,2259], c:[1968,13,2259], screen:false, l:[1953.17,0.04,2266.01]},
{n:'zhaopai29a-02', b:[1961,7,2273], c:[1961,7,2273], screen:false, l:[1953.17,0.04,2266.01]},
{n:'zpai66a_pc', b:[1940,51,2292], c:[1940,51,2292], screen:false, l:[1935.94,0.03,2285.75]}
];
const BO_OFFICIAL=3;
// 传送目的地与输入框解耦:控制器在屏幕正中心(很高),人必须站在地面。
let BO_LAND=BO_SPOTS[BO_OFFICIAL].l.slice();
let BO_PICK=BO_SPOTS[BO_OFFICIAL];
function boSet(x,y,z){ boX.value=x; boY.value=y; boZ.value=z; }
function boTip(t){ document.getElementById('bo').textContent=t; }
function boDistFromBoard(s){
return Math.hypot(s.c[0]-s.l[0], s.c[2]-s.l[2]).toFixed(0);
}
function boUseOfficial(){
const s=BO_PICK=BO_SPOTS[BO_OFFICIAL]; BO_LAND=s.l.slice();
boSet(s.c[0],s.c[1],s.c[2]);
boTip('控制器 → 官方牌「'+s.n+'」的屏幕正中心 ('+s.c.join(', ')+')\n'
+'人站落脚点 ('+s.l.join(', ')+'),离屏幕 '+boDistFromBoard(s)+' m —— 点「人传过去」\n'
+'这个点是客户端写死的 lookAt,相机只会转身 ⇒ 只有这里画面一定对得上');
}
function boUseNearest(){
// 按**落脚点**选最近的(不是按牌子本体):站在某栋楼里时 nearest-by-board
// 会挑中一块隔着墙的牌子,传过去就是穿模掉出地图。
let best=BO_SPOTS[0], bd=Infinity;
if(cur&&cur.connected){
for(const s of BO_SPOTS){ const d=(s.l[0]-cur.x)**2+(s.l[2]-cur.z)**2; if(d<bd){bd=d;best=s;} }
}
BO_PICK=best; BO_LAND=best.l.slice();
boSet(best.c[0],best.c[1],best.c[2]);
boTip('控制器 → '+best.n+' 的中心 ('+best.c.join(', ')+')'
+(best.screen?'(已量取的屏幕点)':'(未知,暂用物体原点 ⇒ 相机可能瞄空)')
+'\n落脚点 ('+best.l.join(', ')+')'+(cur&&cur.connected?(',距你 '+Math.sqrt(bd).toFixed(0)+' m'):''));
}
// 兜底:骇入交互是有射程的(CheckHackCondition 里 data.dis 通常十几米),
// 控制器刷在 70 m 高空有可能按不到 ⇒ 一条按钮退回地面,不用重启服务端。
function boUseFoot(){
boSet(BO_LAND[0],BO_LAND[1],BO_LAND[2]);
boTip('控制器已退回落脚点 ('+BO_LAND.join(', ')+') —— 这是旧行为:'
+'能按到遥控点,但相机瞄的是地面,屏幕看不到');
}
function boClear(){
// 三个框全空 ⇒ 服务端收到 null ⇒ 走配置落点(BlackoutControllerAtBillboard=false
// ⇒ BlackoutControllerAtPlayer=true ⇒ 玩家脚下)。⛑️ 千万别传 (0,0,0):那是 donor
// 原文的 identity transform,交互点在 4 km 外,玩家永远按不到。
boX.value=''; boY.value=''; boZ.value='';
boTip('坐标已清空 ⇒ 控制器刷在玩家脚下(2026-09-24 21:3x 起的默认行为)\n'
+'能骇入、能开局、能玩俄罗斯方块;代价是「站哪都能骇」,相机瞄脚下的控制器');
}
async function boTeleport(){
const r=await fetch('/api/teleport',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({x:BO_LAND[0],y:BO_LAND[1],z:BO_LAND[2],mode:'instant'})});
document.getElementById('bo').textContent='传送到落脚点 ('+BO_LAND.join(', ')+'):'+JSON.stringify(await r.json());
}
// 科斯机甲接管 / 返回(2026-09-26)。enter=true 接管机甲 / false 切回原角色。
async function kesiControl(enter){
const out=document.getElementById('kesiOut'); out.textContent='…正在请求原生控制切换…';
try{
const r=await fetch('/api/kesi/control',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({enter:!!enter})});
const j=await r.json();
out.textContent=(j.ok?'✓ ':'✗ ')+(j.message||'')+'\n'
+'判据:服务端日志 [KESI] 应为 enter='+(enter?'True ok=True':'False ok=True')
+';拒绝时先看是否缺少原生武器/技能配置,再查 [SWITCH-MIN]。';
}catch(e){ out.textContent='请求失败: '+e; }
}
async function blackoutStart(){
const out=document.getElementById('bo'); out.textContent='…等待客户端 ack 控制器(最多 20 秒)';
// ⛑️ 空框必须发 null,不能发 +''=0 —— (0,0,0) 会把控制器扔到世界原点。
const num=v=>{const s=(v||'').trim(); return s===''?null:+s;};
const bx=num(boX.value), by=num(boY.value), bz=num(boZ.value);
const r=await fetch('/api/blackout/start',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({x:bx,y:by,z:bz})});
const j=await r.json();
let extra='';
if(cur&&cur.connected){
if(bx===null||by===null||bz===null){
extra='\n落点 = 玩家脚下(配置默认)';
}else{
const dh=Math.sqrt((bx-cur.x)**2+(bz-cur.z)**2);
const dv=Math.abs(by-cur.y);
extra='\n你距落点 水平 '+dh.toFixed(0)+' m / 垂直 '+dv.toFixed(0)+' m'
+(by>25?' ⚠ 控制器在半空:骇入交互有射程(约 15 m),按不到就先「清空(用玩家脚下)」再启动':'')
+(dh>60?' ⚠ 太远:客户端很可能把这物件流掉了(先「人传过去」再启动)'
:' (服务端会自动补发 6 次 × 5 s)');
}
}
out.textContent=(j.ok?'启动成功:'+j.message:'失败:'+j.message)+'\n控制器 id='+j.id+extra;
}
async function blackoutStop(){
const r=await fetch('/api/blackout/stop',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const j=await r.json();
document.getElementById('bo').textContent=(j.ok?'已复位:'+j.message:'失败:'+j.message);
}
/* 武器下拉:缓存全量到 wAll,搜索框只过滤重建,不再重新请求 /api/weapons */
let wAll=[];
async function weapons(){
const r=await fetch('/api/weapons'); const j=await r.json();
wAll=j.weapons||[]; renderWeaponOpts('');
}
function renderWeaponOpts(q){
const sel=document.getElementById('wf'); const keep=sel.value; sel.innerHTML='';
const kw=String(q||'').trim().toLowerCase();
wAll.filter(w=>!kw||String(w.templateId).indexOf(kw)>=0||String(w.name||'').toLowerCase().indexOf(kw)>=0)
.forEach(w=>{const o=document.createElement('option');o.value=w.templateId;o.text=w.name+' ('+w.templateId+')';sel.appendChild(o);});
if(keep){const hit=[].slice.call(sel.options).some(o=>o.value===keep); if(hit) sel.value=keep;}
}
function filterWeapons(){ renderWeaponOpts(document.getElementById('wfilter').value); }
async function grantWeapon(){
/* 「或输入 ID」优先:填了就用它,没填才用下拉框当前项 */
const tid=+document.getElementById('wid').value||+document.getElementById('wf').value;
if(!tid){ wres.textContent='请先选一个武器,或在「或输入 ID」里填 TemplateId。'; return; }
const r=await fetch('/api/weapons/grant',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({templateId:tid})});
wres.textContent=JSON.stringify(await r.json(),null,2);
}
async function grantAllWeapons(){
wres.textContent='正在解锁全部武器…';
const r=await fetch('/api/weapons/grant-all',{method:'POST',headers:{'Content-Type':'application/json'},
body:'{}'});
wres.textContent=JSON.stringify(await r.json(),null,2);
}
async function timeweather(){
const r=await fetch('/api/timeweather'); const j=await r.json();
document.getElementById('twstate').textContent=j.connected
? `time=${j.hasTime?formatGameTime(j.hour,j.minute)+' ('+String(j.hour).padStart(2,'0')+':'+String(j.minute).padStart(2,'0')+') fix='+j.fix:'(客户端默认)'} weather=${j.hasWeather?j.weatherId:'(客户端默认)'}` : '无会话';
const sel=document.getElementById('ws'); sel.innerHTML='';
(j.weathers||[]).forEach(w=>{const o=document.createElement('option');o.value=w.id;o.text=w.name+' ('+w.id+')';sel.appendChild(o);});
}
function formatGameTime(hour, minute){
const displayMinute=String(minute).padStart(2,'0');
if(hour === 0) return `12:${displayMinute} 午夜`;
if(hour === 12) return `12:${displayMinute} 中午`;
return `${hour % 12}:${displayMinute} ${hour < 12 ? '上午' : '下午'}`;
}
async function setTime(){
const [hour,minute]=(timeValue.value||'12:00').split(':').map(Number);
const r=await fetch('/api/timeweather/time',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({hour,minute,fix:timeFix.checked,transition:+tt.value})});
twres.textContent=JSON.stringify(await r.json(),null,2); timeweather();
}
async function setWeather(){
const r=await fetch('/api/timeweather/weather',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({weatherId:+ws.value,transition:+wt.value})});
twres.textContent=JSON.stringify(await r.json(),null,2); timeweather();
}
async function clearWeather(){
const r=await fetch('/api/timeweather/weather',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({weatherId:0,transition:0})});
twres.textContent=JSON.stringify(await r.json(),null,2); timeweather();
}
async function scenes(){
const r=await fetch('/api/scenes'); const j=await r.json();
const sel=document.getElementById('sf'); sel.innerHTML='';
(j.presets||[]).forEach(p=>{const o=document.createElement('option');o.value=p.key;o.text=p.label+'(副本 '+p.raidId+')';o.dataset.p=JSON.stringify(p);sel.appendChild(o);});
sel.onchange=()=>{const p=JSON.parse(sel.selectedOptions[0].dataset.p);
sr.value=p.raidId;si.value=p.instanceId;su.value=p.universeId;sx.value=p.x;sy.value=p.y;sz.value=p.z;sfa.value=p.facing;};
if(sel.options.length)sel.onchange();
}
let allTimelines=[];
async function timelines(){
const r=await fetch('/api/timelines'); const j=await r.json();
allTimelines=j.timelines||[];
filterTimelines();
}
function filterTimelines(){
const q=(document.getElementById('tlfilter').value||'').toLowerCase();
const sel=document.getElementById('tlsel'); sel.innerHTML='';
let count=0;
allTimelines.forEach(t=>{
const label=(t.timelineName||'')+(t.group?' ['+t.group+']':'');
if(!q||label.toLowerCase().includes(q)||String(t.timelineId).includes(q)){
const o=document.createElement('option');o.value=t.timelineName;o.text=label;
o.dataset.t=JSON.stringify(t);sel.appendChild(o);count++;
}
});
sel.onchange=()=>{if(sel.selectedOptions[0]){const t=JSON.parse(sel.selectedOptions[0].dataset.t);
document.getElementById('tlinfo').textContent='ID='+t.timelineId+' PGO='+t.pgoType+' 控制='+t.playerControlType+(t.banSkip?' [不可跳过]':'')+(t.needPreload?' [需预加载]':'')+(t.pauseAI?' [暂停AI]':'');}};
if(sel.options.length)sel.onchange();
}
async function playTimeline(){
const name=tlsel.value; if(!name){tlres.textContent='请先选择时间轴';return;}
const r=await fetch('/api/timeline/play',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({timelineName:name})});
tlres.textContent=JSON.stringify(await r.json(),null,2);
}
/* ---- 切人演出:按**动画**分(SwitchSpiritConfig 201 行;2026-10-03 纠正) ----
旧版按角色做是错的:同一角色有十几条演出,按角色只能触发服务端替他挑的那条,
玩家想看「送人下车」根本选不到。现在主下拉 = 动画,第二个下拉 = 切成谁。 */
let allSwitchShows=[];
let activeSpiritId=0;
async function loadSwitchShows(){
try{
const r=await fetch('/api/spirit/switch-shows'); const j=await r.json();
allSwitchShows=j.shows||[];
activeSpiritId=j.activeSpiritId||0;
renderSwitchShows();
}catch(e){ allSwitchShows=[]; renderSwitchShows(); }
}
function renderSwitchShows(){
const sel=document.getElementById('shwsel'); if(!sel) return;
const cf=document.getElementById('shwfilter');
const q=((cf&&cf.value)||'').trim().toLowerCase();
const kind=(document.getElementById('shwkind')||{}).value||'playable';
const keep=sel.value;
sel.innerHTML='';
let n=0;
allSwitchShows.forEach(s=>{
if(kind==='playable' && !s.playable) return;
const hay=((s.label||'')+' '+(s.timeline||'')+' '+s.switchShowId).toLowerCase();
if(q && hay.indexOf(q)<0) return;
const o=document.createElement('option'); o.value=s.switchShowId;
o.text=s.label||('#'+s.switchShowId); o.dataset.s=JSON.stringify(s); sel.appendChild(o); n++;
});
const swinfo=document.getElementById('shwinfo');
if(swinfo) swinfo.textContent='共 '+n+' 条'+(n===0?'(试试放宽「范围」或清掉搜索)':'');
if(keep) sel.value=keep;
onSwitchShowPick();
}
function onSwitchShowPick(){
const sel=document.getElementById('shwsel');
const info=document.getElementById('shwinfo');
const tgt=document.getElementById('shwtarget');
if(!sel||!info) return;
const opts=sel.selectedOptions||[];
const opt=opts[0]; if(!opt||!opt.dataset||!opt.dataset.s) return;
const s=JSON.parse(opt.dataset.s);
info.textContent='#'+s.switchShowId+' '+s.timeline
+(s.invalid?' [已作废]':'')+(s.hasCompanionAgents?' [带随从]':'')
+(s.weight?' w='+s.weight:'')+' raid='+s.raidId+' type='+s.switchType;
if(tgt){
tgt.innerHTML='';
// 这份清单是**后端算好的**(IdsFor 判据:写死角色的行 → 只有那些角色;
// 通用行且没写 → 全 roster 扣掉 Forbid)。前端不再自己推断。
const ids=s.fightSpiritIds||[], names=s.spiritNames||[];
ids.forEach((id,i)=>{
const o=document.createElement('option'); o.value=id;
o.text=id+((names[i])?(' '+names[i]):''); tgt.appendChild(o);
});
// ★ 默认目标避开当前受控角色:切到自己 = 服务端静默 return = 「点了没反应」。
const cur=activeSpiritId||0;
const pick=(cur && ids.length>1) ? (ids.find(x=>x!==cur) ?? ids[0]) : (s.defaultSpiritId||ids[0]);
if(pick) tgt.value=pick;
if(!tgt.options.length){
const o=document.createElement('option'); o.value='';
o.text='这条演出没有可用角色(非通用行又没写 FightSpiritId)'; tgt.appendChild(o);
}
}
}
async function playSwitchShow(){
const out=document.getElementById('shwres');
const sid=parseInt(document.getElementById('shwsel').value,10);
const tid=parseInt(document.getElementById('shwtarget').value,10);
if(!sid){ out.textContent='请先选一条切人演出'; return; }
if(!tid){ out.textContent='请先选择要切成哪个角色'; return; }
// 刷新一次当前角色:页面加载后玩家可能已经用别的方式切过人,
// 拿旧的 activeSpiritId 判断会漏掉「切到自己」这个静默 no-op。
try{
const rr=await fetch('/api/spirit/switch-shows'); const jj=await rr.json();
activeSpiritId=jj.activeSpiritId||0;
allSwitchShows=jj.shows||allSwitchShows;
}catch(e){}
if(activeSpiritId && tid===activeSpiritId){
out.textContent='✗ 目标角色 '+tid+' 就是当前受控角色 —— 切人会在服务端静默跳过(什么都不发生)。'
+'请在「切成(目标角色)」里换一个。';
return;
}
out.textContent='正在播放切人演出 #'+sid+' → 切成 '+tid+' …';
try{
const r=await fetch('/api/spirit/switch-show',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({switchShowId:sid,spiritId:tid})});
const j=await r.json();
out.textContent=(j.ok?'✓ ':'✗ ')+(j.message||'');
}catch(e){ out.textContent='请求失败:'+e; }
}
/* ---- 剧情对话 / 台词播放(SyncShowDialog 64967628) ---- */
let allDialogs=[];
async function loadStoryDialogs(){
try{
const q=(document.getElementById('dlgfilter').value||'').trim();
const cut=document.getElementById('dlgcut').value;
const r=await fetch('/api/story/dialogs?q='+encodeURIComponent(q)
+(cut?'&cutscene='+cut:'')+'&limit=300');
const j=await r.json();
allDialogs=j.dialogs||[];
const tot=document.getElementById('dlgtotal'); if(tot&&j.total!=null) tot.textContent=j.total;
const cut2=document.getElementById('dlgcut2'); if(cut2&&j.cutsceneTotal!=null) cut2.textContent=j.cutsceneTotal;
const sel=document.getElementById('dlgsel'); if(!sel) return;
const keep=sel.value;
sel.innerHTML='';
const o0=document.createElement('option'); o0.value=''; o0.text='— 选择台词 —'; sel.appendChild(o0);
allDialogs.forEach(d=>{
const o=document.createElement('option'); o.value=d.dialogId;
const head=d.dialogId+(d.cutscene?' [过场]':'');
const tail=(d.speaker?d.speaker+':':'')+(d.message||'');
o.text=head+' '+(tail.length>44?tail.slice(0,44)+'…':tail);
sel.appendChild(o);
});
if(keep) sel.value=keep;
onDlgPick();
}catch(e){ console.warn('[panel] loadStoryDialogs failed',e); }
}
function onDlgPick(){
const el=document.getElementById('dlginfo'); if(!el) return;
const id=parseInt(document.getElementById('dlgsel').value,10);
const d=allDialogs.find(x=>x.dialogId===id);
el.textContent=d?(d.dialogId+(d.cutscene?' [带镜头]':'')+' type='+d.type
+(d.speaker?' · '+d.speaker:'')):'';
}
async function playStoryDialog(){
const out=document.getElementById('dlgres');
const id=parseInt(document.getElementById('dlgsel').value,10);
if(!id){ out.textContent='请先选一条台词'; return; }
out.textContent='正在下发台词 '+id+' …';
try{
const r=await fetch('/api/story/dialog',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({dialogId:id})});
const j=await r.json();
out.textContent=(j.ok?'✓ ':'✗ ')+(j.message||'');
}catch(e){ out.textContent='请求失败:'+e; }
}
async function playStoryDialogVoice(){
const out=document.getElementById('dlgres');
const id=parseInt(document.getElementById('dlgsel').value,10);
if(!id){ out.textContent='请先选一条台词'; return; }
out.textContent='正在下发语音 '+id+' …';
try{
const r=await fetch('/api/story/dialog-voice',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({dialogId:id})});
const j=await r.json();
out.textContent=(j.ok?'✓ ':'✗ ')+(j.message||'');
}catch(e){ out.textContent='请求失败:'+e; }
}
function onNpcSelect(){
// Only fills the id box. Auto-spawning on selection used to stack with the button
// click: the log showed every pick producing two spirit-spawn bursts one second apart.
const sel=document.getElementById('npsel');
if(!sel.value){ return; }
document.getElementById('npcustom').value=sel.value;
}
function fillNpcCurrent(){ if(!cur||!cur.connected) return;
nx.value=cur.x.toFixed(2); ny.value=cur.y.toFixed(2); nz.value=cur.z.toFixed(2); nfa.value=cur.facing.toFixed(2); }
async function spawnNpc(){
const custom=document.getElementById('npcustom').value.trim();
const id = custom ? parseInt(custom,10) : parseInt(document.getElementById('npsel').value,10);
if(!id){ nres.textContent='请选择角色或输入ID'; return; }
const r=await fetch('/api/npc/spawn',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({npcCultivationId:id,x:+nx.value,y:+ny.value,z:+nz.value,facing:+nfa.value})});
nres.textContent=JSON.stringify(await r.json(),null,2); status();
}
async function grantAllItems(){
const c=parseInt(document.getElementById('itcount').value,10)||999;
const out=document.getElementById('wres');
out.textContent='正在发放全部道具(每个 '+c+' 个)…';
try{
const r=await fetch('/api/items/grant-all',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({count:c})});
out.textContent=JSON.stringify(await r.json(),null,2); status();
}catch(e){ out.textContent='请求失败: '+e; }
}
async function grantAllCurrency(){
const out=document.getElementById('wres');
out.textContent='正在发放全部货币(每种补到 999 万)…';
try{
const r=await fetch('/api/currency/grant-all',{method:'POST',headers:{'Content-Type':'application/json'},
body:'{}'});
const j=await r.json();
out.textContent=(j.ok?'✓ ':'✗ ')+(j.message||JSON.stringify(j,null,2)); status();
}catch(e){ out.textContent='请求失败: '+e; }
}
// The character list is the server's playable roster (FightSpiritConfig filtered by
// config/private-server.json content.roster). Anything outside it cannot be projected,
// so the dropdown is rendered from that source instead of a hand-written list.
let allSpirits=[];
async function loadSpirits(){
try{
const r=await fetch('/api/spirits'); const j=await r.json();
allSpirits=j.roster||[];
const sel=document.getElementById('npsel');
if(sel){
sel.innerHTML='';
const o0=document.createElement('option'); o0.value=''; o0.text='— 选择角色 —'; sel.appendChild(o0);
}
allSpirits.forEach(s=>{
if(!sel) return;
const o=document.createElement('option'); o.value=s.spiritId;
o.text=s.spiritId+' '+s.name; sel.appendChild(o);
});
filterSwitchSpirits();
// 同一批角色也喂「任务和动画播放」标签里的切人演出卡。
loadSwitchShows();
}catch(e){ console.warn('[panel] loadSpirits failed',e); }
}
// Same roster feeds the switch card, but kept in its own filtered list: the roster here is
// every reachable row (CanJoin=false story characters included) and that is ~40 entries.
function filterSwitchSpirits(){
const sel=document.getElementById('swsel'); if(!sel) return;
const q=(document.getElementById('swfilter').value||'').trim().toLowerCase();
const keep=document.getElementById('swsel').value;
sel.innerHTML='';
const o0=document.createElement('option'); o0.value=''; o0.text='— 选择角色 —'; sel.appendChild(o0);
allSpirits.filter(s=>{
if(!q) return true;
if(String(s.spiritId).indexOf(q)>=0) return true;
return (s.name||'').toLowerCase().indexOf(q)>=0;
}).forEach(s=>{
const o=document.createElement('option'); o.value=s.spiritId;
o.text=s.spiritId+' '+s.name; sel.appendChild(o);
});
if(keep) sel.value=keep;
}
async function switchSpiritNow(){
const out=document.getElementById('swres');
const raw=(document.getElementById('swid').value||'').trim();
const id=parseInt(raw||document.getElementById('swsel').value,10);
if(!id){ out.textContent='请先选择角色,或填入战斗灵 ID'; return; }
out.textContent='正在切换到 '+id+' …';
try{
const r=await fetch('/api/spirit/switch',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({spiritId:id})});
const j=await r.json();
out.textContent=(j.ok?'✓ ':'✗ ')+(j.message||JSON.stringify(j,null,2));
status();
}catch(e){ out.textContent='请求失败: '+e; }
}
let allAgents={e:[],o:[]};
function agIds(w){ return w==='e' ? ['esel','ecustom','efilter','eres'] : ['osel','ocustom','ofilter','ores']; }
async function agents(w){
const url = w==='e' ? '/api/agents/hostile' : '/api/agents/other';
const r=await fetch(url); const j=await r.json();
allAgents[w]=j.agents||[]; filterAg(w);
}
function agId(w){
const ids=agIds(w);
const c=document.getElementById(ids[1]).value.trim();
const v = c ? parseInt(c,10) : parseInt(document.getElementById(ids[0]).value,10);
return v||0;
}
function filterAg(w){
const ids=agIds(w);
const q=(document.getElementById(ids[2]).value||'').trim().toLowerCase();
const sel=document.getElementById(ids[0]); sel.innerHTML='';
for(const a of (allAgents[w]||[])){
const variants=a.variantIds||[];
let useId=a.agentId;
const hp=(a.hasHpBar===false)?' ⚠无血条':'';
const label=a.agentId+' '+a.name+hp+(variants.length>1?'(另有 '+(variants.length-1)+' 个同型ID已折叠)':'');
if(q){
const exact=variants.some(v=>String(v)===q);
const fuzzy=(a.name||'').toLowerCase().includes(q)||String(a.agentId).includes(q);
if(!exact&&!fuzzy) continue;
if(exact) useId=parseInt(q,10);
}
const o=document.createElement('option'); o.value=useId; o.text=label; sel.appendChild(o);
}
}
async function agLocal(w){
const outId=(w==='e'?'eres':'ores');
const id=agId(w);
if(!id){ agOut(outId,'请选择单位或输入 Agent ID'); return; }
const label=(w==='e'?'敌人':'市民');
agOut(outId,'正在发送 [DoGmCommand] CreateClientAgent '+id+' …');
try{
const r=await fetch('/api/gm/command',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({command:'CreateClientAgent',args:[String(id)]})});
agOut(outId,'[本地生成'+label+'] CreateClientAgent '+id+'\n'+JSON.stringify(await r.json(),null,2));
}catch(e){ agOut(outId,'请求失败: '+e); }
}
// Server-side spawn: the agent is created by THIS server through the
// SyncRaidBattleUnitAgent batch (GameAdminBridge.SpawnAgentAsync) instead of being
// created by the client's own GmUtils.CreateClientAgent. Ownership is real, so the
// unit lands in the client's native battle-unit registry and the native combat /
// movement packets can reach it — which the local path cannot do. All-zero pose
// means "drop it in front of the player" (handled server-side).
async function agServer(w){
const outId=(w==='e'?'eres':'ores');
const id=agId(w);
const label=(w==='e'?'敌人':'市民');
if(!id){ agOut(outId,'请选择单位或输入 Agent ID'); return; }
agOut(outId,'正在发送 [server-spawn] agent '+id+' …');
try{
const r=await fetch('/api/agent/spawn',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({agentId:id,x:0,y:0,z:0,facing:0,hostile:(w==='e')})});
const j=await r.json();
agOut(outId,'[服务端直生成'+label+'] agent='+id+'\n'+JSON.stringify(j,null,2));
}catch(e){ agOut(outId,'请求失败: '+e); }
}
async function unlockPlayer(mode){
const out=document.getElementById('unlockres');
out.textContent='正在发送 ['+mode+'] …';
try{
const r=await fetch('/api/player/unlock',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({mode:mode})});
const j=await r.json();
out.textContent='[解除控制状态 '+mode+']\n'+JSON.stringify(j,null,2);
}catch(e){ out.textContent='请求失败: '+e; }
}
async function setUnlockAuto(on){
const out=document.getElementById('unlockres');
try{
const r=await fetch('/api/player/unlock-auto',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:on})});
const j=await r.json();
out.textContent='[自动解除 '+(on?'开':'关')+']\n'+JSON.stringify(j,null,2);
}catch(e){ out.textContent='请求失败: '+e; }
}
async function setLifeLock(on){
const out=document.getElementById('lifelockres');
try{
const r=await fetch('/api/player/vitals-lock',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:on})});
const j=await r.json();
out.textContent='[生命值锁定 '+(on?'开':'关')+']\n'+JSON.stringify(j,null,2);
}catch(e){ out.textContent='请求失败: '+e; }
}
async function refreshLifeLock(){
const out=document.getElementById('lifelockres');
try{
const r=await fetch('/api/player/vitals-lock'); const j=await r.json();
document.getElementById('lifelock').checked=!!j.locked;
out.textContent='[生命值锁定]\n'+JSON.stringify(j,null,2);
}catch(e){ out.textContent='请求失败: '+e; }
}
let civGroups=[];
async function loadCivilians(){
const r=await fetch('/api/civilians'); const j=await r.json();
civGroups=j.groups||[];
document.getElementById('cvstate').innerHTML =
(j.error ? `<span style="color:#c00">预设加载失败:${j.error}</span>`
: (j.online ? `共 ${j.groupCount} 组 · 每组最多 60 个 · 服务端不再持有这些实体,点一次生成一批`
: '无玩家会话(请先登录游戏客户端)'));
filterCivGroups();
}
function filterCivGroups(){
const q=(document.getElementById('cvfilter').value||'').trim().toLowerCase();
const sel=document.getElementById('cvgroup'); const prev=sel.value;
sel.innerHTML='';
civGroups.filter(g=>!q||g.label.toLowerCase().includes(q)).forEach(g=>{
const o=document.createElement('option');
o.value=g.id; o.textContent=g.label+`(${g.count} 个)`;
sel.appendChild(o);
});
if([...sel.options].some(o=>o.value===prev)) sel.value=prev;
loadCivGroup();
}
async function loadCivGroup(){
const id=document.getElementById('cvgroup').value;
if(!id){ document.getElementById('cvlist').textContent=''; return; }
try{
const r=await fetch('/api/civilians/group?id='+encodeURIComponent(id)); const j=await r.json();
document.getElementById('cvlist').textContent=(j.items||[])
.map((p,i)=>`${String(i+1).padStart(2,'0')}. ${p.npcName}${p.npcDesc?'('+p.npcDesc+')':''} npc=${p.npcFormworkId} poi=${p.poiActionId} · ${p.action}`).join('\n');
}catch(e){ document.getElementById('cvlist').textContent='读取失败: '+e; }
}
async function spawnCivilians(){
const groupId=+document.getElementById('cvgroup').value;
const spacing=+(document.getElementById('cvspacing').value||2.6);
agOut('cvres','正在添加分组 G'+String(groupId).padStart(3,'0')+' …');
try{
const r=await fetch('/api/civilians/spawn-group',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({groupId,spacing})});
const j=await r.json();
agOut('cvres',JSON.stringify(j,null,2));
}catch(e){ agOut('cvres','请求失败: '+e); }
}
async function loadCivDistricts(){
const el=document.getElementById('cdstate');
if(!el) return;
try{
const r=await fetch('/api/civilians/districts'); const j=await r.json();
if(j.error){ el.innerHTML='<span style="color:#c00">地块数据加载失败:'+j.error+'</span>'; return; }
const sel=document.getElementById('cdHour');
if(sel && sel.options.length<2){
for(let h=0;h<24;h++){ const o=document.createElement('option'); o.value=h; o.text=String(h).padStart(2,'0')+':00'; sel.appendChild(o); }
}
el.innerHTML = j.online
? '地块 <b>'+j.districtCount+'</b> 个 · 站位 <b>'+j.spotCount+'</b> 个 · 当前游戏时钟 <b>'+j.hour+'</b> 时<br>'
+ (j.types||[]).map(t=>t.type+' '+t.districts+'区/'+t.spots+'位').join(' · ')
: '无玩家会话(请先登录游戏客户端)';
}catch(e){ el.textContent='加载失败: '+e; }
}
async function spawnDistrictCivilians(){
const radius=parseFloat(document.getElementById('cdRadius').value)||150;
const maxTotal=parseInt(document.getElementById('cdMax').value,10)||120;
const hv=parseInt(document.getElementById('cdHour').value,10);
agOut('cdres','正在按地块铺人:半径 '+radius+'m,上限 '+maxTotal+' 人 …');
try{
const r=await fetch('/api/civilians/spawn-district',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({radius:radius,maxTotal:maxTotal,hour:isNaN(hv)?null:hv})});
const j=await r.json();
agOut('cdres',(j.ok?'✓ ':'✗ ')+(j.message||'')+'\n'+(j.detail||''));
}catch(e){ agOut('cdres','请求失败: '+e); }
}
async function loadEnemyTerritory(){
const el=document.getElementById('etstate');
if(!el) return;
try{
const r=await fetch('/api/enemy-territory'); const j=await r.json();
if(j.error){ el.innerHTML='<span style="color:#c00">地盘点位加载失败:'+j.error+'</span>'; return; }
const near = (j.nearestDistance>=0 && j.nearestName)
? '<br>离你最近的地盘:<b>'+j.nearestName+'</b>(<b>'+j.nearestDistance+'</b> m)· 你在 '+j.playerPos
: '';
const auto = j.auto
? '<br><b style="color:#6c6">自动铺设已开启</b>:走进 '+j.enter+' m 自动出现,走远 '+j.exit+' m 自动撤走'
+ ' · 每点 '+j.perPoint+' 人 · 上限 '+j.maxActive+' · 当前在场 <b>'+j.active+'</b> 个'
+ (j.engage ? '' : ' · <span style="color:#c88">Engage off:只站桩不开战</span>')
: '<br><span style="color:#c88">自动铺设已关闭</span>(配置 <code>Aether.EnemyTerritoryEnabled</code>)· 只能手动点';
el.innerHTML = j.online
? '地盘点位共 <b>'+j.total+'</b> 个 · 当前场景(raid '+j.raidId+')可用 <b>'+j.here+'</b> 个' + auto + near
: '无玩家会话(请先登录游戏客户端) · 地盘点位共 '+j.total+' 个';
}catch(e){ el.textContent='加载失败: '+e; }
}
/* ---------------- 搜打撤(Extraction Shooter · 不要抬头!) ----------------
记着两套独立的武器系统:轮盘 bag(99952004)只存物品、负责画格子;
gWeaponManager.CurrWeaponSlots 才管 Tab / 滚轮切枪,由服务端用
SyncSpiritWeaponDetail 灌进去。两者不同步 ⇒ Tab 永远只在拳头和自带那把之间转。
下标口径:0 = 拳头、1 = 私有武器、2 + k = 轮盘 CellX k。 */
function exOut(m){ var el=document.getElementById('exres'); if(el) el.textContent=m; }
function exBox(id,html){ var el=document.getElementById(id); if(el) el.innerHTML=html; }
function exWheelBox(j){
var box=document.getElementById('exWheelBox'); if(!box) return;
var w=(j&&j.wheel)||{};
var rows=w.weapons||[];
var head='下标 0/1 = 拳头 / 私有武器(角色自带,不进轮盘)· 轮盘 bag '+(w.bagId||0)
+' · '+(w.slotCells||0)+' 格 · 下标 = CellX + '+(w.homeOffset||0)
+' · 当前同步='+(w.mode||'?');
if(!rows.length){
box.innerHTML=head+'<div style="color:#777;margin-top:4px">轮盘里还没有能实体化的武器 —— 在游戏里把枪拖进武器轮盘,或从箱子里捡一把右键装备。</div>';
}else{
box.innerHTML=head+'<table><tr><th>下标</th><th>CellX</th><th>物品 id</th><th>场景模板</th><th>军械实例</th></tr>'
+ rows.map(function(r){return '<tr><td>'+r.index+'</td><td>'+r.cellX+'</td><td>'+r.itemId+'</td>'
+'<td>'+r.templateId+'</td><td>'+r.instanceId+'</td></tr>';}).join('')
+'</table>';
}
var inert=w.inert||[];
if(inert.length){
box.innerHTML+=('<div style="color:#777;margin-top:4px">轮盘上但不参与切枪(牛奶 / 药剂这类非武器):'
+inert.map(function(x){return 'CellX '+x.cellX+' = '+x.itemId;}).join('、')+'</div>');
}
}
async function loadExtraction(){
var el=document.getElementById('exstate'); if(!el) return;
try{
const r=await fetch('/api/extraction/status'); const j=await r.json();
if(j.catalogError){ el.innerHTML='<span style="color:#c00">配置加载失败:'+j.catalogError+'</span>'; return; }
el.innerHTML=(j.enabled?'整套:<b>开</b>':'整套:<b class="warn">关</b>')
+' · 名单 '+j.raidIds.join('/')
+' · 当前 raid '+j.activeRaid
+(j.inExtractionRaid?' <b style="color:#2e7d32">(在搜打撤图里)</b>':'(不在)')
+(j.online?'':' <span style="color:#c88">(还没有玩家会话)</span>')
+'<br>包 '+(j.bags||[]).length+' 个 · 容器 '+j.containers+' 个 · token '+j.tokens;
// ⛑ 用户正在改的控件不回填 —— 每 3 秒一次的 status 会把没提交的勾选项吃掉。
const fm=document.getElementById('exFakeMatch');
if(fm && !fm.matches(':focus')) fm.checked=!!j.fakeMatch;
const rd=document.getElementById('exRaid');
if(rd && !rd.matches(':focus') && j.preferredRaid) rd.value=j.preferredRaid;
var ws=document.getElementById('exWheelSync');
if(ws && j.wheel && j.wheel.mode) ws.value=String(j.wheel.mode).toLowerCase();
var wd=document.getElementById('exWheelData');
if(wd && j.wheel) wd.checked=!!j.wheel.weaponData;
exWheelBox(j);
}catch(e){ el.textContent='加载失败: '+e; }
}
async function exOptions(){
try{
const fm=document.getElementById('exFakeMatch');
const rd=document.getElementById('exRaid');
const ws=document.getElementById('exWheelSync');
const wd=document.getElementById('exWheelData');
const r=await fetch('/api/extraction/options',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({ fakeMatch: fm?fm.checked:null,
preferredRaid: rd?parseInt(rd.value,10):null,
wheelSync: ws?ws.value:null,
wheelWeaponData: wd?wd.checked:null })});
const j=await r.json(); exOut((j.ok?'✓ ':'✗ ')+(j.message||''));
}catch(e){ exOut('保存失败: '+e); }
}
async function exStart(){
const rd=document.getElementById('exRaid');
const raid=rd?parseInt(rd.value,10):23301277;
exOut('正在切进 raid '+raid+' …');
try{
const r=await fetch('/api/extraction/start',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({raidId:raid})});
const j=await r.json(); exOut((j.ok?'✓ ':'✗ ')+(j.message||''));
}catch(e){ exOut('进图失败: '+e); }
}
async function exExit(){
exOut('正在退出副本 …');
try{
const r=await fetch('/api/extraction/exit',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const j=await r.json(); exOut((j.ok?'✓ ':'✗ ')+(j.message||''));
}catch(e){ exOut('退出失败: '+e); }
}
async function exWheelRepush(){
exOut('正在重推武器轮盘 …');
try{
const r=await fetch('/api/extraction/wheel/republish',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const j=await r.json(); exOut((j.ok?'✓ ':'✗ ')+(j.message||''));
}catch(e){ exOut('重推失败: '+e); }
}
async function exRepublishLogin(){
exOut('正在重推登录帧 …');
try{
const r=await fetch('/api/extraction/republish',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const j=await r.json(); exOut((j.ok?'✓ ':'✗ ')+(j.message||''));
}catch(e){ exOut('重推失败: '+e); }
}
async function exNotify(){
const mid=parseInt(document.getElementById('exNotifyMid').value,10);
const hex=(document.getElementById('exNotifyHex').value||'').trim();
if(!mid){ exOut('✗ 先填 methodId'); return; }
exOut('正在推 '+mid+' …');
try{
const r=await fetch('/api/extraction/notify',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({methodId:mid,hex:hex})});
const j=await r.json(); exOut((j.ok?'✓ ':'✗ ')+(j.message||''));
}catch(e){ exOut('推送失败: '+e); }
}
async function loadInvasionSpots(){
const sel=document.getElementById('ivspot'); const st=document.getElementById('ivstate');
if(!sel) return;
try{
const r=await fetch('/api/faction/invasionspots'); const j=await r.json();
st.textContent=(j.ok?'':'✗ ')+(j.message||'');
sel.innerHTML='';
(j.spots||[]).forEach(s=>{const o=document.createElement('option');o.value=s.eventId;o.textContent=s.label;sel.appendChild(o);});
}catch(e){ st.textContent='入侵点加载失败: '+e; }
}
async function runInvasion(){
const ev=parseInt(document.getElementById('ivspot').value,10);
const out=document.getElementById('ivres');
if(!ev){ out.textContent='✗ 先选一个入侵点'; return; }
out.textContent='正在入侵 …';
try{
const r=await fetch('/api/faction/invade',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({eventId:ev,
teleport:document.getElementById('ivTp').checked,
summon:document.getElementById('ivSum').checked,
occupy:document.getElementById('ivOcc').checked})});
const j=await r.json();
out.textContent=(j.ok?'✓ ':'✗ ')+(j.message||'')+'\n'+(j.detail||'');
}catch(e){ out.textContent='入侵失败: '+e; }
}
async function spawnEnemyTerritory(){
const radius=parseFloat(document.getElementById('etRadius').value)||0;
agOut('etres','正在铺设地盘敌人:'+(radius>0?('半径 '+radius+'m'):'默认 90m')+' …');
try{
const r=await fetch('/api/enemy-territory/spawn',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({radius:radius})});
const j=await r.json();
agOut('etres',(j.ok?'✓ ':'✗ ')+(j.message||'')+'\n'+(j.detail||''));
}catch(e){ agOut('etres','请求失败: '+e); }
}
async function loadFunctionalNpcs(){
const el=document.getElementById('fnstate');
if(!el) return;
try{
const r=await fetch('/api/functional-npcs'); const j=await r.json();
if(j.error){ el.innerHTML='<span style="color:#c00">点位数据加载失败:'+j.error+'</span>'; return; }
const near = (j.nearestDistance>=0 && j.nearestName)
? '<br>离你最近的点位:<b>'+j.nearestName+'</b>(<b>'+j.nearestDistance+'</b> m)· 你在 '+j.playerPos
+ (j.nearestDistance>250 ? ' <span style="color:#c88">太远,客户端不会渲染,先走过去或用场景传送</span>' : '')
: '';
const auto = j.auto
? '<br><b style="color:#6c6">自动铺设已开启</b>:走进 130 m 自动出现,走远 230 m 自动撤走 · 当前在场 <b>'+j.active+'</b> 个'
: '<br><span style="color:#c88">自动铺设已关闭</span>(配置 <code>Aether.FunctionalNpcsEnabled</code>)· 只能手动点';
el.innerHTML = j.online
? '点位文件共 <b>'+j.total+'</b> 个 · 当前场景(raid '+j.raidId+')可用 <b>'+j.here+'</b> 个' + auto + near
: '无玩家会话(请先登录游戏客户端) · 点位文件共 '+j.total+' 个';
}catch(e){ el.textContent='加载失败: '+e; }
}
async function spawnFunctionalNpcs(){
const radius=parseFloat(document.getElementById('fnRadius').value)||0;
// 2026-09-30:「含语义匹配点位」勾选框已删除(tier2Semantic 已清空),固定传 true
const includeSemantic=true;
agOut('fnres','正在铺设功能 NPC:'+(radius>0?('半径 '+radius+'m'):'整张图')+' …');
try{
const r=await fetch('/api/functional-npcs/spawn',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({radius:radius,includeSemantic:includeSemantic})});
const j=await r.json();
agOut('fnres',(j.ok?'✓ ':'✗ ')+(j.message||'')+'\n'+(j.detail||''));
}catch(e){ agOut('fnres','请求失败: '+e); }
}
let dnpcBusy=false;
function updateDonorNpcButton(){
document.getElementById('dnpcSpawn').disabled=dnpcBusy;
}
async function spawnDonorNpcs(){
if(dnpcBusy)return;
const lines=document.getElementById('dnpcItems').value.split(/\r?\n/).map(l=>l.trim()).filter(Boolean);
const items=lines.map(line=>{const parts=line.split(/[,\s]+/);
return {npcFormworkId:Number(parts[0]),poiActionId:Number(parts[1]||0)};});
const output=document.getElementById('dnpcResult');
if(!items.length||items.some(item=>!Number.isInteger(item.npcFormworkId)||item.npcFormworkId<1||item.npcFormworkId>4294967295||!Number.isInteger(item.poiActionId)||item.poiActionId<0||item.poiActionId>4294967295)){
output.textContent='请填写合法的数字 ID,一行一个。';return;
}
const maxPerRow=Number(document.getElementById('dnpcMaxPerRow').value);
dnpcBusy=true;updateDonorNpcButton();output.textContent='Spawning...';
try{
const r=await fetch('/api/donor-npc/spawn',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({items,xSpacing:Number(document.getElementById('dnpcXSpacing').value),
zSpacing:Number(document.getElementById('dnpcZSpacing').value),maxPerRow})});
output.textContent=JSON.stringify(await r.json(),null,2);
}catch(e){ output.textContent='请求失败: '+e; }
finally{ dnpcBusy=false;updateDonorNpcButton(); }
}
function agOut(id,msg){
const el=document.getElementById(id);
if(el){ el.textContent=msg; return; }
console.warn('[panel] missing output element #'+id+'\n'+msg);
}
async function switchPreset(){
const r=await fetch('/api/scene/switch',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({preset:sf.value})});
sres.textContent=JSON.stringify(await r.json(),null,2); status();
}
async function switchCustom(){
const r=await fetch('/api/scene/switch',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({raidId:+sr.value,instanceId:+si.value,universeId:+su.value,x:+sx.value,y:+sy.value,z:+sz.value,facing:+sfa.value})});
sres.textContent=JSON.stringify(await r.json(),null,2); status();
}
/* 通用:转义后拼进 innerHTML。只处理 & < >,中文原样透传(element id 里用中文是安全的)。 */
function esc(x){ return String(x).replace(/[&<>]/g,c=>c==='&'?'&amp;':c==='<'?'&lt;':'&gt;'); }
/* ---- 车流调参:只列 gameplay.aether.traffic*,带中文标签、合理范围与改动提示 ---- */
const TF_PREFIX='gameplay.aether.';
// [键后缀, 中文名, bool|num, [min,max,step] 或 null, 分组, 说明]
const TF_META=[
['trafficEnabled','启用车流','bool',null,'基础','关掉后不再生成与推送车流,已在场的车下一轮回收'],
['trafficTarget','同屏车数','num',[0,40,1],'基础','同时存活几台车(太多更容易互相挡死)'],
['trafficSpawnRadiusMeters','生成半径(米)','num',[50,400,10],'基础','在你多远之内生成车'],
['trafficSpawnMinPlayerDistanceMeters','离你最近生成距离(米)','num',[0,200,5],'基础','⚠ 别 <40:客户端 20m 内会把车流车提升成 GO 实体,从此不吃车道插值 ⇒ 冻在原地'],
['trafficSuppressGoRender','禁止车被提升成完整载具','bool',null,'基础','⛑ 强烈建议开:出生时发 SyncAetherAIVehicleForceGo(forceGo=false)。客户端 20m 内会把车列为 GO 候选、8m 内强制提升成完整载具实体并改由本地接管 —— 而我们的车是服务端开的、不在客户端本地车流里 ⇒ 提升即「没司机」⇒ 你一走近车就停。开着还是走近就停 = 这条通道客户端没当回事,只能改走注入器'],
['trafficAutonomousDrive','交给客户端本地自驾(实验)','bool',null,'基础','🆕 10-01 实验:出生时发 SyncVehicleAutonomousDrivingState(68349264, isStart=true),给车一个**客户端本地司机**(与上一条约而不同 —— 那条是求别变 GO,这条是变了 GO 也有人开)。客户端自带驾驶/避障/交通规则,且本地驾驶本来就面向 GO 完整载具 ⇒ 若生效,走近不再停。⛑ 没有设目标点的 S2C ⇒ 车往哪开由客户端决定,正在实测'],
['trafficCruiseDrive','★换 Cruise 驱动管线(方案A)','bool',null,'基础','🆕 10-01 方案 A:车流车改由**客户端本地 AI** 开(SyncAssignVehicleAITask + Cruise 参数,出租车/玩家自驾同款已验证)。开启后:出生时 AddData 用 ControlType=2 + LaneHandle=-1 关掉车道模拟 → 发 Cruise 目标点 → 该车不再参与服务端车道推进。⛑ 为什么换:ForceGo 通道实机无效 + 注入器 8 探针全 0 命中(AetherVehicleViewManager 根本没在用)⇒ 继续猜客户端不划算。回滚=关掉'],
['trafficCruiseHops','Cruise 规划段数','num',[1,40,1],'基础','每次规划走多少段车道,段数越多目标点越远、车一口气开越久'],
['trafficCruiseMinHops','Cruise 最少段数(死路兜底)','num',[1,20,1],'基础','⛑⛑ 别调低:规划出的段数少于这个值就判「这条车道是死路口袋」⇒ 换一条车道重新规划。2026-10-01 23:2x 根因 —— 我们路网有大量死路车道,车开到目标后从那条车道往后只规划得出 1 段 ⇒ 每台车反复拿到同一个目标点 ⇒ 到点就停、再也不走(日志刷 points=1/1)'],
['trafficCruiseSingleTarget','只发终点(让客户端寻路)','bool',null,'基础','⛑ 强烈建议开:只把最后一个点当目标,客户端 NavigationHelper 自己沿真实路网寻路。关掉=把整串车道终点喂进去,那是点连点直线、会在弯道切内弯'],
['trafficCruiseRefreshSec','Cruise 重发间隔(秒)','num',[5,120,1],'基础','车到终点会停住,而服务端收不到到达通知 ⇒ 只能定时重发目标点续驶'],
['trafficCruiseMaxLifeSec','Cruise 车寿命上限(秒)','num',[30,600,10],'基础','Cruise 车的位置是估算的、按距离回收不准 ⇒ 用寿命兜底,否则老车占着名额、新车补不进来'],
['trafficCruiseLeadMeters','快到终点提前续发(米)','num',[0,300,10],'基础','⛑⛑ 别设 0:Cruise 到点是会**刹停**的,停在大马路上 ⇒ 后车顶上来 ⇒ 两辆车挤在一起开。设成 60~120 ⇒ 车离终点还有这么远时就拿到下一个目标,全程不停。太大则车还没到就改道、观感随机'],
['trafficCruiseTargetMinGapMeters','目标点之间最小间距(米)','num',[0,400,10],'基础','新目标要跟场上其它车的目标点拉开这么远,避免两台车被指到同一个路口然后挤一起。0=不检查'],
['trafficSpawnFacePlayerRatio','朝你开的车占比','num',[0,1,0.1],'基础','⛑⛑ 想看到迎面车流就别设 0:车流按「距你 620m 内」存活 ⇒ 迎面车一趟就穿出去、同向车一直待在圈里 ⇒ 稳态下几乎全是同向车(实测下发方向本身是均匀的,这是生存偏差)。0.5 = 一半迎面一半同向;设 0 = 回到随机'],
['trafficSpawnAvoidSameLane','同一条车道不生第二台','bool',null,'基础','⛑⛑ 强烈建议开:解决「两辆车开一条车道、并列挤着开」。只靠出生间距不够 —— 间距读的是 Cruise 车的推算位置,车实际可能比推算慢很多 ⇒ 判定形同虚设 ⇒ 两台车生在同一条车道几乎同一个点,客户端 AI 各走各的 ⇒ 车身互相穿模。开了之后这条车道上只要有车就换一条'],
['trafficCruiseSpeedJitter','每台车速度差异','num',[0,0.5,0.05],'基础','每台车在 ±这个比例内取一个固定速度倍率。⛑ 设 0 会永远并排:所有车同速 ⇒ 两台车一旦挨上相对速度就是 0,谁也甩不开谁。0.15 = 0.85~1.15 倍,快车自然拉开'],
['trafficCruiseMaxSpeedKmh','★车速上限(km/h)','num',[0,120,5],'基础','⛑⛑ 最直接的那个旋钮。0=不限。车道限速数据里 95.5% 是 35km/h、**4.5% 是 60km/h(主干道/快速路)**,倍率一视同仁地乘 ⇒ 主干道那批会跑到 69km/h —— 「车流太快」多半就是它们。设 30~35 就把主干道也压回城市速度。⛑ 注意:每辆车的速度是**出生时**按出生车道限速算死的,开到别的路上也不变,所以这个上限对所有车都有效'],
['trafficDespawnRadiusMeters','回收半径(米)','num',[100,600,10],'基础','车跑出这个距离就撤走'],
['trafficSpeedScale','限速倍率','num',[0.2,3,0.05],'基础','所有车道限速的总倍率。★ 实测车道限速 95.5% 是 35km/h、4.5% 是 60km/h ⇒ 倍率 1.0 = 街道 35 / 主干道 60;0.85 ≈ 街道 30 / 主干道 51(主干道再被「车速上限」压到 35)'],
['trafficTickMs','推送间隔(毫秒)','num',[20,200,10],'基础','⚠ 必须 ≤50 才对齐客户端 20Hz,调大车会一顿一顿'],
['trafficTimestampLeadMs','时间戳提前量(毫秒)','num',[0,800,50],'基础','⚠ 对齐客户端 0.35s 缓冲;车一冲一停就调大'],
['trafficReseedMs','补给扫描间隔(毫秒)','num',[200,5000,100],'基础','多久检查一次「车够不够、该不该撤」'],
['trafficControlType','控制类型','num',[1,2,1],'基础','⚠ 恒为 1;改 2 车会不动(2 只给骇入用)'],
['trafficMaxLaneGapMeters','允许换道接缝(米)','num',[0,20,0.5],'基础','车道衔接允许的最大缝,0 = 只走最贴合的后继'],
['trafficAccelMps2','加速度(m/s²)','num',[0.5,10,0.1],'加减速','起步太肉调大'],
['trafficDecelMps2','减速度(m/s²)','num',[0.5,15,0.1],'加减速','刹太猛调小'],
['trafficJunctionSlowMeters','路口减速距离(米)','num',[0,40,1],'加减速','距车道末端多远开始减速'],
['trafficJunctionSpeedFactor','路口限速系数','num',[0.1,1,0.05],'加减速','0.5 = 路口限速砍半'],
['trafficFollowRangeMeters','跟车检测半径(米)','num',[0,60,1],'跟车','多远之内开始看前车;调大更早减速'],
['trafficFollowMinGapMeters','最小车距(米)','num',[0,15,0.5],'跟车','与前车保持的距离'],
['trafficFollowWidthMeters','同管道横向门槛(米)','num',[1,6,0.1],'跟车','横向偏移小于它才算「同一条管道」'],
['trafficConflictRadiusMeters','会撞判定半径(米)','num',[1,8,0.1],'交叉让行','⚠ 别 >4.0:会让对向相邻车道被误判成要撞,两边互相等'],
['trafficConflictHorizonSec','撞车前瞻(秒)','num',[0,6,0.1],'交叉让行','只看未来多久会撞'],
['trafficConflictWidthMeters','让行横向门槛(米)','num',[1,6,0.1],'交叉让行','彼此横向都大于它 = 只是并行经过,不判冲突'],
['trafficYieldSpeedMps','让行速度(m/s)','num',[0,8,0.1],'交叉让行','⚠ 别设 0:停下会让对面也进入让行判定,两边来回抖成死锁'],
['trafficStallReleaseSec','停多久算堵死(秒)','num',[0.5,10,0.5],'防堵死','车速长期上不来,累计这么久就强制解锁'],
['trafficStallBoostSec','硬冲时长(秒)','num',[0.5,10,0.5],'防堵死','解锁后无视让行强行通过的时间'],
['trafficStallBoostSpeedMps','硬冲速度(m/s)','num',[1,12,0.5],'防堵死','硬冲期间的目标速度'],
['trafficSpawnMinGapMeters','出生点最小间距(米)','num',[0,80,5],'防堵死','新车离已有车太近就重挑,防「一生成就贴上」'],
['trafficSignalEnabled','红绿灯相位','bool',null,'信号灯','服务端自建相位:红灯方向的车压到 0。⛑ 灯柱不会亮(放行关系在客户端 blob 里),只有车会等'],
['trafficSignalGreenSec','绿灯时长(秒)','num',[2,20,0.5],'信号灯','周期 = 组数 ×(绿+清空)。路口腿越多周期越长'],
['trafficSignalClearSec','全红清空(秒)','num',[0,5,0.5],'信号灯','相位切换之间的全红,别设 0:会对冲的车在路口里撞成一团'],
['trafficSignalMaxWaitSec','单车红灯上限(秒)','num',[2,30,1],'信号灯','等超过它就放行 —— 给「等红灯」统一上界,防多腿路口车杵着不动'],
['trafficSignalYieldToTrafficOnly','感应式放行','bool',null,'信号灯','⛑ 强烈建议开:只有真有别个方向来车才按相位让行。关掉 = 所有路口无脑拦车(实测 spd 掉到 1.9、一半车在等)'],
['trafficSkipRoofModVehicles','排除车顶件车型','bool',null,'车型池','⚠ 09-29 更正:玻璃/天窗浮空与车顶件无关 —— 没车顶件的车也会飘,有车顶件的也有正常的,且只发生在部分车上。这只是 2026-09-21 的一个猜测,消除不了浮空,勾上只会少 7 种车型'],
['trafficMinVehicleLengthMeters','最短车身(米)','num',[0,8,0.1],'车型池','排除摩托/三轮'],
['trafficMaxVehicleLengthMeters','最长车身(米)','num',[3,15,0.5],'车型池','排除客车/卡车']
];
const TF_GROUPS=['基础','加减速','跟车','交叉让行','防堵死','信号灯','车型池'];
const TF_PRESETS={
busy:{trafficTarget:24,trafficSpeedScale:1.15,trafficAccelMps2:3.5,trafficSpawnRadiusMeters:220,trafficSpawnMinGapMeters:18},
smooth:{trafficTarget:8,trafficConflictHorizonSec:1.0,trafficYieldSpeedMps:3.0,trafficJunctionSlowMeters:6,trafficFollowRangeMeters:12},
cautious:{trafficFollowRangeMeters:30,trafficConflictHorizonSec:3.0,trafficJunctionSlowMeters:18,trafficJunctionSpeedFactor:0.4,trafficAccelMps2:2.0},
unjam:{trafficStallReleaseSec:1.0,trafficStallBoostSec:2.5,trafficStallBoostSpeedMps:5,trafficYieldSpeedMps:2.0,trafficConflictWidthMeters:2.4}
};
let TF_VAL={}, TF_DIRTY={};
function tfGet(k){ return (TF_PREFIX+k in TF_DIRTY) ? TF_DIRTY[TF_PREFIX+k] : TF_VAL[k]; }
function tfMark(k,v){ TF_DIRTY[TF_PREFIX+k]=v; tfShowPending(); }
function tfShowPending(){
const n=Object.keys(TF_DIRTY).length;
document.getElementById('tfRes').textContent = n ? ('待保存 '+n+' 项 —— 点「保存改动」生效') : '';
}
function tfSyncNum(key){
const box=document.getElementById('tfn_'+key), rng=document.getElementById('tfr_'+key);
if(!box) return;
let v = box.value==='' ? 0 : Number(box.value);
if(rng) rng.value=v;
tfMark(key,v);
}
function tfSyncRange(key){
const box=document.getElementById('tfn_'+key), rng=document.getElementById('tfr_'+key);
if(!rng) return;
const v=Number(rng.value);
if(box) box.value=v;
tfMark(key,v);
}
function renderTraffic(){
const box=document.getElementById('tfList');
let html='';
for(const g of TF_GROUPS){
const rows=TF_META.filter(m=>m[4]===g);
if(!rows.length) continue;
html+='<div style="margin:10px 0 4px;color:#9cf">['+g+']</div>';
html+='<div style="display:grid;grid-template-columns:170px 250px;gap:4px 10px;align-items:center">';
for(const [key,label,type,rng,,tip] of rows){
const v=tfGet(key);
html+='<div style="font-size:12px;color:#cfd8dc" title="'+esc(tip)+'">'+esc(label)+'</div>';
if(type==='bool'){
html+='<div><input type="checkbox" id="tfb_'+key+'"'+(v?' checked':'')+' onchange="tfMark(\''+key+'\',this.checked)"></div>';
} else {
const lo=rng?rng[0]:0, hi=rng?rng[1]:100, st=rng?rng[2]:1;
html+='<div><input type="number" id="tfn_'+key+'" value="'+esc(v)+'" step="'+st+'" style="width:80px" oninput="tfSyncNum(\''+key+'\')">'
+ '<input type="range" id="tfr_'+key+'" min="'+lo+'" max="'+hi+'" step="'+st+'" value="'+esc(v)+'" style="width:150px;vertical-align:middle" oninput="tfSyncRange(\''+key+'\')"></div>';
}
html+='<div class="gm-tip" style="grid-column:1/-1;font-size:11px;color:#8b96a2">'+esc(tip)+'</div>';
}
html+='</div>';
}
box.innerHTML=html;
}
function applyTrafficPreset(){
const name=document.getElementById('tfPreset').value;
if(!name) return;
const p=TF_PRESETS[name]||{};
for(const k of Object.keys(p)) tfMark(k,p[k]);
renderTraffic();
document.getElementById('tfPreset').value='';
}
function undoTraffic(){ TF_DIRTY={}; renderTraffic(); }
async function loadTraffic(){
const r=await fetch('/api/settings'); const s=await r.json();
const a=((s.values||{}).gameplay||{}).aether||{};
TF_VAL={}; for(const k of Object.keys(a)) if(k.indexOf('traffic')===0) TF_VAL[k]=a[k];
TF_DIRTY={};
document.getElementById('tfPath').textContent='文件:'+s.path;
renderTraffic(); tfShowPending();
}
async function saveTraffic(){
const keys=Object.keys(TF_DIRTY);
if(!keys.length){ document.getElementById('tfRes').textContent='没有改动'; return; }
const changes={}; for(const k of keys) changes[k]=TF_DIRTY[k];
const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({changes})});
const j=await r.json();
document.getElementById('tfRes').textContent=JSON.stringify(j,null,2);
if(j.ok) await loadTraffic();
}
/* ---- 人流调参:只列 gameplay.aether.crowd*,外加 8 个地块类型系数 ---- */
const CF_PREFIX='gameplay.aether.';
// [键后缀, 中文名, bool|num, [min,max,step] 或 null, 分组, 说明]
const CF_META=[
['crowdEnabled','启用人群','bool',null,'基础','人群总开关。关掉:密度压0清掉客户端自驱人群 + 停发/回收服务端行人NPC;已在场的点一次『立即推送密度』当场回收'],
['crowdDensity','全局基准密度','num',[0,5,0.05],'基础','总倍率,每个地块都乘它。>1 是否被客户端 clamp 未验证'],
['crowdUseLandPlots','按地块与时辰变化','bool',null,'地块与时辰','关掉后全城恒为基准密度,等于回到 T0-8 之前'],
['crowdHourFloor','深夜密度下限','num',[0,1,0.05],'地块与时辰','凌晨人流曲线掉到 0 时的兜底;设 0 可以看见彻底空场'],
['crowdPlotMissFactor','地块外回退系数','num',[0,2,0.05],'地块与时辰','站在没有地块的位置(海上 / 荒地)时用的系数'],
['crowdPlotSearchMeters','地块搜索半径(米)','num',[0,300,10],'地块与时辰','0 = 只认脚下的多边形,调大则容许略微擦边'],
['crowdExtendsMeters','密度盒半径(米)','num',[20,800,10],'推送范围','一次推多大范围的密度'],
['crowdExtendsHeight','密度盒高度(米)','num',[5,300,5],'推送范围','盒子有多高(垂直方向)'],
['crowdRefreshMoveMeters','移动重推距离(米)','num',[5,200,5],'推送范围','走这么远自动重推一次']
];
const CF_GROUPS=['基础','地块与时辰','推送范围'];
const CF_PRESETS={
quiet:{crowdDensity:0.5},
busy:{crowdDensity:2.0},
even:{crowdUseLandPlots:false}
};
let CR_VAL={}, CR_DIRTY={}, CR_TYPES={};
function crGet(k){ return (CF_PREFIX+k in CR_DIRTY) ? CR_DIRTY[CF_PREFIX+k] : CR_VAL[k]; }
function crTypePath(n){ return CF_PREFIX+'crowdTypeFactors.'+n; }
function crGetType(n){ const p=crTypePath(n); return (p in CR_DIRTY) ? CR_DIRTY[p] : CR_TYPES[n]; }
function crMark(k,v){ CR_DIRTY[CF_PREFIX+k]=v; crShowPending(); }
function crMarkType(n,v){ CR_DIRTY[crTypePath(n)]=v; crShowPending(); }
function crShowPending(){
const n=Object.keys(CR_DIRTY).length;
document.getElementById('cfRes').textContent = n ? ('待保存 '+n+' 项 —— 改动本身已热生效,保存只是写回 json') : '';
}
function crSyncNum(key){
const box=document.getElementById('cfn_'+key), rng=document.getElementById('cfr_'+key);
if(!box) return;
const v = box.value==='' ? 0 : Number(box.value);
if(rng) rng.value=v;
crMark(key,v);
}
function crSyncRange(key){
const box=document.getElementById('cfn_'+key), rng=document.getElementById('cfr_'+key);
if(!rng) return;
const v=Number(rng.value);
if(box) box.value=v;
crMark(key,v);
}
function crSyncType(n){
const box=document.getElementById('cftn_'+n), rng=document.getElementById('cftr_'+n);
if(!rng) return;
const v=Number(rng.value);
if(box) box.value=v;
crMarkType(n,v);
}
function renderCrowd(){
const box=document.getElementById('cfList');
let html='';
for(const g of CF_GROUPS){
const rows=CF_META.filter(m=>m[4]===g);
if(!rows.length) continue;
html+='<div style="margin:10px 0 4px;color:#9cf">['+g+']</div>';
html+='<div style="display:grid;grid-template-columns:170px 250px;gap:4px 10px;align-items:center">';
for(const [key,label,type,rng,,tip] of rows){
const v=crGet(key);
html+='<div style="font-size:12px;color:#cfd8dc" title="'+esc(tip)+'">'+esc(label)+'</div>';
if(type==='bool'){
html+='<div><input type="checkbox" id="cfb_'+key+'"'+(v?' checked':'')+' onchange="crMark(\''+key+'\',this.checked)"></div>';
} else {
const lo=rng?rng[0]:0, hi=rng?rng[1]:100, st=rng?rng[2]:1;
html+='<div><input type="number" id="cfn_'+key+'" value="'+esc(v)+'" step="'+st+'" style="width:80px" oninput="crSyncNum(\''+key+'\')">'
+ '<input type="range" id="cfr_'+key+'" min="'+lo+'" max="'+hi+'" step="'+st+'" value="'+esc(v)+'" style="width:150px;vertical-align:middle" oninput="crSyncRange(\''+key+'\')"></div>';
}
html+='<div class="gm-tip" style="grid-column:1/-1;font-size:11px;color:#8b96a2">'+esc(tip)+'</div>';
}
html+='</div>';
}
box.innerHTML=html;
}
function renderCrowdTypes(){
const box=document.getElementById('cfTypes');
const names=Object.keys(CR_TYPES);
if(!names.length){ box.innerHTML=''; return; }
let html='<div style="margin:10px 0 4px;color:#9cf">[地块类型系数] · 各地块数量见括号</div>';
html+='<div style="display:grid;grid-template-columns:140px 250px;gap:4px 10px;align-items:center">';
for(const n of names){
const v=crGetType(n);
html+='<div style="font-size:12px;color:#cfd8dc">'+esc(n)+'</div>'
+ '<div><input type="number" id="cftn_'+esc(n)+'" value="'+esc(v)+'" step="0.05" min="0" max="3" style="width:80px" oninput="crSyncType(\''+esc(n)+'\')">'
+ '<input type="range" id="cftr_'+esc(n)+'" min="0" max="3" step="0.05" value="'+esc(v)+'" style="width:150px;vertical-align:middle" oninput="crSyncType(\''+esc(n)+'\')"></div>'
+ '<div class="gm-tip" style="grid-column:1/-1;font-size:11px;color:#8b96a2">该类型的密度倍率</div>';
}
html+='</div>';
box.innerHTML=html;
}
function applyCrowdPreset(){
const name=document.getElementById('cfPreset').value;
if(!name) return;
const p=CF_PRESETS[name]||{};
for(const k of Object.keys(p)) crMark(k,p[k]);
renderCrowd();
document.getElementById('cfPreset').value='';
}
function undoCrowd(){ CR_DIRTY={}; renderCrowd(); renderCrowdTypes(); }
async function loadCrowd(){
const r=await fetch('/api/settings'); const s=await r.json();
const a=((s.values||{}).gameplay||{}).aether||{};
CR_VAL={};
for(const k of Object.keys(a)){
const v=a[k];
if(k.indexOf('crowd')!==0) continue;
if(v && typeof v==='object' && !Array.isArray(v)) continue;
CR_VAL[k]=v;
}
CR_TYPES=(a.crowdTypeFactors && typeof a.crowdTypeFactors==='object') ? a.crowdTypeFactors : {};
CR_DIRTY={};
renderCrowd(); renderCrowdTypes(); crShowPending(); probeCrowd();
}
async function saveCrowd(){
const keys=Object.keys(CR_DIRTY);
if(!keys.length){ crShowPending(); return; }
const changes={}; for(const k of keys) changes[k]=CR_DIRTY[k];
const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({changes})});
const j=await r.json();
document.getElementById('cfRes').textContent = j.ok ? ('已保存 '+keys.length+' 项') : ('保存失败: '+(j.message||''));
if(j.ok) await loadCrowd();
}
/* 人群没有 tick:改完配置必须手动推一次,否则只有走到 35 米才会重算。 */
async function pushCrowd(){
try{
const r=await fetch('/api/crowd/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const j=await r.json();
document.getElementById('cfRes').textContent=(j.ok?'✓ ':'✗ ')+(j.message||'');
await probeCrowd();
}catch(e){ document.getElementById('cfRes').textContent='推送失败: '+e; }
}
async function probeCrowd(){
try{
const r=await fetch('/api/crowd/probe'); const j=await r.json();
const el=document.getElementById('cfNow');
if(!j.online){ el.innerHTML='<span class="warn">无玩家会话(请先用游戏客户端登录)</span>'; return; }
const clock=j.explicitTime ? ('面板固定 '+j.hour+' 点') : ('本地时钟 '+j.hour+' 点');
el.innerHTML='脚下:<b style="color:#9cf">'+esc(j.plotName||'(未命中地块)')+'</b>'
+(j.landPlotType?'('+esc(j.landPlotType)+')':'')
+' · '+esc(clock)+' · 时段系数 '+j.hourFactor
+' ⇒ 密度 <b style="color:#9cf">'+esc(j.density)+'</b>';
}catch(e){ document.getElementById('cfNow').textContent='读数失败: '+e; }
}
status(); vehicles(); scenes(); timelines(); weapons(); timeweather(); loadUnknown(); loadRoster(); agents('e'); agents('o'); loadSpirits(); loadCivilians(); loadCivDistricts(); loadFunctionalNpcs(); loadEnemyTerritory(); loadInvasionSpots(); refreshLifeLock(); loadTraffic(); loadCrowd(); loadExtraction(); loadAutoDriveSpeed(); loadStoryDialogs(); loadSwitchShows(); setInterval(status,2000); setInterval(loadExtraction,3000);
/*metro-script*/
/*view-mode-script*/
</script></body></html>
""";
// 顶栏品牌位 / 侧栏底部 / 站点标签图标(favicon)共用同一张 96x96 PNG:
// 模板里写作 /*brand-icon*/ 占位,组装 PageHtml 时再 Replace 进来(见文件末尾)。
private const string BrandIconB64 = "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";
/// <summary>
/// The page actually served: the template above with the metro card spliced in. Keeping the fragments
/// in <see cref="MetroCardParts"/> instead of pasted into the template means the donor panel and this
/// one can still be diffed line by line later.
/// </summary>
private static readonly string PageHtml = PageHtmlTemplate
.Replace("/*metro-css*/", MetroCardParts.Css)
.Replace("/*metro-card*/", MetroCardParts.Card)
.Replace("/*metro-script*/", MetroCardParts.Script)
.Replace("/*view-mode-css*/", AdminViewModeParts.Css)
.Replace("<!--view-mode-card-->", AdminViewModeParts.Card)
.Replace("/*view-mode-script*/", AdminViewModeParts.Script)
.Replace("/*brand-icon*/", BrandIconB64);
}