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
@@ -0,0 +1,13 @@
{
"name": "ReplacedPerson.EditMode.Tests",
"rootNamespace": "ReplacedPerson.Tests",
"references": ["ReplacedPerson.Core", "ReplacedPerson.Runtime", "ExampleGreedMod.Rules", "UnityEngine.TestRunner", "UnityEditor.TestRunner"],
"includePlatforms": ["Editor"],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": false,
"defineConstraints": ["UNITY_INCLUDE_TESTS"],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 486ca7ff6bf7f444395635a675a3f4c0
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,362 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using ExampleGreedMod;
using NUnit.Framework;
using ReplacedPerson.Core;
namespace ReplacedPerson.Tests
{
public sealed class ReplacedPersonCoreTests
{
[Test]
public void Pcg32_IsDeterministic()
{
var a = new Pcg32(12345);
var b = new Pcg32(12345);
CollectionAssert.AreEqual(Enumerable.Range(0, 32).Select(_ => a.NextUInt()).ToArray(),
Enumerable.Range(0, 32).Select(_ => b.NextUInt()).ToArray());
}
[Test]
public void BuiltInDeck_IsValid()
{
var content = ReplacedBuiltInContent.Create();
var starter = ReplacedBuiltInContent.CreateStarterDeck();
Assert.That(ReplacedDeckValidator.Validate(starter, content), Is.Empty);
Assert.That(starter.NormalCards.Count(id => content.Cards[id].EffectType == ReplacedEffectType.Counter), Is.EqualTo(6));
Assert.That(starter.NormalCards.Count(id => content.Cards[id].EffectType == ReplacedEffectType.Dodge), Is.EqualTo(2));
Assert.That(starter.NormalCards.Count(id => content.Cards[id].EffectType == ReplacedEffectType.Attack), Is.EqualTo(4));
Assert.That(content.Enemies.ContainsKey("greed.tutorial"));
Assert.That(content.Enemies.ContainsKey("greed.full"));
}
[Test]
public void Match_StartsWithFourNormalAndOneEnd()
{
var engine = BuiltInEngine(1);
Assert.That(engine.State.Round, Is.EqualTo(1));
Assert.That(engine.State.Players[0].NormalHand, Has.Count.EqualTo(4));
Assert.That(engine.State.Players[0].EndHand, Has.Count.EqualTo(1));
Assert.That(engine.State.Phase, Is.EqualTo(ReplacedMatchPhase.AwaitingLead));
}
[Test]
public void ResolvedSlots_RemainVisibleDuringTheNextRound()
{
var setup = FixedEngine(ReplacedEffectType.Attack, 4, ReplacedEffectType.Counter, 3, 31);
ResolveOneCardRound(setup.engine, setup.cardOne, setup.cardTwo);
Assert.That(setup.engine.State.Round, Is.EqualTo(2));
Assert.That(setup.engine.State.LastResolution, Has.Count.EqualTo(1));
Assert.That(setup.engine.State.LastResolution[0].Summary, Is.Not.Empty);
}
[Test]
public void Command_RejectsWrongPlayerAndDeduplicatesAcceptedToken()
{
var engine = BuiltInEngine(2);
var wrong = 1 - engine.State.LeadPlayer;
var rejected = engine.Apply(Command(engine, wrong, Pass(), 1, "wrong"));
Assert.That(rejected.ErrorCode, Is.EqualTo("turn.not-yours"));
var lead = engine.State.LeadPlayer;
var accepted = Command(engine, lead, Pass(), 1, "same");
Assert.That(engine.Apply(accepted).Accepted, Is.True);
var duplicate = engine.Apply(accepted);
Assert.That(duplicate.Accepted, Is.True);
Assert.That(duplicate.Duplicate, Is.True);
}
[Test]
public void AttackAgainstDodge_DealsDifferenceCappedAtEight()
{
var setup = FixedEngine(ReplacedEffectType.Attack, 20, ReplacedEffectType.Dodge, 1, 3);
ResolveOneCardRound(setup.engine, setup.cardOne, setup.cardTwo);
Assert.That(setup.engine.State.Players[1].Health, Is.EqualTo(22));
}
[Test]
public void CounterAgainstAttack_CancelsAndReturnsDamageCappedAtSix()
{
var setup = FixedEngine(ReplacedEffectType.Counter, 20, ReplacedEffectType.Attack, 1, 4);
ResolveOneCardRound(setup.engine, setup.cardOne, setup.cardTwo);
Assert.That(setup.engine.State.Players[1].Health, Is.EqualTo(24));
}
[Test]
public void DodgeAgainstAttack_GrantsExtraDraw()
{
var setup = FixedEngine(ReplacedEffectType.Dodge, 20, ReplacedEffectType.Attack, 1, 5);
ResolveOneCardRound(setup.engine, setup.cardOne, setup.cardTwo);
Assert.That(setup.engine.State.Players[0].NormalHand.Count, Is.EqualTo(6));
Assert.That(setup.engine.State.Players[0].Health, Is.EqualTo(30));
}
[Test]
public void DoubleAttackFusion_UsesOnePointFiveMultiplier()
{
var setup = FixedEngine(ReplacedEffectType.Attack, 2, ReplacedEffectType.Dodge, 1, 6);
var state = setup.engine.State;
state.Players[0].NormalHand.Clear();
state.Players[0].NormalHand.AddRange(new[] { setup.cardOne, setup.cardOne, setup.cardOne, setup.cardOne });
var first = new ReplacedTurnSubmission
{
NormalCardIds = new List<string> { setup.cardOne, setup.cardOne, setup.cardOne, setup.cardOne },
EndCardIds = new List<string> { state.Players[0].EndHand[0] }
};
ResolveRound(setup.engine, first, Pass());
Assert.That(setup.engine.State.Players[1].Health, Is.EqualTo(20));
}
[Test]
public void Pass_SuppressesNextEndDrawAndPaysNoCost()
{
var engine = BuiltInEngine(7);
var endsBefore = engine.State.Players[0].EndHand.Count;
ResolveRound(engine, Pass(), Pass());
Assert.That(engine.State.Players[0].Cost, Is.EqualTo(10));
Assert.That(engine.State.Players[0].EndHand.Count, Is.EqualTo(endsBefore));
}
[Test]
public void Cost_IsPaidAtLockAndRecoveredAtRoundEnd()
{
var engine = BuiltInEngine(8);
var firstPlayer = engine.State.LeadPlayer;
var player = engine.State.Players[firstPlayer];
var affordable = player.NormalHand.First(id => engine.Content.Cards[id].Cost <= player.Cost);
var ending = player.EndHand[0];
var spent = engine.Content.Cards[affordable].Cost + engine.Content.EndCards[ending].Cost;
var result = engine.Apply(Command(engine, firstPlayer, Submit(affordable, ending), 1, "lock"));
Assert.That(result.Accepted);
Assert.That(player.Cost, Is.EqualTo(10 - spent));
var other = 1 - firstPlayer;
engine.Apply(Command(engine, other, Pass(), 1, "other"));
Assert.That(player.Cost, Is.EqualTo(Math.Min(10, 10 - spent + 1 + spent / 2)));
}
[Test]
public void SimultaneousLethal_EntersLastStand()
{
var setup = FixedEngine(ReplacedEffectType.Attack, 3, ReplacedEffectType.Attack, 1, 9);
var engine = setup.engine;
var strong = setup.cardOne;
var weak = setup.cardTwo;
engine.State.Players[0].Health = 1;
engine.State.Players[1].Health = 1;
engine.State.Players[0].NormalHand.Clear();
engine.State.Players[1].NormalHand.Clear();
engine.State.Players[0].NormalHand.AddRange(new[] { strong, weak });
engine.State.Players[1].NormalHand.AddRange(new[] { weak, strong });
var end0 = engine.State.Players[0].EndHand[0];
var end1 = engine.State.Players[1].EndHand[0];
ResolveRound(engine,
new ReplacedTurnSubmission { NormalCardIds = new List<string> { strong, weak }, EndCardIds = new List<string> { end0 } },
new ReplacedTurnSubmission { NormalCardIds = new List<string> { weak, strong }, EndCardIds = new List<string> { end1 } });
Assert.That(engine.State.IsLastStand, Is.True);
Assert.That(engine.State.LastStandUsed, Is.True);
Assert.That(engine.State.Players[0].NormalHand, Has.Count.EqualTo(4));
Assert.That(engine.State.Players[0].EndHand, Has.Count.EqualTo(2));
}
[Test]
public void Replay_ProducesIdenticalStateHash()
{
var content = ReplacedBuiltInContent.Create();
var deck = ReplacedBuiltInContent.CreateStarterDeck();
var original = new ReplacedMatchEngine(content, deck, deck, 77);
ResolveRound(original, Pass(), Pass());
ResolveRound(original, Pass(), Pass());
var replay = ReplacedReplayRunner.Replay(content, deck, deck, 77, original.Replay);
Assert.That(replay.ComputeStateHash(), Is.EqualTo(original.ComputeStateHash()));
}
[Test]
public void StateHash_IncludesVisibleSlotResolution()
{
var engine = BuiltInEngine(91);
var before = engine.ComputeStateHash();
engine.State.LastResolution.Add(new ReplacedSlotResolution
{
SlotIndex = 0,
PlayerOneType = ReplacedEffectType.Counter,
PlayerTwoType = ReplacedEffectType.Attack,
PlayerOneRoll = 5,
PlayerTwoRoll = 2,
PlayerOneValue = 17,
PlayerTwoValue = 8,
DamageToPlayerTwo = 6,
Summary = "hash-visible-resolution"
});
Assert.That(engine.ComputeStateHash(), Is.Not.EqualTo(before));
}
[Test]
public void Ai_ProducesLegalSubmissionWithoutMutatingVisibleState()
{
var engine = BuiltInEngine(12);
var aiPlayer = engine.State.LeadPlayer;
var before = engine.ComputeStateHash();
var submission = new ReplacedAi(engine.Content).Choose(engine.State.Clone(), aiPlayer);
Assert.That(engine.ComputeStateHash(), Is.EqualTo(before));
Assert.That(engine.Apply(Command(engine, aiPlayer, submission, 1, "ai")).Accepted, Is.True);
}
[Test]
public void SaveMigration_AddsFirstOrnamentForLegacyProgress()
{
var save = new ReplacedPersonSaveData { Version = 1, ChapterProgress = 1, Ornaments = new List<string>() };
ReplacedPersonSaveMigration.Migrate(save);
Assert.That(save.Version, Is.EqualTo(3));
Assert.That(save.Ornaments, Contains.Item("ornament.gold-eye"));
Assert.That(save.ActiveDeck.NormalCards.Count(id => id == "counter.stance"), Is.EqualTo(2));
Assert.That(save.CollectedCards.Count(id => id == "counter.stance"), Is.EqualTo(2));
}
[Test]
public void ModHandshake_ReportsVersionAndFileHashDifferences()
{
var expected = Manifest("a", "1", "x.dll", "aa");
var actual = Manifest("a", "2", "x.dll", "bb");
var result = ReplacedModCompatibility.Compare(new[] { expected }, new[] { actual });
Assert.That(result.Compatible, Is.False);
Assert.That(result.Differences.Any(value => value.StartsWith("version:", StringComparison.Ordinal)));
Assert.That(result.Differences.Any(value => value.StartsWith("hash:", StringComparison.Ordinal)));
}
[Test]
public void HotReload_DoesNotChangeCurrentMatchSnapshot()
{
var catalog = ReplacedBuiltInContent.Create();
var hotReload = new ReplacedHotReloadCatalog(catalog);
var registry = new ReplacedPersonRegistry(catalog);
new ExampleGreedRules().Register(registry);
var next = registry.CreateSnapshot();
Assert.That(next.ComputeHash(), Is.Not.EqualTo(catalog.ComputeHash()));
hotReload.Stage(next);
Assert.That(hotReload.CurrentHash, Is.EqualTo(catalog.ComputeHash()));
var promoted = hotReload.SnapshotForNewMatch();
Assert.That(hotReload.Pending, Is.Null);
Assert.That(promoted.Cards, Contains.Key("example.audit"));
}
[Test]
public void PlayerSnapshot_HidesOpponentCardsButPreservesCounts()
{
var room = StartedRoom();
var expectedNormalCount = room.Match!.State.Players[1].NormalHand.Count;
var snapshot = room.PlayerMatchSnapshot("a")!;
Assert.That(snapshot.Players[1].NormalHand, Has.Count.EqualTo(expectedNormalCount));
Assert.That(snapshot.Players[1].NormalHand.All(string.IsNullOrEmpty));
Assert.That(snapshot.Players[0].NormalHand.All(value => !string.IsNullOrEmpty(value)));
}
[Test]
public void RoomAuthority_StartsOnlyAfterTwoValidReadyDecks()
{
var content = ReplacedBuiltInContent.Create();
var room = new ReplacedRoomAuthority("test", content, Array.Empty<ReplacedPersonModManifest>(), 20);
Assert.That(room.TryJoin(Join("a"), DateTime.UtcNow).Compatible);
Assert.That(room.TryJoin(Join("b"), DateTime.UtcNow).Compatible);
Assert.That(room.SetDeckAndReady("a", ReplacedBuiltInContent.CreateStarterDeck()), Is.Empty);
Assert.That(room.Match, Is.Null);
Assert.That(room.SetDeckAndReady("b", ReplacedBuiltInContent.CreateStarterDeck()), Is.Empty);
Assert.That(room.Match, Is.Not.Null);
}
[Test]
public void TwoConsecutiveTimeouts_ForfeitMatch()
{
var content = ReplacedBuiltInContent.Create();
var room = new ReplacedRoomAuthority("test", content, Array.Empty<ReplacedPersonModManifest>(), 21);
room.TryJoin(Join("a"), DateTime.UtcNow); room.TryJoin(Join("b"), DateTime.UtcNow);
room.SetDeckAndReady("a", ReplacedBuiltInContent.CreateStarterDeck()); room.SetDeckAndReady("b", ReplacedBuiltInContent.CreateStarterDeck());
room.RecordTurnTimeout("a"); room.RecordTurnTimeout("a");
Assert.That(room.Match!.State.Winner, Is.EqualTo(ReplacedWinner.PlayerTwo));
}
private static ReplacedMatchEngine BuiltInEngine(ulong seed)
{
var deck = ReplacedBuiltInContent.CreateStarterDeck();
return new ReplacedMatchEngine(ReplacedBuiltInContent.Create(), deck, deck, seed);
}
private static ReplacedRoomAuthority StartedRoom()
{
var room = new ReplacedRoomAuthority("test", ReplacedBuiltInContent.Create(),
Array.Empty<ReplacedPersonModManifest>(), 22);
room.TryJoin(Join("a"), DateTime.UtcNow); room.TryJoin(Join("b"), DateTime.UtcNow);
room.SetDeckAndReady("a", ReplacedBuiltInContent.CreateStarterDeck());
room.SetDeckAndReady("b", ReplacedBuiltInContent.CreateStarterDeck());
return room;
}
private static (ReplacedMatchEngine engine, string cardOne, string cardTwo) FixedEngine(
ReplacedEffectType firstType, int firstValue, ReplacedEffectType secondType, int secondValue, ulong seed)
{
var content = new ReplacedContentCatalog();
var firstIds = AddSixCards(content, "one", firstType, firstValue);
var secondIds = AddSixCards(content, "two", secondType, secondValue);
var ends = new List<string>();
for (var i = 0; i < 4; i++)
{
var id = "end." + i;
content.EndCards.Add(id, new ReplacedEndCardDefinition { Id = id, Name = id, SlotBase = new int[3], SlotFactor = new int[3] });
ends.Add(id);
}
var firstDeck = new ReplacedDeck { NormalCards = firstIds.SelectMany(id => new[] { id, id }).ToList(), EndCards = new List<string>(ends) };
var secondDeck = new ReplacedDeck { NormalCards = secondIds.SelectMany(id => new[] { id, id }).ToList(), EndCards = new List<string>(ends) };
var engine = new ReplacedMatchEngine(content, firstDeck, secondDeck, seed);
return (engine, engine.State.Players[0].NormalHand[0], engine.State.Players[1].NormalHand[0]);
}
private static List<string> AddSixCards(ReplacedContentCatalog content, string prefix, ReplacedEffectType type, int value)
{
var result = new List<string>();
for (var i = 0; i < 6; i++)
{
var id = prefix + "." + i;
content.Cards.Add(id, new ReplacedCardDefinition { Id = id, Name = id, EffectType = type, BaseValue = value, Factor = 0, Cost = 0 });
result.Add(id);
}
return result;
}
private static void ResolveOneCardRound(ReplacedMatchEngine engine, string first, string second)
{
ResolveRound(engine, Submit(first, engine.State.Players[0].EndHand[0]), Submit(second, engine.State.Players[1].EndHand[0]));
}
private static void ResolveRound(ReplacedMatchEngine engine, ReplacedTurnSubmission first, ReplacedTurnSubmission second)
{
var byPlayer = new[] { first, second };
var lead = engine.State.LeadPlayer;
Assert.That(engine.Apply(Command(engine, lead, byPlayer[lead], engine.State.LastAcceptedSequence[lead] + 1, Guid.NewGuid().ToString("N"))).Accepted);
var response = 1 - lead;
Assert.That(engine.Apply(Command(engine, response, byPlayer[response], engine.State.LastAcceptedSequence[response] + 1, Guid.NewGuid().ToString("N"))).Accepted);
}
private static ReplacedMatchCommand Command(ReplacedMatchEngine engine, int player, ReplacedTurnSubmission submission, long sequence, string token) => new()
{
PlayerIndex = player, Submission = submission, Sequence = sequence, IdempotencyToken = token
};
private static ReplacedTurnSubmission Submit(string card, string ending) => new()
{
NormalCardIds = new List<string> { card }, EndCardIds = new List<string> { ending }
};
private static ReplacedTurnSubmission Pass() => new() { Pass = true };
private static ReplacedPersonModManifest Manifest(string id, string version, string path, string hash) => new()
{
Id = id, Version = version, Files = new List<ReplacedPersonModFile> { new() { Path = path, Sha256 = hash } }
};
private static ReplacedJoinRequest Join(string id) => new()
{
RoomId = "test", PlayerId = id, DisplayName = id, Mods = new List<ReplacedPersonModManifest>()
};
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 66da5bf1a98e7aa42bb87d6685a4d862
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: