#nullable enable using System; using System.Collections.Generic; using System.Linq; namespace ReplacedPerson.Core { public sealed class ReplacedMatchEngine { private sealed class PreparedSlot { public ReplacedEffectType Type; public int BaseValue; public int Factor; public bool DrawAndRecover; public bool BonusDamage; public bool Reflect; public bool AddDefense; public bool GuaranteedDodge; public readonly List SpecialEffects = new(); } private readonly HashSet _acceptedTokens = new(StringComparer.Ordinal); private readonly List _replay = new(); public ReplacedContentCatalog Content { get; } public ReplacedMatchState State { get; } public Pcg32 Random { get; } public IReadOnlyList Replay => _replay; public ReplacedMatchEngine(ReplacedContentCatalog content, ReplacedDeck playerOneDeck, ReplacedDeck playerTwoDeck, ulong seed, string playerOneName = "玩家", string playerTwoName = "贪婪") { Content = (content ?? throw new ArgumentNullException(nameof(content))).Clone(); ValidateDeck(playerOneDeck, nameof(playerOneDeck)); ValidateDeck(playerTwoDeck, nameof(playerTwoDeck)); Random = new Pcg32(seed); State = new ReplacedMatchState { Round = 0, Players = new[] { CreatePlayer("p1", playerOneName, playerOneDeck), CreatePlayer("p2", playerTwoName, playerTwoDeck) }, ContentHash = Content.ComputeHash() }; Random.Shuffle(State.Players[0].NormalDrawPile); Random.Shuffle(State.Players[0].EndDrawPile); Random.Shuffle(State.Players[1].NormalDrawPile); Random.Shuffle(State.Players[1].EndDrawPile); BeginRound(initial: true); } public ReplacedCommandResult Apply(ReplacedMatchCommand command) { if (command == null) return Reject("command.null", "指令不能为空。"); if (State.Phase == ReplacedMatchPhase.Completed) return Reject("match.completed", "对局已经结束。"); if (command.PlayerIndex is < 0 or > 1) return Reject("player.invalid", "玩家索引无效。"); if (string.IsNullOrWhiteSpace(command.IdempotencyToken)) return Reject("token.empty", "幂等令牌不能为空。"); if (_acceptedTokens.Contains(command.IdempotencyToken)) return new ReplacedCommandResult { Accepted = true, Duplicate = true, Message = "duplicate", StateHash = ComputeStateHash() }; if (command.Sequence <= State.LastAcceptedSequence[command.PlayerIndex]) return Reject("sequence.stale", "指令序号必须严格递增。"); var expectedPlayer = State.Phase == ReplacedMatchPhase.AwaitingLead ? State.LeadPlayer : 1 - State.LeadPlayer; if (command.PlayerIndex != expectedPlayer) return Reject("turn.not-yours", "尚未轮到该玩家提交。"); var validation = ValidateSubmission(command.PlayerIndex, command.Submission); if (validation != null) return Reject(validation.Value.code, validation.Value.message); LockSubmission(command.PlayerIndex, command.Submission); State.LastAcceptedSequence[command.PlayerIndex] = command.Sequence; _acceptedTokens.Add(command.IdempotencyToken); _replay.Add(command.Clone()); State.Log.Add($"R{State.Round} {State.Players[command.PlayerIndex].DisplayName} 锁定出牌"); if (State.Phase == ReplacedMatchPhase.AwaitingLead) { State.Phase = ReplacedMatchPhase.AwaitingResponse; } else { State.Phase = ReplacedMatchPhase.Resolving; ResolveRound(); } return new ReplacedCommandResult { Accepted = true, Message = "accepted", StateHash = ComputeStateHash() }; } public string ComputeStateHash() => ReplacedStateHasher.Compute(State, Random); private void BeginRound(bool initial) { State.Round++; State.Submissions = new ReplacedTurnSubmission?[2]; for (var i = 0; i < 2; i++) { var player = State.Players[i]; player.Defense = 0; player.SpentThisRound = 0; player.EffectiveEffectsThisRound = 0; player.DamageThisRound = 0; if (State.IsLastStand) { DiscardHands(player); DrawNormal(player, 4); DrawEnd(player, 2); } else { DrawNormal(player, initial ? 4 : 2 + player.BonusNormalDraw); player.BonusNormalDraw = 0; if (initial || !player.SkipNextEndDraw) DrawEnd(player, 1); player.SkipNextEndDraw = false; } } State.InitiativeRoll = Random.RollD6(); State.LeadPlayer = State.InitiativeRoll % 2 == 1 ? 0 : 1; State.Phase = ReplacedMatchPhase.AwaitingLead; State.Log.Add($"R{State.Round} 先手骰={State.InitiativeRoll},{State.Players[State.LeadPlayer].DisplayName}先手"); } private (string code, string message)? ValidateSubmission(int playerIndex, ReplacedTurnSubmission? submission) { if (submission == null) return ("submission.null", "提交内容不能为空。"); if (submission.RerollSlot is < -1 or > 2) return ("reroll.invalid", "重掷槽必须为 -1 到 2。"); var player = State.Players[playerIndex]; if (submission.Pass) { if (submission.NormalCardIds.Count != 0 || submission.EndCardIds.Count != 0) return ("pass.with-cards", "跳过时不能同时提交卡牌。"); return null; } var requiredNormal = State.IsLastStand ? 4 : 1; var requiredEnd = State.IsLastStand ? 2 : 1; if (submission.NormalCardIds.Count < requiredNormal || submission.NormalCardIds.Count > 4) return ("normal.count", State.IsLastStand ? "孤注一掷必须提交 4 张普通牌。" : "必须提交 1 到 4 张普通牌。"); if (submission.EndCardIds.Count != requiredEnd) return ("end.count", State.IsLastStand ? "孤注一掷必须提交主、副两张结束牌。" : "必须提交 1 张结束牌。"); if (!ContainsAll(player.NormalHand, submission.NormalCardIds)) return ("normal.not-in-hand", "普通牌不在手牌中。"); if (!ContainsAll(player.EndHand, submission.EndCardIds)) return ("end.not-in-hand", "结束牌不在手牌中。"); var cost = 0; foreach (var id in submission.NormalCardIds) { if (!Content.Cards.TryGetValue(id, out var card)) return ("normal.unknown", "未知普通牌:" + id); cost += card.Cost; } foreach (var id in submission.EndCardIds) { if (!Content.EndCards.TryGetValue(id, out var card)) return ("end.unknown", "未知结束牌:" + id); cost += card.Cost; } if (cost > player.Cost) return ("cost.insufficient", $"COST 不足:需要 {cost},当前 {player.Cost}。"); return null; } private void LockSubmission(int playerIndex, ReplacedTurnSubmission submission) { var player = State.Players[playerIndex]; var copy = submission.Clone(); State.Submissions[playerIndex] = copy; if (copy.Pass) { player.SkipNextEndDraw = true; if (copy.DiscardEndOnPass) { player.EndDiscard.AddRange(player.EndHand); player.EndHand.Clear(); } return; } var spent = 0; foreach (var id in copy.NormalCardIds) { player.NormalHand.Remove(id); spent += Content.Cards[id].Cost; } foreach (var id in copy.EndCardIds) { player.EndHand.Remove(id); spent += Content.EndCards[id].Cost; } player.Cost -= spent; player.SpentThisRound = spent; } private void ResolveRound() { State.LastResolution.Clear(); var prepared = new[] { PrepareSlots(State.Submissions[0]), PrepareSlots(State.Submissions[1]) }; for (var slotIndex = 0; slotIndex < 3; slotIndex++) ResolveSlot(slotIndex, prepared[0][slotIndex], prepared[1][slotIndex]); ApplyRoundRecoveryAndDiscard(); if (State.IsLastStand) { CompleteLastStand(); return; } var oneDead = State.Players[0].Health <= 0; var twoDead = State.Players[1].Health <= 0; if (oneDead && twoDead && !State.LastStandUsed) { State.LastStandUsed = true; State.IsLastStand = true; State.Players[0].Health = 1; State.Players[1].Health = 1; State.Log.Add("双方同时归零,进入孤注一掷"); BeginRound(initial: false); return; } if (oneDead || twoDead) { Complete(oneDead ? ReplacedWinner.PlayerTwo : ReplacedWinner.PlayerOne); return; } BeginRound(initial: false); } private PreparedSlot?[] PrepareSlots(ReplacedTurnSubmission? submission) { var result = new PreparedSlot?[3]; if (submission == null || submission.Pass) return result; var primaryEnd = Content.EndCards[submission.EndCardIds[0]]; for (var i = 0; i < submission.NormalCardIds.Count; i++) { var card = Content.Cards[submission.NormalCardIds[i]]; var slot = Math.Min(i, 2); var next = new PreparedSlot { Type = card.EffectType, BaseValue = card.BaseValue, Factor = card.Factor }; if (!string.IsNullOrWhiteSpace(card.SpecialEffectId)) next.SpecialEffects.Add(card.SpecialEffectId); result[slot] = result[slot] == null ? next : Fuse(result[slot]!, next); } for (var i = 0; i < result.Length; i++) { if (result[i] == null) continue; result[i]!.BaseValue += primaryEnd.SlotBase[i]; result[i]!.Factor += primaryEnd.SlotFactor[i]; } if (State.IsLastStand && submission.EndCardIds.Count > 1 && result[0] != null) { var secondary = Content.EndCards[submission.EndCardIds[1]]; result[0]!.BaseValue += secondary.SlotBase[0]; result[0]!.Factor += secondary.SlotFactor[0]; } return result; } private static PreparedSlot Fuse(PreparedSlot left, PreparedSlot right) { var result = new PreparedSlot { Type = left.Type == ReplacedEffectType.Attack || right.Type == ReplacedEffectType.Attack ? ReplacedEffectType.Attack : left.Type == ReplacedEffectType.Counter || right.Type == ReplacedEffectType.Counter ? ReplacedEffectType.Counter : ReplacedEffectType.Dodge, BaseValue = left.BaseValue + right.BaseValue, Factor = left.Factor + right.Factor, DrawAndRecover = left.DrawAndRecover || right.DrawAndRecover, BonusDamage = left.BonusDamage || right.BonusDamage, Reflect = left.Reflect || right.Reflect, AddDefense = left.AddDefense || right.AddDefense, GuaranteedDodge = left.GuaranteedDodge || right.GuaranteedDodge }; result.SpecialEffects.AddRange(left.SpecialEffects); result.SpecialEffects.AddRange(right.SpecialEffects); var a = left.Type; var b = right.Type; if (a == ReplacedEffectType.Attack && b == ReplacedEffectType.Attack) { result.BaseValue = (int)Math.Round(result.BaseValue * 1.5, MidpointRounding.AwayFromZero); result.Factor = (int)Math.Round(result.Factor * 1.5, MidpointRounding.AwayFromZero); } else if (IsPair(a, b, ReplacedEffectType.Attack, ReplacedEffectType.Dodge)) result.DrawAndRecover = true; else if (IsPair(a, b, ReplacedEffectType.Attack, ReplacedEffectType.Counter)) result.BonusDamage = true; else if (a == ReplacedEffectType.Counter && b == ReplacedEffectType.Counter) result.Reflect = true; else if (IsPair(a, b, ReplacedEffectType.Dodge, ReplacedEffectType.Counter)) result.AddDefense = true; else if (a == ReplacedEffectType.Dodge && b == ReplacedEffectType.Dodge) result.GuaranteedDodge = true; return result; } private void ResolveSlot(int slotIndex, PreparedSlot? one, PreparedSlot? two) { if (one == null && two == null) return; var submissionOne = State.Submissions[0]!; var submissionTwo = State.Submissions[1]!; var oneRerolled = false; var twoRerolled = false; var oneRoll = one == null ? 0 : RollWithMarkedReroll(submissionOne, slotIndex, out oneRerolled); var twoRoll = two == null ? 0 : RollWithMarkedReroll(submissionTwo, slotIndex, out twoRerolled); var oneValue = one == null ? 0 : one.BaseValue + one.Factor * oneRoll; var twoValue = two == null ? 0 : two.BaseValue + two.Factor * twoRoll; var tieGuard = 0; while (one != null && two != null && oneValue == twoValue && tieGuard++ < 32) { oneRoll = Random.RollD6(); twoRoll = Random.RollD6(); oneValue = one.BaseValue + one.Factor * oneRoll; twoValue = two.BaseValue + two.Factor * twoRoll; } var resolution = new ReplacedSlotResolution { SlotIndex = slotIndex, PlayerOneType = one?.Type ?? ReplacedEffectType.Dodge, PlayerTwoType = two?.Type ?? ReplacedEffectType.Dodge, PlayerOneRoll = oneRoll, PlayerTwoRoll = twoRoll, PlayerOneValue = oneValue, PlayerTwoValue = twoValue, PlayerOneRerolled = oneRerolled, PlayerTwoRerolled = twoRerolled }; if (one == null) ApplyUnopposed(1, two!, twoValue, resolution); else if (two == null) ApplyUnopposed(0, one, oneValue, resolution); else if (oneValue == twoValue) resolution.Summary = "连续平点,槽位无效"; else ApplyOpposed(oneValue > twoValue ? 0 : 1, one, two, oneValue, twoValue, resolution); State.LastResolution.Add(resolution); State.Log.Add($"槽{slotIndex + 1}: {resolution.Summary}"); } private void ApplyUnopposed(int owner, PreparedSlot slot, int value, ReplacedSlotResolution resolution) { if (slot.Type == ReplacedEffectType.Attack) { var damage = Math.Min(8, value); DealDamage(owner, 1 - owner, damage, resolution); ApplySuccess(owner, slot, resolution); resolution.Summary = $"{State.Players[owner].DisplayName} 无对位进攻 {damage}"; } else { ApplySuccess(owner, slot, resolution); resolution.Summary = $"{State.Players[owner].DisplayName} 无对位效果发动"; } } private void ApplyOpposed(int winner, PreparedSlot one, PreparedSlot two, int oneValue, int twoValue, ReplacedSlotResolution resolution) { var loser = 1 - winner; var winSlot = winner == 0 ? one : two; var loseSlot = winner == 0 ? two : one; var difference = Math.Abs(oneValue - twoValue); if (winSlot.Type == ReplacedEffectType.Attack) { if (loseSlot.Type == ReplacedEffectType.Dodge && State.Players[loser].GuaranteedDodge) { State.Players[loser].GuaranteedDodge = false; resolution.Summary = $"{State.Players[loser].DisplayName} 消耗必定闪避"; return; } var damage = Math.Min(8, difference); DealDamage(winner, loser, damage, resolution); ApplySuccess(winner, winSlot, resolution); resolution.Summary = $"{State.Players[winner].DisplayName} 进攻胜出 {damage}"; return; } if (winSlot.Type == ReplacedEffectType.Counter && loseSlot.Type == ReplacedEffectType.Attack) { var damage = Math.Min(6, difference); DealDamage(winner, loser, damage, resolution); ApplySuccess(winner, winSlot, resolution); resolution.Summary = $"{State.Players[winner].DisplayName} 反击 {damage}"; return; } if (winSlot.Type == ReplacedEffectType.Dodge && loseSlot.Type == ReplacedEffectType.Attack) { State.Players[winner].BonusNormalDraw++; ApplySuccess(winner, winSlot, resolution); resolution.Summary = $"{State.Players[winner].DisplayName} 闪避并获得额外抽牌"; return; } ApplySuccess(winner, winSlot, resolution); resolution.Summary = $"{State.Players[winner].DisplayName} 对位胜出"; } private void ApplySuccess(int owner, PreparedSlot slot, ReplacedSlotResolution resolution) { var player = State.Players[owner]; player.EffectiveEffectsThisRound++; if (slot.DrawAndRecover) { DrawNormal(player, 1); player.Cost = Math.Min(10, player.Cost + 1); } if (slot.BonusDamage) DealDamage(owner, 1 - owner, 3, resolution); if (slot.AddDefense) player.Defense += 2; if (slot.GuaranteedDodge) player.GuaranteedDodge = true; if (slot.Reflect && player.DamageThisRound > 0) DealDamage(owner, 1 - owner, Math.Min(5, (int)Math.Ceiling(player.DamageThisRound * 0.5)), resolution); foreach (var special in slot.SpecialEffects) { switch (special) { case "greed.draw": DrawNormal(player, 1); break; case "greed.refund": player.Cost = Math.Min(10, player.Cost + 1); break; case "greed.wage-cut": State.Players[1 - owner].Cost = Math.Max(0, State.Players[1 - owner].Cost - 2); break; default: if (Content.EffectResolvers.TryGetValue(special, out var resolver)) { var effectResult = resolver.Resolve(new ReplacedEffectContext { EffectId = special, OwnerIndex = owner, State = State, Value = 0 }); player.Cost = Math.Min(10, Math.Max(0, player.Cost + effectResult.CostDelta)); DrawNormal(player, Math.Max(0, effectResult.DrawDelta)); if (effectResult.BonusDamage > 0) DealDamage(owner, 1 - owner, effectResult.BonusDamage, resolution); if (!string.IsNullOrWhiteSpace(effectResult.Log)) State.Log.Add(effectResult.Log); } break; } } } private void DealDamage(int source, int target, int amount, ReplacedSlotResolution resolution) { if (amount <= 0) return; var targetState = State.Players[target]; var afterDefense = Math.Max(0, amount - targetState.Defense); if (State.IsLastStand) { State.Players[source].DamageThisRound += afterDefense; } else { targetState.Health -= afterDefense; State.Players[source].DamageThisRound += afterDefense; } if (target == 0) resolution.DamageToPlayerOne += afterDefense; else resolution.DamageToPlayerTwo += afterDefense; } private int RollWithMarkedReroll(ReplacedTurnSubmission submission, int slotIndex, out bool rerolled) { var roll = Random.RollD6(); rerolled = submission.RerollSlot == slotIndex && roll <= 2; return rerolled ? Random.RollD6() : roll; } private void ApplyRoundRecoveryAndDiscard() { for (var i = 0; i < 2; i++) { var player = State.Players[i]; player.Cost = Math.Min(10, player.Cost + 1 + player.SpentThisRound / 2); var submission = State.Submissions[i]; if (submission == null || submission.Pass) continue; player.NormalDiscard.AddRange(submission.NormalCardIds); player.EndDiscard.AddRange(submission.EndCardIds); } } private void CompleteLastStand() { var first = State.Players[0]; var second = State.Players[1]; if (first.DamageThisRound != second.DamageThisRound) Complete(first.DamageThisRound > second.DamageThisRound ? ReplacedWinner.PlayerOne : ReplacedWinner.PlayerTwo); else if (first.EffectiveEffectsThisRound != second.EffectiveEffectsThisRound) Complete(first.EffectiveEffectsThisRound > second.EffectiveEffectsThisRound ? ReplacedWinner.PlayerOne : ReplacedWinner.PlayerTwo); else Complete(Random.RollD6() % 2 == 1 ? ReplacedWinner.PlayerOne : ReplacedWinner.PlayerTwo); } private void Complete(ReplacedWinner winner) { State.Winner = winner; State.Phase = ReplacedMatchPhase.Completed; State.Log.Add("对局结束:" + winner); } private void DrawNormal(ReplacedPlayerState player, int count) { for (var i = 0; i < count && player.NormalHand.Count < 9; i++) { RecycleIfNeeded(player.NormalDrawPile, player.NormalDiscard); if (player.NormalDrawPile.Count == 0) return; var last = player.NormalDrawPile.Count - 1; player.NormalHand.Add(player.NormalDrawPile[last]); player.NormalDrawPile.RemoveAt(last); } } private void DrawEnd(ReplacedPlayerState player, int count) { for (var i = 0; i < count; i++) { RecycleIfNeeded(player.EndDrawPile, player.EndDiscard); if (player.EndDrawPile.Count == 0) return; var last = player.EndDrawPile.Count - 1; player.EndHand.Add(player.EndDrawPile[last]); player.EndDrawPile.RemoveAt(last); } } private void RecycleIfNeeded(List draw, List discard) { if (draw.Count > 0 || discard.Count == 0) return; draw.AddRange(discard); discard.Clear(); Random.Shuffle(draw); } private static bool ContainsAll(List hand, List requested) { var available = new List(hand); foreach (var id in requested) if (!available.Remove(id)) return false; return true; } private static bool IsPair(ReplacedEffectType a, ReplacedEffectType b, ReplacedEffectType x, ReplacedEffectType y) => (a == x && b == y) || (a == y && b == x); private static ReplacedPlayerState CreatePlayer(string id, string name, ReplacedDeck deck) => new() { PlayerId = id, DisplayName = name, NormalDrawPile = new List(deck.NormalCards), EndDrawPile = new List(deck.EndCards) }; private void ValidateDeck(ReplacedDeck deck, string parameter) { var errors = ReplacedDeckValidator.Validate(deck, Content); if (errors.Count > 0) throw new ArgumentException(string.Join(", ", errors), parameter); } private static void DiscardHands(ReplacedPlayerState player) { player.NormalDiscard.AddRange(player.NormalHand); player.NormalHand.Clear(); player.EndDiscard.AddRange(player.EndHand); player.EndHand.Clear(); } private ReplacedCommandResult Reject(string code, string message) => new() { Accepted = false, ErrorCode = code, Message = message, StateHash = ComputeStateHash() }; } }