feat(battle-node): Bot dispatch arms in ComputeFrames
Three new arms gated on Type == BattleType.Bot, placed before the existing PvP / Scripted arms: - InitBattle → ack to sender (no Matched push — client uses AIBattleStart HTTP data) - Loaded → silent (no BattleStart, no Deal — client short-circuits to GotoBattle) - TurnEnd / TurnEndFinal → Judge to sender only (not broadcast) Other URIs in Bot mode fall through existing arms: Swap is Type-agnostic (per-sender SwapResponse + Ready), Retire/Kill hits the existing Scripted no-contest BattleFinish(Win), gameplay forwarders are gated on Pvp so Bot's PlayActions/Echo/etc. fall through default (drop). 8 new dispatch tests cover the wire contract. Reference: docs/api-spec/in-battle/ai-passive.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -135,6 +135,38 @@ public sealed class BattleSession
|
|||||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle;
|
phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// --- Phase 3 Bot arms — placed BEFORE the existing handshake arms so they
|
||||||
|
// win pattern matching on Type == Bot. Bot mode: ack handshake, silent Loaded,
|
||||||
|
// Judge-to-sender on TurnEnd. The rest reuse Scripted's arms (Retire/Kill ->
|
||||||
|
// BattleFinishNoContest, Swap -> per-sender response, default -> drop).
|
||||||
|
// Reference: docs/api-spec/in-battle/ai-passive.md.
|
||||||
|
|
||||||
|
case NetworkBattleUri.InitBattle
|
||||||
|
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
|
||||||
|
// Ack only — NO Matched push. Client uses AIBattleStart HTTP data for
|
||||||
|
// the lobby plates instead of the Matched envelope.
|
||||||
|
result.Add((from, BuildAck(NetworkBattleUri.InitBattle), true));
|
||||||
|
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NetworkBattleUri.Loaded
|
||||||
|
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
|
||||||
|
// Silent — no BattleStart, no Deal. Client's AINetworkBattleManager
|
||||||
|
// short-circuits to its local AI flow after Loaded; the server has
|
||||||
|
// nothing to contribute.
|
||||||
|
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NetworkBattleUri.TurnEnd
|
||||||
|
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||||
|
case NetworkBattleUri.TurnEndFinal
|
||||||
|
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||||
|
// Judge to sender ONLY (not broadcast — there's no real other side).
|
||||||
|
// The client's JudgeOperation → ControlTurnStartPlayer flips back to
|
||||||
|
// the local AI's turn after this Judge arrives.
|
||||||
|
result.Add((from, BuildJudgeBroadcast(), false));
|
||||||
|
break;
|
||||||
|
|
||||||
case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
|
case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
|
||||||
// Phase 1: push Matched only to the "real" participant. The session reads
|
// Phase 1: push Matched only to the "real" participant. The session reads
|
||||||
// selfInfo from from.Context and oppoInfo from other.Context (the scripted
|
// selfInfo from from.Context and oppoInfo from other.Context (the scripted
|
||||||
|
|||||||
@@ -431,6 +431,140 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.Win));
|
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.Win));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
||||||
|
{
|
||||||
|
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());
|
||||||
|
var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, NoOpBotContext());
|
||||||
|
var s = new BattleSession("bid-bot-1", BattleType.Bot, a, b, NullLogger<BattleSession>.Instance);
|
||||||
|
return (s, a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MatchContext NoOpBotContext() => new(
|
||||||
|
SelfDeckCardIds: Array.Empty<long>(),
|
||||||
|
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
||||||
|
CountryCode: "", UserName: "Bot", SleeveId: "0",
|
||||||
|
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 0);
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Bot_InitNetwork_acks_to_sender()
|
||||||
|
{
|
||||||
|
var (s, a, _) = NewBotSession();
|
||||||
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
|
||||||
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||||
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
||||||
|
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Bot_InitBattle_acks_to_sender_with_no_Matched_push()
|
||||||
|
{
|
||||||
|
var (s, a, _) = NewBotSession();
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
|
||||||
|
Assert.That(routes.Count, Is.EqualTo(1), "Bot InitBattle is ack-only, no Matched push.");
|
||||||
|
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||||
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle),
|
||||||
|
"Expected an ack envelope for InitBattle, NOT a Matched envelope.");
|
||||||
|
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Bot_Loaded_produces_no_routes_but_advances_phase()
|
||||||
|
{
|
||||||
|
var (s, a, _) = NewBotSession();
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
|
||||||
|
Assert.That(routes, Is.Empty, "Bot Loaded is silent.");
|
||||||
|
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap),
|
||||||
|
"Phase still advances even though there are no outbound routes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Bot_Swap_per_sender_SwapResponse_plus_Ready()
|
||||||
|
{
|
||||||
|
var (s, a, _) = NewBotSession();
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
|
Assert.That(routes.Select(r => r.Frame.Uri),
|
||||||
|
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
||||||
|
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
||||||
|
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Bot_TurnEnd_pushes_Judge_to_sender_only()
|
||||||
|
{
|
||||||
|
var (s, a, b) = NewBotSession();
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||||
|
|
||||||
|
Assert.That(routes.Count, Is.EqualTo(1), "Bot TurnEnd → exactly one Judge frame back.");
|
||||||
|
Assert.That(routes[0].Target, Is.SameAs(a), "Judge target is the sender, not broadcast.");
|
||||||
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Bot_TurnEndFinal_pushes_Judge_to_sender_only()
|
||||||
|
{
|
||||||
|
var (s, a, _) = NewBotSession();
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
|
||||||
|
|
||||||
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||||
|
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Bot_PlayActions_drops_no_recipient()
|
||||||
|
{
|
||||||
|
var (s, a, _) = NewBotSession();
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
|
// Bot's PlayActions falls through the default arm — the Pvp forwarder is gated
|
||||||
|
// on Type == Pvp, so Bot's gameplay frames have no routing rule and drop.
|
||||||
|
// (The PvP semantics would have been "forward to NoOp which swallows" — same
|
||||||
|
// observable result, but cleaner to leave them as default-drops.)
|
||||||
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
|
||||||
|
|
||||||
|
Assert.That(routes, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Bot_Retire_pushes_BattleFinish_Win_to_sender()
|
||||||
|
{
|
||||||
|
var (s, a, _) = NewBotSession();
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
|
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(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||||
|
}
|
||||||
|
|
||||||
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