feat(battle-node): PvP TurnEnd broadcast + flipped Retire/Kill result

Pvp TurnEnd/TurnEndFinal broadcasts TurnEnd+Judge to BOTH so each
client's JudgeOperation advances. Pvp Retire/Kill pushes BattleFinish
with flipped result (sender=Lose, other=Win). Scripted Retire keeps
Phase 1 behaviour (sender-only Win via BuildBattleFinishNoContest).
This commit is contained in:
gamer147
2026-06-01 21:51:27 -04:00
parent 72dc1887d9
commit db054205b3
2 changed files with 151 additions and 6 deletions

View File

@@ -130,16 +130,37 @@ public sealed class BattleSession
case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
// Phase 1: forward the player's TurnEnd to the scripted bot. The bot's
// PushAsync fires its three-frame burst via FrameEmitted; each emitted
// frame loops back through HandleFrameAsync → ComputeFrames → routes to
// the real participant. Net wire effect: same three pushes as v1.2.
result.Add((other, env, false));
if (Type == BattleType.Pvp && BothAfterReady())
{
// Broadcast TurnEnd + Judge to BOTH. Each client's JudgeOperation ->
// ControlTurnStartPlayer advances the active-player state machine.
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)
{
// Phase 1 Scripted: forward to bot; bot fires three-frame burst back.
result.Add((other, env, false));
}
// For Bot type, no-op (NoOpBot swallows; client handles its own turn end).
break;
case NetworkBattleUri.Retire:
case NetworkBattleUri.Kill:
result.Add((from, BuildBattleFinishNoContest(), true));
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));
}
Phase = BattleSessionPhase.Terminal;
break;
@@ -220,6 +241,39 @@ public sealed class BattleSession
PlaySeq: null,
Body: new BattleFinishBody(Result: BattleResult.Win));
private MsgEnvelope BuildTurnEndBroadcast() => new(
NetworkBattleUri.TurnEnd,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new TurnEndBody(TurnState: 0));
private MsgEnvelope BuildJudgeBroadcast() => new(
NetworkBattleUri.Judge,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
private MsgEnvelope BuildBattleFinish(BattleResult result) => new(
NetworkBattleUri.BattleFinish,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new BattleFinishBody(Result: result));
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
{
if (env.Body is not RawBody rawBody) return Array.Empty<long>();

View File

@@ -340,6 +340,97 @@ public class BattleSessionDispatchTests
"PvP gameplay forwarding must wait until BOTH sides reach AfterReady.");
}
[Test]
public void Pvp_TurnEnd_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
Assert.That(routes.Count, Is.EqualTo(4));
Assert.That(routes.Select(r => (r.Target, r.Frame.Uri)), Is.EquivalentTo(new[]
{
((IBattleParticipant)a, NetworkBattleUri.TurnEnd),
((IBattleParticipant)b, NetworkBattleUri.TurnEnd),
((IBattleParticipant)a, NetworkBattleUri.Judge),
((IBattleParticipant)b, NetworkBattleUri.Judge),
}));
}
[Test]
public void Pvp_TurnEndFinal_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
Assert.That(routes.Count, Is.EqualTo(4));
Assert.That(routes.Select(r => r.Frame.Uri).Distinct(),
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
}
[Test]
public void Pvp_TurnEnd_when_B_still_AwaitingSwap_drops()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
// B not at AfterReady.
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
Assert.That(routes, Is.Empty);
}
[Test]
public void Pvp_Retire_from_A_pushes_BattleFinish_Lose_to_A_and_Win_to_B()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
Assert.That(routes.Count, Is.EqualTo(2));
var aRoute = routes.Single(r => ReferenceEquals(r.Target, a));
var bRoute = routes.Single(r => ReferenceEquals(r.Target, b));
Assert.That(aRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Lose));
Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Win));
Assert.That(aRoute.NoStock, Is.True);
Assert.That(bRoute.NoStock, Is.True);
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
public void Pvp_Kill_from_A_same_as_Retire()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
public void Scripted_Retire_still_pushes_BattleFinish_Win_to_sender_only()
{
// Regression guard — Phase 1 behavior preserved for Scripted.
var (s, a, _) = NewSession();
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.Win));
}
private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession()
{
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());