refactor(battle-node): unify TurnEndFinal / Retire-Kill / gameplay-forwarder dispatch across types
Three dispatch arms had Type-based branching that was either wrong or
unnecessary. Unified per the audit doc's recommended order, grounded in
verified facts about each participant's PushAsync.
(1) TurnEndFinal — was branched: PvP broadcast TurnEnd+Judge (wrong on a
game-end signal); Scripted pushed BattleFinish(LifeWin). Unified:
- forward the envelope to other (matches prod TK2 capture
battle-traffic_tk2_regular.ndjson:273 — loser receives TurnEndFinal
from server before BattleFinish)
- push BattleFinish(LifeWin) to from (winner)
- push BattleFinish(LifeLose) to other (loser)
- Phase → Terminal
Requires ScriptedBotParticipant.PushAsync to no longer fire its 3-frame
burst on TurnEndFinal (previously it reacted to both TurnEnd and
TurnEndFinal). The dispatch arm now owns TurnEndFinal's response; the
bot reacting too would race with the BattleFinish push. Bot still
fires on regular TurnEnd as before.
(2) Retire / Kill — was branched: PvP pushed Lose=0 (NotFinish) /
Win=1 (NoContest); Scripted pushed BuildBattleFinishNoContest() (Win=1).
Both shipped wrong RESULT_CODE values; the audit doc's outstanding item
documented this. Unified:
- push BattleFinish(RetireLose=106) to from (the retirer)
- push BattleFinish(RetireWin=105) to other (the survivor)
- Phase → Terminal
Added RetireWin=105 / RetireLose=106 to BattleResult enum with the
same player-perspective convention.
(3) PvP gameplay forwarder (TurnStart / PlayActions / Echo /
TurnEndActions / JudgeResult) — had a redundant `Type == BattleType.Pvp`
guard. Verified that BothAfterReady() is naturally only true when both
participants are RealParticipant (ScriptedBot / NoOpBot don't implement
IHasHandshakePhase per RealParticipant.cs:20-23 / Participants/*.cs grep).
Dropped the redundant guard.
Bot type still has its dedicated InitBattle/Loaded/TurnEnd arms above
the unified ones, so Bot-specific behavior is unchanged.
Tests: 177 battle-node tests passing.
- Updated 9 tests to match the unified dispatch (paired BattleFinish
pushes, correct RESULT_CODE values, forwarded TurnEndFinal envelope).
- ScriptedBotParticipantTests.PushAsync_TurnEndFinal_* rewritten to
assert the bot does NOT fire on TurnEndFinal (was asserting it did).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -54,15 +54,21 @@ public enum BattleResult
|
||||
/// Wire-equivalent of <c>RESULT_CODE.Invalid = 2</c>. Don't use for new code.</summary>
|
||||
Consistency = 2,
|
||||
|
||||
/// <summary>Player won by reducing opponent's life to 0. Pushed by Scripted mode
|
||||
/// on the player's <c>TurnEndFinal</c> emit. Routes through the client switch to
|
||||
/// <summary>Player won by reducing opponent's life to 0. Pushed to the winner
|
||||
/// on <c>TurnEndFinal</c>. Routes through the client switch to
|
||||
/// <c>InitiateGameEndSequence(hasWon: true)</c>.</summary>
|
||||
LifeWin = 101,
|
||||
|
||||
/// <summary>Player lost by their own life dropping to 0. Not currently emitted
|
||||
/// by Scripted mode — the bot can't kill the player — but listed for completeness
|
||||
/// and for the prod TK2 capture at
|
||||
/// <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson:274</c> (a loss the
|
||||
/// player suffered to a real opponent).</summary>
|
||||
/// <summary>Player lost by their own life dropping to 0. Pushed to the loser on
|
||||
/// the opponent's <c>TurnEndFinal</c>. Prod TK2 capture at
|
||||
/// <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson:274</c> is a loss
|
||||
/// shown to the player from a real opponent's lethal.</summary>
|
||||
LifeLose = 102,
|
||||
|
||||
/// <summary>Player won because opponent retired. Pushed to the survivor on
|
||||
/// <c>Retire</c>/<c>Kill</c>. Same player-perspective convention as the Life codes.</summary>
|
||||
RetireWin = 105,
|
||||
|
||||
/// <summary>Player lost by retiring. Pushed to the retirer on <c>Retire</c>/<c>Kill</c>.</summary>
|
||||
RetireLose = 106,
|
||||
}
|
||||
|
||||
@@ -239,50 +239,29 @@ public sealed class BattleSession
|
||||
// Bot type: no-op (NoOpBot swallows; client handles its own turn end).
|
||||
break;
|
||||
|
||||
// TurnEndFinal: client signals the player's FINAL turn is over (they hit a
|
||||
// game-end condition — usually killed opponent's leader). The client computes
|
||||
// win/loss locally; the server's job is just to push back a wire BattleFinish
|
||||
// so the client's BattleFinishToOpponentDisConnectChecker doesn't fire.
|
||||
// Reference: prod TK2 capture battle-traffic_tk2_regular.ndjson:273-274 shows
|
||||
// TurnEndFinal followed immediately by BattleFinish.
|
||||
// TurnEndFinal: client signals the player's FINAL turn is over (game-end
|
||||
// condition met, usually killed opponent's leader). Unified across types:
|
||||
// forward the envelope to other (matches prod TK2 capture
|
||||
// battle-traffic_tk2_regular.ndjson:273 — loser-side receives TurnEndFinal
|
||||
// from server before BattleFinish), then push BattleFinish per-side with
|
||||
// player-perspective codes (LifeWin to winner, LifeLose to loser).
|
||||
// ScriptedBotParticipant no longer reacts to TurnEndFinal (only TurnEnd) —
|
||||
// this dispatch arm owns it. NoOpBotParticipant swallows. Phase → Terminal
|
||||
// so the RunAsync cascade doesn't synthesize a follow-up BattleFinish.
|
||||
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
if (Type == BattleType.Pvp && BothAfterReady())
|
||||
{
|
||||
// PvP: same broadcast as regular TurnEnd. The actual game-end push (a
|
||||
// separate BattleFinish frame to the survivor) is driven by the loser's
|
||||
// Retire/Kill or the disconnect cascade in RunAsync — not here.
|
||||
var turnEndBroadcast = BuildTurnEndBroadcast();
|
||||
var judgeBroadcast = BuildJudgeBroadcast();
|
||||
result.Add((from, turnEndBroadcast, false));
|
||||
result.Add((other, turnEndBroadcast, false));
|
||||
result.Add((from, judgeBroadcast, false));
|
||||
result.Add((other, judgeBroadcast, false));
|
||||
}
|
||||
else if (Type == BattleType.Scripted)
|
||||
{
|
||||
// Push BattleFinish with LifeWin=101 — names are from the player's
|
||||
// perspective: "I won by life". Verified end-to-end via
|
||||
// FinishBattleEffect:1289-1315 → InitiateGameEndSequence(hasWon: true) →
|
||||
// WIN UI. Don't forward to bot (no next turn). Phase → Terminal so the
|
||||
// RunAsync cascade doesn't synthesize a follow-up BattleFinish.
|
||||
result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true));
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
}
|
||||
// Bot type: no-op (rank-battle finish is HTTP-driven via /ai_*/finish).
|
||||
result.Add((other, env, false));
|
||||
result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true));
|
||||
result.Add((other, BuildBattleFinish(BattleResult.LifeLose), true));
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
break;
|
||||
|
||||
// Retire / Kill: sender concedes (Retire) or the client requested an immediate
|
||||
// terminate (Kill). Unified across types: push BattleFinish per-side with the
|
||||
// proper retire codes. Bots swallow their push (no real-opponent state).
|
||||
case NetworkBattleUri.Retire:
|
||||
case NetworkBattleUri.Kill:
|
||||
if (Type == BattleType.Pvp)
|
||||
{
|
||||
result.Add((from, BuildBattleFinish(BattleResult.Lose), true));
|
||||
result.Add((other, BuildBattleFinish(BattleResult.Win), true));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scripted (and future Bot) — sender wins by default (no real opponent).
|
||||
result.Add((from, BuildBattleFinishNoContest(), true));
|
||||
}
|
||||
result.Add((from, BuildBattleFinish(BattleResult.RetireLose), true));
|
||||
result.Add((other, BuildBattleFinish(BattleResult.RetireWin), true));
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
break;
|
||||
|
||||
@@ -304,14 +283,17 @@ public sealed class BattleSession
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
// --- PvP gameplay forwarding (post-AfterReady).
|
||||
// Order matters: this MUST come after the FakeOpponentViewerId arms so
|
||||
// Scripted bot emissions don't fall into the PvP forwarder.
|
||||
case NetworkBattleUri.TurnStart when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.PlayActions when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.Echo when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.TurnEndActions when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.JudgeResult when Type == BattleType.Pvp && BothAfterReady():
|
||||
// Gameplay-frame forwarding (post-AfterReady). Unified across types:
|
||||
// BothAfterReady() is only true when both participants are RealParticipants
|
||||
// (ScriptedBot/NoOpBot don't implement IHasHandshakePhase so their Phase is
|
||||
// always null), so this arm naturally fires for PvP only. Order matters:
|
||||
// this MUST come after the FakeOpponentViewerId arms so Scripted bot
|
||||
// emissions don't fall into this forwarder.
|
||||
case NetworkBattleUri.TurnStart when BothAfterReady():
|
||||
case NetworkBattleUri.PlayActions when BothAfterReady():
|
||||
case NetworkBattleUri.Echo when BothAfterReady():
|
||||
case NetworkBattleUri.TurnEndActions when BothAfterReady():
|
||||
case NetworkBattleUri.JudgeResult when BothAfterReady():
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
|
||||
@@ -37,9 +37,12 @@ public sealed class ScriptedBotParticipant : IBattleParticipant
|
||||
|
||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
||||
{
|
||||
// v1.2 behavior: react to the player's TurnEnd / TurnEndFinal with the
|
||||
// three-frame burst. Everything else is silently swallowed.
|
||||
if (envelope.Uri is NetworkBattleUri.TurnEnd or NetworkBattleUri.TurnEndFinal)
|
||||
// React to the player's TurnEnd with the three-frame burst (TurnStart / TurnEnd /
|
||||
// Judge) — that's the v1.2 "scripted bot takes its turn" behavior. Everything else
|
||||
// (including TurnEndFinal) is silently swallowed: TurnEndFinal is the player's
|
||||
// game-end signal and is handled directly by the BattleSession dispatch arm, which
|
||||
// pushes BattleFinish per-side; the bot doesn't need to react.
|
||||
if (envelope.Uri is NetworkBattleUri.TurnEnd)
|
||||
{
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user