This commit is contained in:
2026-08-17 18:18:13 +08:00
parent 5765f411e9
commit d2cd3b9fa8
188 changed files with 32929 additions and 2 deletions
+166
View File
@@ -0,0 +1,166 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace ReplacedPerson.Core
{
public sealed class ReplacedContentCatalog
{
public Dictionary<string, ReplacedCardDefinition> Cards { get; } = new(StringComparer.Ordinal);
public Dictionary<string, ReplacedEndCardDefinition> EndCards { get; } = new(StringComparer.Ordinal);
public Dictionary<string, ReplacedEnemyDefinition> Enemies { get; } = new(StringComparer.Ordinal);
public Dictionary<string, IReplacedPersonEffectResolver> EffectResolvers { get; } = new(StringComparer.Ordinal);
public ReplacedContentCatalog Clone()
{
var clone = new ReplacedContentCatalog();
foreach (var pair in Cards)
clone.Cards.Add(pair.Key, Clone(pair.Value));
foreach (var pair in EndCards)
clone.EndCards.Add(pair.Key, Clone(pair.Value));
foreach (var pair in Enemies)
clone.Enemies.Add(pair.Key, Clone(pair.Value));
foreach (var pair in EffectResolvers)
clone.EffectResolvers.Add(pair.Key, pair.Value);
return clone;
}
public string ComputeHash()
{
var text = new StringBuilder();
foreach (var card in Cards.Values.OrderBy(value => value.Id, StringComparer.Ordinal))
text.Append("C|").Append(card.Id).Append('|').Append(card.Name).Append('|').Append((int)card.EffectType)
.Append('|').Append(card.Cost).Append('|').Append(card.BaseValue).Append('|').Append(card.Factor)
.Append('|').Append(card.SpecialEffectId).Append('\n');
foreach (var card in EndCards.Values.OrderBy(value => value.Id, StringComparer.Ordinal))
text.Append("E|").Append(card.Id).Append('|').Append(card.Name).Append('|').Append(card.Cost).Append('|')
.Append(string.Join(",", card.SlotBase)).Append('|').Append(string.Join(",", card.SlotFactor)).Append('\n');
foreach (var enemy in Enemies.Values.OrderBy(value => value.Id, StringComparer.Ordinal))
text.Append("N|").Append(enemy.Id).Append('|').Append(enemy.Name).Append('|').Append(enemy.MaxHealth).Append('|')
.Append(string.Join(",", enemy.NormalDeck)).Append('|').Append(string.Join(",", enemy.EndDeck)).Append('\n');
foreach (var effect in EffectResolvers.OrderBy(value => value.Key, StringComparer.Ordinal))
text.Append("R|").Append(effect.Key).Append('|').Append(effect.Value.GetType().FullName).Append('\n');
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(text.ToString()));
var hash = new StringBuilder(bytes.Length * 2);
foreach (var value in bytes) hash.Append(value.ToString("x2"));
return hash.ToString();
}
private static ReplacedCardDefinition Clone(ReplacedCardDefinition value) => new()
{
Id = value.Id, Name = value.Name, Description = value.Description, EffectType = value.EffectType,
Cost = value.Cost, BaseValue = value.BaseValue, Factor = value.Factor, SpecialEffectId = value.SpecialEffectId
};
private static ReplacedEndCardDefinition Clone(ReplacedEndCardDefinition value) => new()
{
Id = value.Id, Name = value.Name, Description = value.Description, Cost = value.Cost,
SlotBase = (int[])value.SlotBase.Clone(), SlotFactor = (int[])value.SlotFactor.Clone()
};
private static ReplacedEnemyDefinition Clone(ReplacedEnemyDefinition value) => new()
{
Id = value.Id, Name = value.Name, MaxHealth = value.MaxHealth,
NormalDeck = new List<string>(value.NormalDeck), EndDeck = new List<string>(value.EndDeck),
RewardIds = new List<string>(value.RewardIds)
};
}
public static class ReplacedBuiltInContent
{
public static ReplacedContentCatalog Create()
{
var result = new ReplacedContentCatalog();
AddCard(result, "attack.kill", "杀", ReplacedEffectType.Attack, 2, 2, 2, "直截了当的低费进攻。");
AddCard(result, "attack.elbow", "肘击", ReplacedEffectType.Attack, 3, 3, 2, "稳定进攻,适合试探对位。");
AddCard(result, "attack.break", "破局", ReplacedEffectType.Attack, 4, 3, 3, "高波动进攻。");
AddCard(result, "attack.iron", "铁山靠", ReplacedEffectType.Attack, 5, 5, 2, "高始数压制。");
AddCard(result, "attack.burst", "寸劲", ReplacedEffectType.Attack, 6, 4, 3, "高上限连击。");
AddCard(result, "attack.palm", "黄经极世掌", ReplacedEffectType.Attack, 7, 5, 3, "终盘重击。");
AddCard(result, "dodge.flash", "闪", ReplacedEffectType.Dodge, 2, 4, 2, "闪避成功后获得额外抽牌。");
AddCard(result, "dodge.phase", "虚化", ReplacedEffectType.Dodge, 3, 5, 3, "与反击合成可获得防御。");
AddCard(result, "counter.stance", "架势", ReplacedEffectType.Counter, 1, 2, 2, "低费反击,用来试探攻击并为高费反制保留 COST。");
AddCard(result, "counter.blood", "以血蒙眼", ReplacedEffectType.Counter, 3, 5, 2, "反制进攻并返还伤害。");
AddCard(result, "counter.instant", "一瞬千击", ReplacedEffectType.Counter, 5, 7, 3, "高费反击。");
AddCard(result, "greed.snob", "势利眼", ReplacedEffectType.Dodge, 3, 2, 2, "贪婪的闪避牌。", "greed.draw");
AddCard(result, "greed.turn-profit", "扭亏为盈", ReplacedEffectType.Counter, 4, 3, 3, "反击成功时恢复 COST。", "greed.refund");
AddCard(result, "greed.wage-cut", "降薪", ReplacedEffectType.Attack, 4, 2, 2, "命中后削减对方 COST。", "greed.wage-cut");
AddEnd(result, "end.inherit", "继承", 0, new[] { 0, 0, 0 }, new[] { 0, 0, 0 }, "将左侧普通牌效果继承到三个槽位。");
AddEnd(result, "end.ledger", "账本", 1, new[] { 1, 0, 1 }, new[] { 0, 1, 0 }, "稳定首尾槽。");
AddEnd(result, "end.severance", "断契", 1, new[] { 0, 2, 0 }, new[] { 1, 0, 1 }, "强化中槽始数与边槽因数。");
AddEnd(result, "end.black", "黑色遗嘱", 2, new[] { 1, 1, 1 }, new[] { 1, 0, 1 }, "全面强化,适合孤注一掷。");
var playerDeck = CreateStarterDeck();
result.Enemies.Add("greed.tutorial", new ReplacedEnemyDefinition
{
Id = "greed.tutorial", Name = "贪婪·影子", MaxHealth = 20,
NormalDeck = new List<string> { "attack.kill", "attack.kill", "attack.elbow", "attack.elbow", "dodge.flash", "dodge.flash", "counter.blood", "counter.blood", "greed.snob", "greed.snob", "greed.turn-profit", "greed.wage-cut" },
EndDeck = new List<string>(playerDeck.EndCards), RewardIds = new List<string> { "ornament.gold-eye" }
});
result.Enemies.Add("greed.full", new ReplacedEnemyDefinition
{
Id = "greed.full", Name = "贪婪·葛朗台", MaxHealth = 30,
NormalDeck = new List<string> { "attack.break", "attack.break", "attack.iron", "attack.iron", "dodge.phase", "dodge.phase", "greed.snob", "greed.snob", "greed.turn-profit", "greed.turn-profit", "greed.wage-cut", "greed.wage-cut" },
EndDeck = new List<string>(playerDeck.EndCards), RewardIds = new List<string> { "ornament.gold-eye", "ornament.gold-chain", "ornament.worn-suit", "greed.snob", "greed.turn-profit", "greed.wage-cut" }
});
return result;
}
public static ReplacedDeck CreateStarterDeck() => new()
{
NormalCards = new List<string>
{
"counter.stance", "counter.stance", "counter.blood", "counter.blood", "counter.instant", "counter.instant",
"dodge.flash", "dodge.flash", "attack.kill", "attack.kill", "attack.elbow", "attack.elbow"
},
EndCards = new List<string> { "end.inherit", "end.ledger", "end.severance", "end.black" }
};
private static void AddCard(ReplacedContentCatalog target, string id, string name, ReplacedEffectType type,
int cost, int baseValue, int factor, string description, string special = "")
{
target.Cards.Add(id, new ReplacedCardDefinition
{
Id = id, Name = name, EffectType = type, Cost = cost, BaseValue = baseValue,
Factor = factor, Description = description, SpecialEffectId = special
});
}
private static void AddEnd(ReplacedContentCatalog target, string id, string name, int cost,
int[] slotBase, int[] slotFactor, string description)
{
target.EndCards.Add(id, new ReplacedEndCardDefinition
{
Id = id, Name = name, Cost = cost, SlotBase = slotBase, SlotFactor = slotFactor, Description = description
});
}
}
public static class ReplacedDeckValidator
{
public static IReadOnlyList<string> Validate(ReplacedDeck deck, ReplacedContentCatalog catalog)
{
var errors = new List<string>();
if (deck == null) return new[] { "deck.null" };
if (deck.NormalCards.Count != 12) errors.Add("deck.normal.count");
if (deck.EndCards.Count != 4) errors.Add("deck.end.count");
foreach (var group in deck.NormalCards.GroupBy(value => value, StringComparer.Ordinal))
{
if (!catalog.Cards.ContainsKey(group.Key)) errors.Add("deck.normal.unknown:" + group.Key);
if (group.Count() > 2) errors.Add("deck.normal.duplicate:" + group.Key);
}
foreach (var group in deck.EndCards.GroupBy(value => value, StringComparer.Ordinal))
{
if (!catalog.EndCards.ContainsKey(group.Key)) errors.Add("deck.end.unknown:" + group.Key);
if (group.Count() > 1) errors.Add("deck.end.duplicate:" + group.Key);
}
return errors;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fa02820e6d30cc849adac4b52dcd52f6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+220
View File
@@ -0,0 +1,220 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace ReplacedPerson.Core
{
public enum ReplacedEffectType
{
Attack = 0,
Dodge = 1,
Counter = 2
}
public enum ReplacedMatchPhase
{
AwaitingLead = 0,
AwaitingResponse = 1,
Resolving = 2,
Completed = 3
}
public enum ReplacedWinner
{
None = -1,
PlayerOne = 0,
PlayerTwo = 1,
Draw = 2
}
[Serializable]
public sealed class ReplacedCardDefinition
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public ReplacedEffectType EffectType { get; set; }
public int Cost { get; set; }
public int BaseValue { get; set; }
public int Factor { get; set; }
public string SpecialEffectId { get; set; } = string.Empty;
}
[Serializable]
public sealed class ReplacedEndCardDefinition
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Cost { get; set; }
public int[] SlotBase { get; set; } = new int[3];
public int[] SlotFactor { get; set; } = new int[3];
}
[Serializable]
public sealed class ReplacedEnemyDefinition
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int MaxHealth { get; set; } = 30;
public List<string> NormalDeck { get; set; } = new();
public List<string> EndDeck { get; set; } = new();
public List<string> RewardIds { get; set; } = new();
}
[Serializable]
public sealed class ReplacedDeck
{
public List<string> NormalCards { get; set; } = new();
public List<string> EndCards { get; set; } = new();
public ReplacedDeck Clone() => new()
{
NormalCards = new List<string>(NormalCards),
EndCards = new List<string>(EndCards)
};
}
[Serializable]
public sealed class ReplacedTurnSubmission
{
public bool Pass { get; set; }
public bool DiscardEndOnPass { get; set; }
public List<string> NormalCardIds { get; set; } = new();
public List<string> EndCardIds { get; set; } = new();
public int RerollSlot { get; set; } = -1;
public ReplacedTurnSubmission Clone() => new()
{
Pass = Pass,
DiscardEndOnPass = DiscardEndOnPass,
NormalCardIds = new List<string>(NormalCardIds),
EndCardIds = new List<string>(EndCardIds),
RerollSlot = RerollSlot
};
}
[Serializable]
public sealed class ReplacedMatchCommand
{
public long Sequence { get; set; }
public string IdempotencyToken { get; set; } = string.Empty;
public int PlayerIndex { get; set; }
public ReplacedTurnSubmission Submission { get; set; } = new();
public ReplacedMatchCommand Clone() => new()
{
Sequence = Sequence,
IdempotencyToken = IdempotencyToken,
PlayerIndex = PlayerIndex,
Submission = Submission.Clone()
};
}
[Serializable]
public sealed class ReplacedPlayerState
{
public string PlayerId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public int Health { get; set; } = 30;
public int MaxHealth { get; set; } = 30;
public int Cost { get; set; } = 10;
public int Defense { get; set; }
public int BonusNormalDraw { get; set; }
public bool GuaranteedDodge { get; set; }
public bool SkipNextEndDraw { get; set; }
public int SpentThisRound { get; set; }
public int EffectiveEffectsThisRound { get; set; }
public int DamageThisRound { get; set; }
public List<string> NormalDrawPile { get; set; } = new();
public List<string> NormalDiscard { get; set; } = new();
public List<string> NormalHand { get; set; } = new();
public List<string> EndDrawPile { get; set; } = new();
public List<string> EndDiscard { get; set; } = new();
public List<string> EndHand { get; set; } = new();
public ReplacedPlayerState Clone() => new()
{
PlayerId = PlayerId,
DisplayName = DisplayName,
Health = Health,
MaxHealth = MaxHealth,
Cost = Cost,
Defense = Defense,
BonusNormalDraw = BonusNormalDraw,
GuaranteedDodge = GuaranteedDodge,
SkipNextEndDraw = SkipNextEndDraw,
SpentThisRound = SpentThisRound,
EffectiveEffectsThisRound = EffectiveEffectsThisRound,
DamageThisRound = DamageThisRound,
NormalDrawPile = new List<string>(NormalDrawPile),
NormalDiscard = new List<string>(NormalDiscard),
NormalHand = new List<string>(NormalHand),
EndDrawPile = new List<string>(EndDrawPile),
EndDiscard = new List<string>(EndDiscard),
EndHand = new List<string>(EndHand)
};
}
[Serializable]
public sealed class ReplacedSlotResolution
{
public int SlotIndex { get; set; }
public ReplacedEffectType PlayerOneType { get; set; }
public ReplacedEffectType PlayerTwoType { get; set; }
public int PlayerOneRoll { get; set; }
public int PlayerTwoRoll { get; set; }
public int PlayerOneValue { get; set; }
public int PlayerTwoValue { get; set; }
public int DamageToPlayerOne { get; set; }
public int DamageToPlayerTwo { get; set; }
public bool PlayerOneRerolled { get; set; }
public bool PlayerTwoRerolled { get; set; }
public string Summary { get; set; } = string.Empty;
}
[Serializable]
public sealed class ReplacedMatchState
{
public int Round { get; set; }
public int LeadPlayer { get; set; }
public int InitiativeRoll { get; set; }
public ReplacedMatchPhase Phase { get; set; }
public ReplacedWinner Winner { get; set; } = ReplacedWinner.None;
public bool IsLastStand { get; set; }
public bool LastStandUsed { get; set; }
public ReplacedPlayerState[] Players { get; set; } = Array.Empty<ReplacedPlayerState>();
public ReplacedTurnSubmission?[] Submissions { get; set; } = new ReplacedTurnSubmission?[2];
public List<ReplacedSlotResolution> LastResolution { get; set; } = new();
public List<string> Log { get; set; } = new();
public long[] LastAcceptedSequence { get; set; } = new long[2];
public string ContentHash { get; set; } = string.Empty;
public ReplacedMatchState Clone() => new()
{
Round = Round,
LeadPlayer = LeadPlayer,
InitiativeRoll = InitiativeRoll,
Phase = Phase,
Winner = Winner,
IsLastStand = IsLastStand,
LastStandUsed = LastStandUsed,
Players = new[] { Players[0].Clone(), Players[1].Clone() },
Submissions = new ReplacedTurnSubmission?[] { Submissions[0]?.Clone(), Submissions[1]?.Clone() },
LastResolution = new List<ReplacedSlotResolution>(LastResolution),
Log = new List<string>(Log),
LastAcceptedSequence = (long[])LastAcceptedSequence.Clone(),
ContentHash = ContentHash
};
}
public sealed class ReplacedCommandResult
{
public bool Accepted { get; set; }
public bool Duplicate { get; set; }
public string ErrorCode { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string StateHash { get; set; } = string.Empty;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b7a20d95a3133394cb808caef94ec04c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,610 @@
#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()
};
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 47d95b984ad7b2546856f977713e4853
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+169
View File
@@ -0,0 +1,169 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace ReplacedPerson.Core
{
public interface IReplacedPersonMod
{
string Id { get; }
string Version { get; }
void Register(IReplacedPersonRegistry registry);
}
public interface IReplacedPersonRegistry
{
void RegisterCard(ReplacedCardDefinition definition);
void RegisterEndCard(ReplacedEndCardDefinition definition);
void RegisterEnemy(ReplacedEnemyDefinition definition);
void RegisterReward(string id, string displayName);
void RegisterStory(string id, string title, string body);
void RegisterEffectResolver(string effectId, IReplacedPersonEffectResolver resolver);
void RegisterCommand(string path, Func<IReadOnlyList<string>, string> handler);
}
public interface IReplacedPersonEffectResolver
{
ReplacedEffectResult Resolve(ReplacedEffectContext context);
}
public sealed class ReplacedEffectContext
{
public string EffectId { get; set; } = string.Empty;
public int OwnerIndex { get; set; }
public int OpponentIndex => 1 - OwnerIndex;
public int Value { get; set; }
public ReplacedMatchState State { get; set; } = null!;
}
public sealed class ReplacedEffectResult
{
public int BonusDamage { get; set; }
public int CostDelta { get; set; }
public int DrawDelta { get; set; }
public string Log { get; set; } = string.Empty;
}
[Serializable]
public sealed class ReplacedPersonModFile
{
public string Path { get; set; } = string.Empty;
public string Sha256 { get; set; } = string.Empty;
}
[Serializable]
public sealed class ReplacedPersonModManifest
{
public string Id { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public List<ReplacedPersonModFile> Files { get; set; } = new();
}
public sealed class ReplacedModCompatibilityResult
{
public bool Compatible => Differences.Count == 0;
public List<string> Differences { get; } = new();
}
public static class ReplacedModCompatibility
{
public static ReplacedModCompatibilityResult Compare(IEnumerable<ReplacedPersonModManifest> authority,
IEnumerable<ReplacedPersonModManifest> client)
{
var result = new ReplacedModCompatibilityResult();
var expected = authority.ToDictionary(value => value.Id, StringComparer.Ordinal);
var actual = client.ToDictionary(value => value.Id, StringComparer.Ordinal);
foreach (var id in expected.Keys.Union(actual.Keys, StringComparer.Ordinal).OrderBy(value => value, StringComparer.Ordinal))
{
if (!expected.TryGetValue(id, out var expectedMod)) { result.Differences.Add("unexpected-mod:" + id); continue; }
if (!actual.TryGetValue(id, out var actualMod)) { result.Differences.Add("missing-mod:" + id); continue; }
if (!string.Equals(expectedMod.Version, actualMod.Version, StringComparison.Ordinal))
result.Differences.Add($"version:{id}:{expectedMod.Version}!={actualMod.Version}");
CompareFiles(id, expectedMod.Files, actualMod.Files, result.Differences);
}
return result;
}
public static string ComputeManifestSetHash(IEnumerable<ReplacedPersonModManifest> manifests)
{
var text = new StringBuilder();
foreach (var manifest in manifests.OrderBy(value => value.Id, StringComparer.Ordinal))
{
text.Append(manifest.Id).Append('|').Append(manifest.Version).Append('\n');
foreach (var file in manifest.Files.OrderBy(value => value.Path, StringComparer.Ordinal))
text.Append(file.Path).Append('|').Append(file.Sha256.ToLowerInvariant()).Append('\n');
}
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(text.ToString()));
return string.Concat(bytes.Select(value => value.ToString("x2")));
}
private static void CompareFiles(string modId, IEnumerable<ReplacedPersonModFile> expected,
IEnumerable<ReplacedPersonModFile> actual, ICollection<string> differences)
{
var expectedFiles = expected.ToDictionary(value => value.Path, StringComparer.Ordinal);
var actualFiles = actual.ToDictionary(value => value.Path, StringComparer.Ordinal);
foreach (var path in expectedFiles.Keys.Union(actualFiles.Keys, StringComparer.Ordinal).OrderBy(value => value, StringComparer.Ordinal))
{
if (!expectedFiles.TryGetValue(path, out var expectedFile)) { differences.Add($"unexpected-file:{modId}:{path}"); continue; }
if (!actualFiles.TryGetValue(path, out var actualFile)) { differences.Add($"missing-file:{modId}:{path}"); continue; }
if (!string.Equals(expectedFile.Sha256, actualFile.Sha256, StringComparison.OrdinalIgnoreCase))
differences.Add($"hash:{modId}:{path}");
}
}
}
public sealed class ReplacedPersonRegistry : IReplacedPersonRegistry
{
private readonly ReplacedContentCatalog _catalog;
private readonly Dictionary<string, string> _rewards = new(StringComparer.Ordinal);
private readonly Dictionary<string, (string title, string body)> _stories = new(StringComparer.Ordinal);
private readonly Dictionary<string, IReplacedPersonEffectResolver> _effects = new(StringComparer.Ordinal);
private readonly Dictionary<string, Func<IReadOnlyList<string>, string>> _commands = new(StringComparer.OrdinalIgnoreCase);
public ReplacedPersonRegistry(ReplacedContentCatalog baseCatalog) => _catalog = baseCatalog.Clone();
public ReplacedContentCatalog CreateSnapshot() => _catalog.Clone();
public IReadOnlyDictionary<string, IReplacedPersonEffectResolver> Effects => _effects;
public IReadOnlyDictionary<string, Func<IReadOnlyList<string>, string>> Commands => _commands;
public void RegisterCard(ReplacedCardDefinition definition) => AddUnique(_catalog.Cards, definition.Id, definition);
public void RegisterEndCard(ReplacedEndCardDefinition definition) => AddUnique(_catalog.EndCards, definition.Id, definition);
public void RegisterEnemy(ReplacedEnemyDefinition definition) => AddUnique(_catalog.Enemies, definition.Id, definition);
public void RegisterReward(string id, string displayName) => AddUnique(_rewards, id, displayName);
public void RegisterStory(string id, string title, string body) => AddUnique(_stories, id, (title, body));
public void RegisterEffectResolver(string effectId, IReplacedPersonEffectResolver resolver)
{
AddUnique(_effects, effectId, resolver);
AddUnique(_catalog.EffectResolvers, effectId, resolver);
}
public void RegisterCommand(string path, Func<IReadOnlyList<string>, string> handler) => AddUnique(_commands, path, handler);
private static void AddUnique<T>(IDictionary<string, T> target, string id, T value)
{
if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("Registry id must not be empty.", nameof(id));
var key = id.Trim();
if (target.ContainsKey(key)) throw new InvalidOperationException("Duplicate registry id: " + id);
target.Add(key, value);
}
}
public sealed class ReplacedHotReloadCatalog
{
public ReplacedContentCatalog Current { get; private set; }
public ReplacedContentCatalog? Pending { get; private set; }
public string CurrentHash => Current.ComputeHash();
public string PendingHash => Pending?.ComputeHash() ?? string.Empty;
public ReplacedHotReloadCatalog(ReplacedContentCatalog initial) => Current = initial.Clone();
public void Stage(ReplacedContentCatalog next) => Pending = next.Clone();
public ReplacedContentCatalog SnapshotForNewMatch()
{
if (Pending != null) { Current = Pending; Pending = null; }
return Current.Clone();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cf4213a9a53d2c74a917cdd129f062ed
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,272 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace ReplacedPerson.Core
{
public enum ReplacedNetworkMessageKind
{
Join = 0,
Create = 1,
Ready = 2,
Deck = 3,
MatchCommand = 4,
MatchSnapshot = 5,
MatchEvent = 6,
Reconnect = 7,
Error = 8
}
[Serializable]
public sealed class ReplacedNetworkEnvelope
{
public ReplacedNetworkMessageKind Kind { get; set; }
public long Sequence { get; set; }
public string IdempotencyToken { get; set; } = string.Empty;
public string RoomId { get; set; } = string.Empty;
public string PlayerId { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public string StateHash { get; set; } = string.Empty;
}
[Serializable]
public sealed class ReplacedJoinRequest
{
public string RoomId { get; set; } = string.Empty;
public string PlayerId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public List<ReplacedPersonModManifest> Mods { get; set; } = new();
}
[Serializable]
public sealed class ReplacedRoomSnapshot
{
public string RoomId { get; set; } = string.Empty;
public string ContentHash { get; set; } = string.Empty;
public List<string> Players { get; set; } = new();
public List<string> ReadyPlayers { get; set; } = new();
public bool MatchStarted { get; set; }
public string MatchStateHash { get; set; } = string.Empty;
}
[Serializable]
public sealed class ReplacedReadyRequest
{
public bool Ready { get; set; }
}
[Serializable]
public sealed class ReplacedMatchSnapshotPayload
{
public ReplacedRoomSnapshot Room { get; set; } = new();
public ReplacedMatchState? Match { get; set; }
public int LocalPlayerIndex { get; set; } = -1;
}
[Serializable]
public sealed class ReplacedNetworkResultPayload
{
public bool Accepted { get; set; }
public bool Duplicate { get; set; }
public string Code { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
}
public sealed class ReplacedRoomSeat
{
public string PlayerId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public ReplacedDeck? Deck { get; set; }
public bool Ready { get; set; }
public bool Connected { get; set; } = true;
public DateTime ReservedUntilUtc { get; set; }
public int ConsecutiveTimeouts { get; set; }
}
public sealed class ReplacedRoomAuthority
{
private readonly ReplacedContentCatalog _content;
private readonly List<ReplacedPersonModManifest> _mods;
private readonly ReplacedRoomSeat?[] _seats = new ReplacedRoomSeat?[2];
private readonly ulong _seed;
public string RoomId { get; }
public ReplacedMatchEngine? Match { get; private set; }
public IReadOnlyList<ReplacedRoomSeat?> Seats => _seats;
public ReplacedRoomAuthority(string roomId, ReplacedContentCatalog content,
IEnumerable<ReplacedPersonModManifest> mods, ulong seed)
{
RoomId = roomId;
_content = content.Clone();
_mods = new List<ReplacedPersonModManifest>(mods);
_seed = seed;
}
public ReplacedModCompatibilityResult TryJoin(ReplacedJoinRequest request, DateTime nowUtc)
{
var compatibility = ReplacedModCompatibility.Compare(_mods, request.Mods);
if (!compatibility.Compatible) return compatibility;
for (var i = 0; i < _seats.Length; i++)
{
if (_seats[i]?.PlayerId == request.PlayerId)
{
_seats[i]!.Connected = true;
_seats[i]!.ReservedUntilUtc = nowUtc.AddSeconds(30);
return compatibility;
}
}
for (var i = 0; i < _seats.Length; i++)
{
if (_seats[i] == null || (!_seats[i]!.Connected && _seats[i]!.ReservedUntilUtc <= nowUtc))
{
_seats[i] = new ReplacedRoomSeat
{
PlayerId = request.PlayerId,
DisplayName = request.DisplayName,
Connected = true,
ReservedUntilUtc = nowUtc.AddSeconds(30)
};
return compatibility;
}
}
compatibility.Differences.Add("room.full");
return compatibility;
}
public string SetDeck(string playerId, ReplacedDeck deck)
{
var seat = FindSeat(playerId);
var errors = ReplacedDeckValidator.Validate(deck, _content);
if (errors.Count > 0) return string.Join(",", errors);
seat.Deck = deck.Clone();
seat.Ready = false;
return string.Empty;
}
public string SetReady(string playerId, bool ready)
{
var seat = FindSeat(playerId);
if (ready && seat.Deck == null) return "deck.missing";
seat.Ready = ready;
if (Match == null && _seats[0]?.Ready == true && _seats[1]?.Ready == true)
Match = new ReplacedMatchEngine(_content, _seats[0]!.Deck!, _seats[1]!.Deck!, _seed,
_seats[0]!.DisplayName, _seats[1]!.DisplayName);
return string.Empty;
}
public string SetDeckAndReady(string playerId, ReplacedDeck deck)
{
var error = SetDeck(playerId, deck);
return string.IsNullOrEmpty(error) ? SetReady(playerId, true) : error;
}
public ReplacedCommandResult Apply(string playerId, ReplacedMatchCommand command)
{
if (Match == null) return new ReplacedCommandResult { ErrorCode = "match.not-started", Message = "match.not-started" };
var index = _seats[0]?.PlayerId == playerId ? 0 : _seats[1]?.PlayerId == playerId ? 1 : -1;
if (index < 0) return new ReplacedCommandResult { ErrorCode = "player.not-seated", Message = "player.not-seated" };
command.PlayerIndex = index;
return Match.Apply(command);
}
public void Disconnect(string playerId, DateTime nowUtc)
{
var seat = FindSeat(playerId);
seat.Connected = false;
seat.ReservedUntilUtc = nowUtc.AddSeconds(30);
}
public void RecordTurnTimeout(string playerId)
{
var seat = FindSeat(playerId);
seat.ConsecutiveTimeouts++;
if (seat.ConsecutiveTimeouts >= 2 && Match != null && Match.State.Phase != ReplacedMatchPhase.Completed)
{
Match.State.Winner = ReferenceEquals(seat, _seats[0]) ? ReplacedWinner.PlayerTwo : ReplacedWinner.PlayerOne;
Match.State.Phase = ReplacedMatchPhase.Completed;
Match.State.Log.Add("连续两个回合超时判负");
}
}
public bool Kick(string playerId)
{
for (var i = 0; i < _seats.Length; i++)
{
if (_seats[i]?.PlayerId != playerId) continue;
_seats[i] = null;
return true;
}
return false;
}
public int GetPlayerIndex(string playerId)
{
if (_seats[0]?.PlayerId == playerId) return 0;
if (_seats[1]?.PlayerId == playerId) return 1;
return -1;
}
public ReplacedMatchState? PlayerMatchSnapshot(string playerId)
{
if (Match == null) return null;
var playerIndex = GetPlayerIndex(playerId);
if (playerIndex < 0) return null;
var snapshot = Match.State.Clone();
var opponent = snapshot.Players[1 - playerIndex];
opponent.NormalHand = HiddenCards(opponent.NormalHand.Count);
opponent.EndHand = HiddenCards(opponent.EndHand.Count);
opponent.NormalDrawPile = HiddenCards(opponent.NormalDrawPile.Count);
opponent.EndDrawPile = HiddenCards(opponent.EndDrawPile.Count);
return snapshot;
}
public ReplacedRoomSnapshot Snapshot()
{
var result = new ReplacedRoomSnapshot
{
RoomId = RoomId,
ContentHash = _content.ComputeHash(),
MatchStarted = Match != null,
MatchStateHash = Match?.ComputeStateHash() ?? string.Empty
};
foreach (var seat in _seats)
{
if (seat == null) continue;
result.Players.Add(seat.PlayerId);
if (seat.Ready) result.ReadyPlayers.Add(seat.PlayerId);
}
return result;
}
private ReplacedRoomSeat FindSeat(string playerId)
{
foreach (var seat in _seats)
if (seat?.PlayerId == playerId) return seat;
throw new InvalidOperationException("Player is not seated: " + playerId);
}
private static List<string> HiddenCards(int count)
{
var result = new List<string>(count);
for (var i = 0; i < count; i++) result.Add(string.Empty);
return result;
}
}
public static class ReplacedReplayRunner
{
public static ReplacedMatchEngine Replay(ReplacedContentCatalog content, ReplacedDeck first, ReplacedDeck second,
ulong seed, IEnumerable<ReplacedMatchCommand> commands)
{
var engine = new ReplacedMatchEngine(content, first, second, seed);
foreach (var command in commands)
{
var result = engine.Apply(command.Clone());
if (!result.Accepted) throw new InvalidOperationException(result.ErrorCode + ":" + result.Message);
}
return engine;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6ac7eae5b8f766a40ab60ce8ea0fb184
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+64
View File
@@ -0,0 +1,64 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace ReplacedPerson.Core
{
/// <summary>PCG-XSH-RR 32; state is explicit so replays are portable across Unity and .NET.</summary>
public sealed class Pcg32
{
public ulong State { get; private set; }
public ulong Increment { get; private set; }
public Pcg32(ulong seed, ulong stream = 54UL)
{
State = 0UL;
Increment = (stream << 1) | 1UL;
NextUInt();
State += seed;
NextUInt();
}
private Pcg32(ulong state, ulong increment, bool _) { State = state; Increment = increment; }
public Pcg32 Clone() => new(State, Increment, true);
public uint NextUInt()
{
var oldState = State;
State = unchecked(oldState * 6364136223846793005UL + Increment);
var xorShifted = (uint)(((oldState >> 18) ^ oldState) >> 27);
var rotation = (int)(oldState >> 59);
return (xorShifted >> rotation) | (xorShifted << ((-rotation) & 31));
}
public int NextInt(int exclusiveMax)
{
if (exclusiveMax <= 0)
throw new ArgumentOutOfRangeException(nameof(exclusiveMax));
var bound = (uint)exclusiveMax;
var threshold = unchecked((uint)(-bound)) % bound;
while (true)
{
var value = NextUInt();
if (value >= threshold)
return (int)(value % bound);
}
}
public int RollD6() => NextInt(6) + 1;
public void Shuffle<T>(IList<T> values)
{
if (values == null)
throw new ArgumentNullException(nameof(values));
for (var i = values.Count - 1; i > 0; i--)
{
var j = NextInt(i + 1);
(values[i], values[j]) = (values[j], values[i]);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d9886d17170c61941a970081ad8e742a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,81 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
namespace ReplacedPerson.Core
{
[Serializable]
public sealed class ReplacedPersonSaveData
{
public const int CurrentVersion = 3;
public int Version { get; set; } = CurrentVersion;
public int ChapterProgress { get; set; }
public List<string> CollectedCards { get; set; } = new();
public List<string> Ornaments { get; set; } = new();
public List<string> EquippedOrnaments { get; set; } = new();
public ReplacedDeck ActiveDeck { get; set; } = ReplacedBuiltInContent.CreateStarterDeck();
public List<ReplacedMatchCommand> RecentReplay { get; set; } = new();
}
public static class ReplacedPersonSaveMigration
{
public static ReplacedPersonSaveData Migrate(ReplacedPersonSaveData source)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (source.Version > ReplacedPersonSaveData.CurrentVersion)
throw new InvalidOperationException("Save data comes from a newer version.");
source.CollectedCards ??= new List<string>();
source.Ornaments ??= new List<string>();
source.EquippedOrnaments ??= new List<string>();
source.ActiveDeck ??= ReplacedBuiltInContent.CreateStarterDeck();
source.RecentReplay ??= new List<ReplacedMatchCommand>();
if (source.Version < 2)
{
if (source.ChapterProgress > 0 && !source.Ornaments.Contains("ornament.gold-eye"))
source.Ornaments.Add("ornament.gold-eye");
source.Version = 2;
}
if (source.Version < 3)
{
var starter = ReplacedBuiltInContent.CreateStarterDeck();
if (IsLegacyStarter(source.ActiveDeck)) source.ActiveDeck = starter;
EnsureOwnedCopies(source.CollectedCards, starter.NormalCards);
source.Version = 3;
}
return source;
}
public static ReplacedPersonSaveData CreateNew()
{
var deck = ReplacedBuiltInContent.CreateStarterDeck();
return new ReplacedPersonSaveData
{
ActiveDeck = deck,
CollectedCards = new List<string>(deck.NormalCards)
};
}
private static bool IsLegacyStarter(ReplacedDeck deck)
{
var legacy = new[]
{
"attack.kill", "attack.kill", "attack.elbow", "attack.elbow", "attack.break", "attack.break",
"dodge.flash", "dodge.flash", "dodge.phase", "dodge.phase", "counter.blood", "counter.blood"
};
return deck.NormalCards.OrderBy(value => value, StringComparer.Ordinal)
.SequenceEqual(legacy.OrderBy(value => value, StringComparer.Ordinal)) &&
deck.EndCards.SequenceEqual(new[] { "end.inherit", "end.ledger", "end.severance", "end.black" });
}
private static void EnsureOwnedCopies(List<string> collection, IEnumerable<string> starterCards)
{
foreach (var group in starterCards.GroupBy(value => value, StringComparer.Ordinal))
{
var missing = group.Count() - collection.Count(value => value == group.Key);
for (var i = 0; i < missing; i++) collection.Add(group.Key);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e65918b8d9a6065499ad8cd7f6247e40
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,84 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
namespace ReplacedPerson.Core
{
public sealed class ReplacedAi
{
private readonly ReplacedContentCatalog _content;
public ReplacedAi(ReplacedContentCatalog content) => _content = content ?? throw new ArgumentNullException(nameof(content));
/// <summary>Uses only own hand plus the already-public lead submission.</summary>
public ReplacedTurnSubmission Choose(ReplacedMatchState visibleState, int playerIndex)
{
var self = visibleState.Players[playerIndex];
var targetCount = visibleState.IsLastStand ? 4 : Math.Min(3, self.NormalHand.Count);
var publicOpponent = visibleState.Submissions[1 - playerIndex];
var opponentTypes = publicOpponent == null
? Array.Empty<ReplacedEffectType>()
: publicOpponent.NormalCardIds.Where(_content.Cards.ContainsKey).Select(id => _content.Cards[id].EffectType).ToArray();
var scored = new List<(string id, double score)>();
foreach (var id in self.NormalHand.Distinct(StringComparer.Ordinal))
{
var card = _content.Cards[id];
var expected = card.BaseValue + card.Factor * 3.5;
var efficiency = expected / Math.Max(1, card.Cost);
var survival = self.Health <= 10 && card.EffectType != ReplacedEffectType.Attack ? 4.0 : 0.0;
var counterScore = opponentTypes.Sum(type => MatchupScore(card.EffectType, type));
scored.Add((id, expected + efficiency * 2.0 + survival + counterScore));
}
var selected = new List<string>();
var remainingCost = self.Cost;
foreach (var candidate in scored.OrderByDescending(value => value.score).ThenBy(value => value.id, StringComparer.Ordinal))
{
var available = self.NormalHand.Count(id => id == candidate.id);
for (var copy = 0; copy < available && selected.Count < targetCount; copy++)
{
var cost = _content.Cards[candidate.id].Cost;
if (cost > remainingCost) continue;
selected.Add(candidate.id);
remainingCost -= cost;
}
}
var endCount = visibleState.IsLastStand ? 2 : 1;
var ends = self.EndHand.Where(id => _content.EndCards[id].Cost <= remainingCost)
.OrderByDescending(id => _content.EndCards[id].SlotBase.Sum() + _content.EndCards[id].SlotFactor.Sum() * 3.5)
.ThenBy(id => id, StringComparer.Ordinal).Take(endCount).ToList();
if (selected.Count < (visibleState.IsLastStand ? 4 : 1) || ends.Count < endCount)
return new ReplacedTurnSubmission { Pass = true, DiscardEndOnPass = false };
return new ReplacedTurnSubmission
{
NormalCardIds = selected,
EndCardIds = ends,
RerollSlot = ChooseRerollSlot(selected)
};
}
private int ChooseRerollSlot(IReadOnlyList<string> selected)
{
if (selected.Count == 0) return -1;
var weakest = double.MaxValue;
var result = -1;
for (var i = 0; i < Math.Min(3, selected.Count); i++)
{
var card = _content.Cards[selected[i]];
var expected = card.BaseValue + card.Factor * 3.5;
if (expected < weakest) { weakest = expected; result = i; }
}
return result;
}
private static double MatchupScore(ReplacedEffectType own, ReplacedEffectType opponent) =>
own == ReplacedEffectType.Counter && opponent == ReplacedEffectType.Attack ? 4.0 :
own == ReplacedEffectType.Dodge && opponent == ReplacedEffectType.Attack ? 3.0 :
own == ReplacedEffectType.Attack && opponent == ReplacedEffectType.Dodge ? 2.0 : 0.0;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 54aee14b2a69366498d14e2f9294bcdb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,13 @@
{
"name": "ReplacedPerson.Core",
"rootNamespace": "ReplacedPerson.Core",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2d11e372f3354d047977650288e4ea13
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
#nullable enable
using System;
using System.Security.Cryptography;
using System.Text;
namespace ReplacedPerson.Core
{
public static class ReplacedStateHasher
{
public static string Compute(ReplacedMatchState state, Pcg32 random)
{
var text = new StringBuilder(2048);
text.Append(state.Round).Append('|').Append(state.LeadPlayer).Append('|').Append(state.InitiativeRoll)
.Append('|').Append((int)state.Phase).Append('|').Append((int)state.Winner).Append('|')
.Append(state.IsLastStand ? 1 : 0).Append('|').Append(state.LastStandUsed ? 1 : 0).Append('|')
.Append(state.ContentHash).Append('|').Append(random.State).Append('|').Append(random.Increment).Append('\n');
for (var index = 0; index < state.Players.Length; index++)
{
var player = state.Players[index];
text.Append(index).Append('|').Append(player.PlayerId).Append('|').Append(player.Health).Append('|')
.Append(player.Cost).Append('|').Append(player.Defense).Append('|').Append(player.BonusNormalDraw)
.Append('|').Append(player.GuaranteedDodge ? 1 : 0).Append('|').Append(player.SkipNextEndDraw ? 1 : 0)
.Append('|').Append(player.SpentThisRound).Append('|').Append(player.EffectiveEffectsThisRound)
.Append('|').Append(player.DamageThisRound).Append('\n');
AppendList(text, player.NormalDrawPile);
AppendList(text, player.NormalDiscard);
AppendList(text, player.NormalHand);
AppendList(text, player.EndDrawPile);
AppendList(text, player.EndDiscard);
AppendList(text, player.EndHand);
}
for (var i = 0; i < state.LastAcceptedSequence.Length; i++)
text.Append("S|").Append(i).Append('|').Append(state.LastAcceptedSequence[i]).Append('\n');
for (var i = 0; i < state.Submissions.Length; i++)
{
var submission = state.Submissions[i];
text.Append("C|").Append(i).Append('|');
if (submission == null)
{
text.Append("null\n");
continue;
}
text.Append(submission.Pass ? 1 : 0).Append('|').Append(submission.DiscardEndOnPass ? 1 : 0)
.Append('|').Append(submission.RerollSlot).Append('|');
AppendList(text, submission.NormalCardIds);
AppendList(text, submission.EndCardIds);
}
foreach (var resolution in state.LastResolution)
{
text.Append("R|").Append(resolution.SlotIndex).Append('|').Append((int)resolution.PlayerOneType).Append('|')
.Append((int)resolution.PlayerTwoType).Append('|').Append(resolution.PlayerOneRoll).Append('|')
.Append(resolution.PlayerTwoRoll).Append('|').Append(resolution.PlayerOneValue).Append('|')
.Append(resolution.PlayerTwoValue).Append('|').Append(resolution.PlayerOneRerolled ? 1 : 0).Append('|')
.Append(resolution.PlayerTwoRerolled ? 1 : 0).Append('|').Append(resolution.DamageToPlayerOne).Append('|')
.Append(resolution.DamageToPlayerTwo).Append('|').Append(resolution.Summary).Append('\n');
}
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(text.ToString()));
var result = new StringBuilder(bytes.Length * 2);
foreach (var value in bytes) result.Append(value.ToString("x2"));
return result.ToString();
}
private static void AppendList(StringBuilder target, System.Collections.Generic.IEnumerable<string> values) =>
target.Append('[').Append(string.Join(",", values)).Append("]\n");
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c301f3f68d939564db3971ef7199dd01
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: