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:
@@ -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>();
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user