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.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||||
case NetworkBattleUri.TurnEndFinal 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
|
if (Type == BattleType.Pvp && BothAfterReady())
|
||||||
// PushAsync fires its three-frame burst via FrameEmitted; each emitted
|
{
|
||||||
// frame loops back through HandleFrameAsync → ComputeFrames → routes to
|
// Broadcast TurnEnd + Judge to BOTH. Each client's JudgeOperation ->
|
||||||
// the real participant. Net wire effect: same three pushes as v1.2.
|
// ControlTurnStartPlayer advances the active-player state machine.
|
||||||
result.Add((other, env, false));
|
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;
|
break;
|
||||||
|
|
||||||
case NetworkBattleUri.Retire:
|
case NetworkBattleUri.Retire:
|
||||||
case NetworkBattleUri.Kill:
|
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;
|
Phase = BattleSessionPhase.Terminal;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -220,6 +241,39 @@ public sealed class BattleSession
|
|||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
Body: new BattleFinishBody(Result: BattleResult.Win));
|
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)
|
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
||||||
{
|
{
|
||||||
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
|
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.");
|
"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()
|
private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession()
|
||||||
{
|
{
|
||||||
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());
|
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());
|
||||||
|
|||||||
Reference in New Issue
Block a user