Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.RacingTasks.cs

320 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
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));
}