4826 lines
349 KiB
C#
4826 lines
349 KiB
C#
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>)。
|
||
/// ⛑ 必须 > 0 —— <c>OnDropPoliceJobExp</c> 里 <c>Money>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 <= 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>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>,只在「进世界」和「移动
|
||
>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 · 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==='&'?'&':c==='<'?'<':'>'); }
|
||
/* ---- 车流调参:只列 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);
|
||
}
|