320 lines
19 KiB
C#
320 lines
19 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Threading.Tasks;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration; // PrivateServerConfigStore(消融旗标路径解析)
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// Racing-challenge TASK plumbing (R190, ported from the Lyco fork's GameRouter.Task.cs +
|
||
/// TaskLifecycle.cs, adapted to v2.1's own task contracts in Methods.Game).
|
||
///
|
||
/// WHY EVERY CHALLENGE TASK MUST BE PUBLISHED UP FRONT
|
||
/// The 挑战面板 (S_ChallengeStatementPanel.OnStartChallengeButtonClick) starts a run with
|
||
/// <c>gTaskManager:SetCurrentTask(self.taskId, ...)</c> — a PURE CLIENT-SIDE call. gTaskManager can
|
||
/// only build the runtime node when the id is already present in the SyncPlayerAllTask list
|
||
/// published at world entry. Otherwise it logs
|
||
/// "#NoCreateIssue 当前任务找不到对应的数据" and the challenge silently does nothing —
|
||
/// the exact "点开始挑战没后续" symptom. (Lyco measured 2026-09-21: 60000147/60000150 both hit it;
|
||
/// challenge 1003 "王牌车神·城中之夜" → RelatedTask 60000147, raid 23300888.)
|
||
///
|
||
/// The AskAcceptAndSetCurrentTask handler (63961538) is the other half: the client sends it when a
|
||
/// task is accepted, and for a racing task we additionally fire the server-side auto-start
|
||
/// (ScheduleRacingAutoStart → GameRouter.RacingAutoStart.cs). Reply-neutral: still returns empty OK.
|
||
///
|
||
/// ⛑ 回滚:删掉 Enabled4229938MethodIds 里的 AskAcceptAndSetCurrentTask 一行即可
|
||
/// (handler 不注册 = 走 typed-default 兜底),本文件的其余代码保留无害。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>ChallengeConfig.ChallengeType for a racing challenge (竞速挑战).</summary>
|
||
private const uint RacingChallengeType = 2;
|
||
|
||
// ── ⛑ 消融旗标(R191b,临时诊断,定位「开始挑战卡死闪退」用,定位后整块删除)──
|
||
// 旗标文件:config\racing-ablation.txt,**一行一个词**(可多词共存):
|
||
// all = 全链(默认;一个词都没有/文件不存在都按 all)
|
||
// norace = 只推任务包(SyncCurrentTask + SyncPlayerAllTask),不点火 —— 崩 ⇒ 任务包有罪
|
||
// notask = 不推任务包,照常点火(AI 车 + zone + 倒计时)—— 不崩 ⇒ 任务包有罪
|
||
// noai = 任务包 + 点火,但 AI 车 0 辆 —— 不崩 ⇒ AI 车包群有罪
|
||
// noguard = **客户端**用(generate_client_config_patch.js 读同一文件,摘掉 racing_guard.lua
|
||
// 整块);服务端只忽略它,但必须容忍它**和 noai 同行共存**。
|
||
// ⛔ R197 修正:原实现是 `v.Trim() is "all" or …`(整串比对)⇒ 一旦文件里多一行
|
||
// `noguard`,Trim 后 = "noai\nnoguard" ≠ "noai" ⇒ **静默退回 all**(AI 车又活过来)。
|
||
// 现改为**按行/按词扫描**,多旗标共存安全;客户端 JS 侧本来就用 /\bnoguard\b/ 按词匹配。
|
||
// ⛔⛔ R203c 追加铁律(新铁律 252):**旗标文件里绝不出现旗标词本体**(注释/备份名都不行)!
|
||
// 本服务端是**整词 token** 匹配(`. + ) 。` 不是分隔符 ⇒ 注释里 `x+noguard)。` 恰好不命中),
|
||
// 但**客户端 JS 是 `\b` 词边界正则** ⇒ `+`/`)` 是非词字符 ⇒ 注释里的 `noguard` **照样命中**
|
||
// ⇒ guard 被**静默关掉**、与意图相反且无报错。需要说明就只写档名,别写旗标本身。
|
||
// ★ R295 追加:`noalltask`(R236)与 `notimer`(R295)走**独立旗标**(`RacingAblationWordsLazy`),
|
||
// 不在本互斥链里 ⇒ 可与 noai 共存,一次只动一个变量。
|
||
// 绝对路径是有意的:服务端工作目录随启动方式漂移,这个旗标是本 fork 的临时诊断件。
|
||
private static readonly Lazy<string> RacingAblationModeLazy = new(() =>
|
||
{
|
||
try
|
||
{
|
||
var p = PrivateServerConfigStore.ResolveProjectPath(Path.Combine("config", "racing-ablation.txt"));
|
||
if (!File.Exists(p)) return "all";
|
||
// ★ R203c(铁律 252):先剥掉**整行 # 注释**再分词,与客户端 JS 口径一致
|
||
// (防止「注释里写了旗标词」被误当成真旗标;两边容忍度必须一致)。
|
||
var effective = string.Join('\n', File.ReadAllText(p)
|
||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||
.Where(line => !line.TrimStart().StartsWith("#")));
|
||
var words = effective
|
||
.Split(new[] { '\r', '\n', ' ', '\t', ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(w => w.Trim().ToLowerInvariant())
|
||
.ToHashSet();
|
||
// 语义模式:后几者互斥,优先取更"缩"的那个(诊断时希望更小范围生效)。
|
||
if (words.Contains("norace")) return "norace";
|
||
if (words.Contains("notask")) return "notask";
|
||
// ★ R203c 新增:noaigrid = 照常 spawn AI 实体,但 zone 里推**空** participant 网格。
|
||
// 用来把「AI 车实体 spawn 的副作用」和「网格里 5 条 participant 数据」分开。
|
||
if (words.Contains("noaigrid")) return "noaigrid";
|
||
if (words.Contains("noai")) return "noai";
|
||
return "all";
|
||
}
|
||
catch { return "all"; }
|
||
});
|
||
internal static string RacingAblationMode => RacingAblationModeLazy.Value;
|
||
|
||
// ── ★ R236 独立旗标 `noalltask`(**不是**互斥模式,可与 noai 共存)──────────────────
|
||
// 崩因锁定:`SyncPlayerAllTask`(64260853, ~1148 B) 在 accept 后 ~16ms 送达客户端,客户端
|
||
// 要么 "deserialize failed parameter=taskInfos"(包被丢弃,安全),要么**硬崩**。
|
||
// 证据:R236 崩轮 19:38:24.247 推该包 ⇒ 心跳在 24.44 永久缺席;不崩轮(prev3 19:03/19:05)
|
||
// 客户端打出 `deserialize failed … methodId=64260853 parameter=taskInfos`。服务端 `taskPanel.enabled=false`
|
||
// 已让**通用**任务发布路径早退 ⇒ 该包只可能来自本文件(赛车挑战专用)。
|
||
// ⛔ 为什么不用现成的 `notask`:它走**互斥模式链**(norace/notask/noaigrid/noai 同级),
|
||
// 启用后会把当前的 `noai` 顶掉 ⇒ AI 车重新 spawn = 一次动两个变量。本旗标独立求值,不动其它。
|
||
// 判读:加了它**不崩** ⇒ 就是该包;**仍崩** ⇒ 干净排除,回查 SyncCurrentTask / 点火链。
|
||
// 回滚:从 config\racing-ablation.txt 删掉 `noalltask` 即可(本文件不用改)。
|
||
private static readonly Lazy<HashSet<string>> RacingAblationWordsLazy = new(() =>
|
||
{
|
||
try
|
||
{
|
||
var p = PrivateServerConfigStore.ResolveProjectPath(Path.Combine("config", "racing-ablation.txt"));
|
||
if (!File.Exists(p)) return new HashSet<string>();
|
||
var effective = string.Join('\n', File.ReadAllText(p)
|
||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||
.Where(line => !line.TrimStart().StartsWith("#")));
|
||
return effective
|
||
.Split(new[] { '\r', '\n', ' ', '\t', ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(w => w.Trim().ToLowerInvariant())
|
||
.ToHashSet();
|
||
}
|
||
catch { return new HashSet<string>(); }
|
||
});
|
||
|
||
/// <summary>true ⇒ 跳过 `SyncPlayerAllTask`(保留 `SyncCurrentTask` 与点火链)。</summary>
|
||
|
||
// ── ★★★★★ R334 独立旗标 `trackbytaskoff`(**不是**互斥模式,可与 noai 等共存)──────────
|
||
// 关掉「按 task/EventId 精确选轨」(GameRouter.RacingAutoStart.cs 的 TryPickRacingTrack),
|
||
// 回到 R334 之前的「按 raid 猜一条」。存在理由:R334 会把「事件链已知、但没有成套
|
||
// 检查点表的赛道」的任务一律**不启动**(打一行写明原因的日志)。要单独回退这一条就用本旗标。
|
||
// 回滚:从 config\racing-ablation.txt 删掉 `trackbytaskoff` 即可(本文件不用改)。
|
||
internal static bool RacingTrackSelectStrict => !RacingAblationWordsLazy.Value.Contains("trackbytaskoff");
|
||
internal static bool RacingSkipPlayerAllTask => RacingAblationWordsLazy.Value.Contains("noalltask");
|
||
|
||
// ── ★ R295 独立旗标 `notimer`(**不是**互斥模式,可与 noai 共存)───────────────
|
||
// 关掉「中上比赛计时器」那一次 `SyncOpenPanelCountDown` 推送
|
||
// (见 GameRouter.RacingAutoStart.cs 的 `RacingTimerPushCountDown`)。
|
||
//
|
||
// 为什么单独开一个旗标:R253/R254 一直把中上那块判成「native HUD,不需要 Lua 面板」,
|
||
// 而 R295 的实机证据(Player.log 21:01 那局装载清单只有 `S_ChallengeCountDownPanel`、
|
||
// 从来没有 `S_ChallengeCountDownRacingPanel`,且它的存活时长恰好 = 我们喂的 countDown=3s)
|
||
// 说明中上那块**确实是被 `SyncOpenPanelCountDown` 打开的**,只是我们只喂了 3 秒。
|
||
// 要判定「这一次推送到底有没有用」,必须能一键关掉它 ⇒ 这个旗标。
|
||
//
|
||
// ⛔ 不用 `notask`:那条走**互斥模式链**(norace/notask/noaigrid/noai 同级),
|
||
// 启用后会把当前的 `noai` 顶掉 = 一次动两个变量(同 R236 对 noalltask 的取舍)。
|
||
// 回滚:从 config\racing-ablation.txt 删掉 `notimer` 即可(本文件不用改)。
|
||
internal static bool RacingSkipRaceTimer => RacingAblationWordsLazy.Value.Contains("notimer");
|
||
|
||
// ── ★★★ R303 起:上车闸**默认关闭**(改回显式 `boarding` 才开)───────────────
|
||
//
|
||
// 【为什么反过来了 —— 官方抓包 10/10 次实测,2026-09-23/24,见 `MEMORY.racing.md` R303】
|
||
// 官方**没有**「上车才倒计时」。官方的完整时序是:
|
||
// 开车(在车里)→ 声明页 `AskNewChallengeRecord(ChallengeId:1003)`
|
||
// → 点开始挑战 `AskAcceptAndSetCurrentTask`
|
||
// → 服务器 0.05 s 内刷 5 台 AI 车(`SyncSpawnVehicle` ×5)
|
||
// → **+1.2 s** 推 `SyncGameGroundZoneInfo`(DISPLAY) → **+12 ms** 推 `SyncOpenPanelCountDown`
|
||
// → **+3.0 s** 推 `SyncGameGroundZoneInfo`(GAMESTART)。
|
||
// 那 10 次里「接受 → 倒计时」之间**没有任何 exit/enter**(`SyncPlayerExitVehicle`
|
||
// 在 9/23–9/24 全部会话里 0 命中);其中 8 次接受那一刻玩家**正开着车**(氮气连喷)。
|
||
// 而且官方**要求**你在车里:赛前声明页对赛车挑战会先调
|
||
// `gRacerManager:TrySetCompetitionVehicle(trackId)`,它取 `CurrentPlayerBaseVehicle`,
|
||
// 取不到就报「未找到玩家载具,无法设置赛事车辆!」。
|
||
// ⇒ 「下车再上车才倒计时」不是官方行为(视频里那个效果另有来源)。
|
||
//
|
||
// 【现在的语义】默认 = 官方(不等,接受即走 1.2 s/3 s 时序)。想要「上车启动」的手感,
|
||
// 往 config\racing-ablation.txt 加一行 `boarding` 即可(本文件不用改)。
|
||
// ⚠ 非官方:开着车点挑战时闸门会立刻放行(本来就在车里),只有「在车外点挑战」
|
||
// 才会真的等 —— 这正是 R301 实机看到的现象。
|
||
internal static bool RacingStartRequiresBoarding => RacingAblationWordsLazy.Value.Contains("boarding");
|
||
|
||
// ── ★★★★★ R309(2026-10-03):开赛「连人带车瞬移到起点」的开关 ──────────────────
|
||
//
|
||
// 【为什么是我们自己实现(用户授权)】官方抓包 `packets-*.log` 10/10 证明**官方不传送**:
|
||
// 官方要求玩家**自己开车到起点**再点挑战 —— 赛前声明页对赛车挑战先调
|
||
// `gRacerManager:TrySetCompetitionVehicle(trackId)`,它取
|
||
// `gDriveVehiclesManager.cs_manager.CurrentPlayerBaseVehicle`,取不到就报
|
||
// 「未找到玩家载具,无法设置赛事车辆!」⇒ 官方前置条件 = **你已经在车里、且车在起点**。
|
||
// 私服里玩家常常就地点挑战 ⇒ 人和车留在原地(赛道起点 cp00 在 (2988, -0.1, 969),
|
||
// 玩家出生点离它约 49 m 但那只是「出生点」;就地点挑战时可能差 2.9 km)⇒ 321 倒计时
|
||
// 照走,屏幕上却是别的街景、也没有起跑线。
|
||
// 用户原话:「…就直接最简单的,在面板点击开始挑战后把角色连人带车一起传送到比赛起点,
|
||
// 然后正常倒计时321,开始比赛就行」+「如果官方没有,就我们自己实现一个,这个我允许自创」。
|
||
//
|
||
// 【实现】见 `GameRouter.RacingAutoStart.cs` 的 `TeleportPlayerAndVehicleToRacingStartAsync`:
|
||
// 人走 `SyncUnitPositionAndFacing`(68030312)(同 GM 传送,无 loading、不打断开赛时序),
|
||
// 车走 `SyncTeleportVehicle`(68088911) `Reset=true`(同改装店钉车)。
|
||
//
|
||
// 【回滚】往 `config/racing-ablation.txt` 加一行 `noteleport` 即可(本文件不用改)。
|
||
// ⚠ 与 `boarding`(上车闸)是两件事:那条 =「等玩家上车」,这条 =「把玩家挪到起点」。
|
||
internal static bool RacingSkipTeleportToStart => RacingAblationWordsLazy.Value.Contains("noteleport");
|
||
|
||
/// <summary>Every task id owned by a racing challenge (ChallengeConfig.ChallengeType == 2).</summary>
|
||
private static readonly Lazy<uint[]> RacingChallengeTaskIds = new(() =>
|
||
ClientConfigRepository.Challenges()
|
||
.Where(c => c.ChallengeType == RacingChallengeType)
|
||
.SelectMany(c => c.RelatedTasks)
|
||
.Where(id => id != 0)
|
||
.Distinct()
|
||
.OrderBy(id => id)
|
||
.ToArray());
|
||
|
||
/// <summary>
|
||
/// AskAcceptAndSetCurrentTask(63961538): (uint taskId, uint spirit, bool ignoreSwitchSpirit)
|
||
/// -> empty return. Re-affirms "this task is the currently tracked one" (GPS arming) and, for
|
||
/// racing tasks, schedules the zone auto-start.
|
||
/// </summary>
|
||
[Handler(MethodId.AskAcceptAndSetCurrentTask)]
|
||
private async Task AskAcceptAndSetCurrentTaskAccept(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var b = msg.Body;
|
||
uint taskId = b.Length >= 4 ? ReadU32(b, 0) : 0u;
|
||
|
||
var ablation = RacingAblationMode;
|
||
conn.Log.Info($"[TASK][ABLATION] mode={ablation} taskId={taskId}");
|
||
|
||
// EventId is per-task; v2.1 has no demo-task event registry, so 0 for everything
|
||
// (a wrong EventId would make the client re-render the wrong panel event).
|
||
const uint eventId = 0;
|
||
|
||
if (ablation != "notask")
|
||
{
|
||
// 1) Make this task the current tracked task (GPS arming).
|
||
await conn.NotifyAsync(MethodId.SyncCurrentTask, UxSerializer.Serialize(new GameMethods.SyncCurrentTaskData4229938
|
||
{
|
||
Type = 0, // CurrentTaskType.Primary
|
||
TaskId = taskId,
|
||
EventId = eventId,
|
||
FirstTime = false,
|
||
Reason = 2, // ChangeCurrentTaskReason.Client
|
||
}));
|
||
|
||
// 2) Reflect "accepted / underway" in the panel list.
|
||
// ★ R236:`noalltask` ⇒ 单独跳过本包(崩因嫌疑包,~1148 B),SyncCurrentTask 保留。
|
||
if (!RacingSkipPlayerAllTask)
|
||
{
|
||
await conn.NotifyAsync(MethodId.SyncPlayerAllTask, UxSerializer.Serialize(
|
||
BuildChallengeTaskPanelBody(taskId)));
|
||
|
||
conn.Log.Info($"[TASK] AskAcceptAndSetCurrentTask taskId={taskId} -> current + panel updated "
|
||
+ $"(racingChallengeTasks={RacingChallengeTaskIds.Value.Length})");
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Info("[TASK][ABLATION] noalltask: SyncPlayerAllTask SKIPPED "
|
||
+ "(SyncCurrentTask kept; this is the R236 crash-suspect packet)");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Info("[TASK][ABLATION] notask: SyncCurrentTask + SyncPlayerAllTask SKIPPED (ignition still fires)");
|
||
}
|
||
|
||
// 3) Racing challenges additionally need the server to stage the GameGround zone — the
|
||
// client never sends AskStartCompetitionRacing in offline mode and 开始挑战 is a
|
||
// local-only call. Reply-neutral fire-and-forget: see GameRouter.RacingAutoStart.cs.
|
||
if (ablation == "norace")
|
||
{
|
||
conn.Log.Info("[TASK][ABLATION] norace: ignition chain SKIPPED (task packets alone were pushed; "
|
||
+ "client crashed here ⇒ task packets guilty)");
|
||
}
|
||
else
|
||
{
|
||
ScheduleRacingAutoStart(msg.Context, taskId);
|
||
}
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// SyncPlayerAllTask body carrying the racing-challenge task nodes. The accepted task
|
||
/// (<paramref name="currentTaskId"/>, 0 = none) becomes CurrentTask and is marked
|
||
/// Accepted+Underway; every other racing task is registered as an Accepted node only —
|
||
/// which one becomes CURRENT is decided by the client when the player hits 开始挑战.
|
||
/// </summary>
|
||
private static GameMethods.SyncPlayerAllTaskData4229938 BuildChallengeTaskPanelBody(uint currentTaskId)
|
||
{
|
||
var body = new GameMethods.SyncPlayerAllTaskData4229938
|
||
{
|
||
CurrentTask = currentTaskId,
|
||
LoginGameServer = true,
|
||
};
|
||
|
||
foreach (var taskId in RacingChallengeTaskIds.Value)
|
||
{
|
||
var node = BuildChallengeTaskViewData(taskId);
|
||
if (taskId == currentTaskId)
|
||
node.State = GameMethods.TaskState4229938.Accepted;
|
||
body.TaskInfos.Add(node);
|
||
}
|
||
|
||
return body;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Minimal runtime node for a racing-challenge task.
|
||
///
|
||
/// TaskConfig rows for these tasks carry NO Counter and NO EventObjective — the whole race is
|
||
/// authored in the raid's Spoon graph (60000147 → RelatedRaid 23300888). The client refuses a
|
||
/// task whose Counters list is empty ("一个计数器都没有"), so one placeholder counter is
|
||
/// supplied; the goals the player actually sees (第1名 / 前3名 / 180秒内) come from
|
||
/// ChallengeConfig.CountersDescription and are tracked by the challenge system.
|
||
/// </summary>
|
||
private static GameMethods.PlayerTaskInfo4229938 BuildChallengeTaskViewData(uint taskId)
|
||
{
|
||
return new GameMethods.PlayerTaskInfo4229938
|
||
{
|
||
TaskId = taskId,
|
||
CounterValues = { 0 },
|
||
Counters =
|
||
{
|
||
new GameMethods.TaskCounter4229938
|
||
{
|
||
Index = 1,
|
||
Value = 0,
|
||
ConfigValue = 1,
|
||
Parent = -1, // root counter, no parent
|
||
},
|
||
},
|
||
State = GameMethods.TaskState4229938.Accepted,
|
||
ActiveFunctionValue =
|
||
{
|
||
[0] = 1, // CounterDes
|
||
[2] = 1, // GPS
|
||
},
|
||
};
|
||
}
|
||
|
||
private static uint ReadU32(ReadOnlySpan<byte> b, int off)
|
||
=> (uint)(b[off] | (b[off + 1] << 8) | (b[off + 2] << 16) | (b[off + 3] << 24));
|
||
}
|