Files
Workspace/Assets/Demos/ReplacedPerson/Core/MatchEngine.cs
T
2026-08-17 18:18:13 +08:00

611 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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<string> SpecialEffects = new();
}
private readonly HashSet<string> _acceptedTokens = new(StringComparer.Ordinal);
private readonly List<ReplacedMatchCommand> _replay = new();
public ReplacedContentCatalog Content { get; }
public ReplacedMatchState State { get; }
public Pcg32 Random { get; }
public IReadOnlyList<ReplacedMatchCommand> 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<string> draw, List<string> discard)
{
if (draw.Count > 0 || discard.Count == 0) return;
draw.AddRange(discard);
discard.Clear();
Random.Shuffle(draw);
}
private static bool ContainsAll(List<string> hand, List<string> requested)
{
var available = new List<string>(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<string>(deck.NormalCards),
EndDrawPile = new List<string>(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()
};
}
}