using System.Linq; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; namespace SVSim.BattleNode.Sessions.Participants; /// /// Server-scripted opponent that drives a client-shaped emit chain so the session brokers /// it through the same handshake arms as a human. kicks off /// InitNetwork; the session's pushes then drive reactively: /// InitNetwork(ack)→InitBattle, MatchedLoaded, DealSwap /// (empty mulligan). After the player's TurnEnd it fires the v1.2 three-frame burst /// (OpponentTurnStart, OpponentTurnEnd, OpponentJudge). All other URIs /// are swallowed. Implementing is what makes the session /// treat it as a real handshake participant (mulligan-barrier swapper included). /// /// /// ViewerId, Context are fixtures matching /// and a scripted opponent profile. The Context fixture is the source of truth for the /// scripted opponent's half of Matched and BattleStart (cosmetics, deck count, class/chara) — /// reads other.Context for those frames. /// Deal still uses fixed scripted frames that ignore Context. /// public sealed class ScriptedBotParticipant : IBattleParticipant, IHasHandshakePhase { public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId; public MatchContext Context { get; } = new( // 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 — prod frame[2] (Matched) // shipped OppoDeckCount: 30. SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(), // BattleStart opponent half (frame[5]): ClassId/CharaId both "8" (neutral test class). ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015", // Matched opponent half (frame[2]): cosmetic fields from the prod capture. CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010", EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0, BattleType: 0); // Session reads/advances this through its phase-gated handshake arms, exactly as it // does for a RealParticipant. The bot doesn't read it — it reacts to pushed URIs — // but implementing IHasHandshakePhase is what makes the session treat the bot as a // real handshake participant (so its InitNetwork/InitBattle/Loaded/Swap emissions are // processed, and the mulligan barrier counts it as a swapper). public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork; public event Func? FrameEmitted; // Kick off the handshake like a connecting client. The session acks InitNetwork, // which drives PushAsync below through InitBattle → Loaded → Swap. public Task RunAsync(CancellationToken ct) => EmitAsync(ScriptedLifecycle.BuildClientInitNetwork(), ct); public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct) { switch (envelope.Uri) { case NetworkBattleUri.InitNetwork: // the ack await EmitAsync(ScriptedLifecycle.BuildClientInitBattle(), ct).ConfigureAwait(false); break; case NetworkBattleUri.Matched: await EmitAsync(ScriptedLifecycle.BuildClientLoaded(), ct).ConfigureAwait(false); break; case NetworkBattleUri.Deal: await EmitAsync(ScriptedLifecycle.BuildClientSwap(), ct).ConfigureAwait(false); break; case NetworkBattleUri.TurnEnd: // v1.2 scripted-turn burst, taken AFTER the player's turn (bot is second). await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false); await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false); await EmitAsync(ScriptedLifecycle.BuildOpponentJudge(), ct).ConfigureAwait(false); break; // Everything else (BattleStart, our own Swap-response, Ready, TurnEndFinal, // Judge, BattleFinish, …) needs no bot reaction. } } public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; private Task EmitAsync(MsgEnvelope env, CancellationToken ct) => FrameEmitted?.Invoke(env, ct) ?? Task.CompletedTask; }