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,8 @@
fileFormatVersion: 2
guid: 5024fb2de5966154ca9fd4d6d17c27dc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9797e4d566011044bb9e083d1c531488
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "ReplacedPerson.PlayMode.Tests",
"rootNamespace": "ReplacedPerson.Tests",
"references": ["ReplacedPerson.Core", "ReplacedPerson.Runtime", "UnityEngine.UI", "Unity.TextMeshPro"],
"optionalUnityReferences": ["TestAssemblies"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": false,
"defineConstraints": ["UNITY_INCLUDE_TESTS"],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 22325762d0cf69e43bffc9022b9907df
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,267 @@
#nullable enable
using System.Collections;
using System.Linq;
using NUnit.Framework;
using ReplacedPerson.Runtime;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using UnityEngine.UI;
namespace ReplacedPerson.Tests
{
public sealed class ReplacedPersonSceneTests
{
[UnityTest]
public IEnumerator EntryScene_HasResponsiveCanvasAndAllScreens()
{
SceneManager.LoadScene("ReplacedPerson");
yield return null;
var root = GameObject.Find("ReplacedPersonDemo");
Assert.That(root, Is.Not.Null);
var camera = Camera.main;
Assert.That(camera, Is.Not.Null);
Assert.That(camera!.enabled, Is.True);
Assert.That(camera.cullingMask, Is.Zero);
var canvas = root.transform.Find("Canvas")!.GetComponent<Canvas>();
var scaler = canvas.GetComponent<CanvasScaler>();
Assert.That(canvas.renderMode, Is.EqualTo(RenderMode.ScreenSpaceOverlay));
Assert.That(scaler.referenceResolution, Is.EqualTo(new Vector2(1920, 1080)));
Assert.That(root.transform.Find("Canvas/SafeAreaRoot")!.GetComponent<ReplacedSafeArea>(), Is.Not.Null);
Assert.That(root.GetComponentsInChildren<TMP_Text>(true), Has.Length.GreaterThan(20));
Assert.That(root.GetComponentsInChildren<Button>(true), Has.Length.GreaterThan(12));
Assert.That(root.transform.Find("Canvas/SafeAreaRoot/Screens/BattleScreen/LogPanel/CardDetailText"), Is.Not.Null);
foreach (var audioName in new[] { "ui_click", "card_draw", "card_select", "lock", "dice_roll", "hit", "victory", "defeat", "error" })
Assert.That(Resources.Load<AudioClip>("ReplacedPersonAudio/" + audioName), Is.Not.Null, audioName);
}
[UnityTest]
public IEnumerator GameService_CompletesARealRoundWithAi()
{
var service = new ReplacedPersonGameService();
var match = service.StartAi("greed.tutorial", 123);
while ((match.State.Phase == Core.ReplacedMatchPhase.AwaitingLead ? match.State.LeadPlayer : 1 - match.State.LeadPlayer) != 0)
yield return null;
var player = match.State.Players[0];
var submission = new Core.ReplacedTurnSubmission
{
NormalCardIds = new System.Collections.Generic.List<string> { player.NormalHand[0] },
EndCardIds = new System.Collections.Generic.List<string> { player.EndHand[0] },
RerollSlot = 0
};
Assert.That(service.SubmitPlayer(submission).Accepted, Is.True);
Assert.That(match.State.Round, Is.GreaterThanOrEqualTo(2));
service.Dispose();
yield return null;
}
[UnityTest]
public IEnumerator DraggingCard_RestoresItsHandOrderAndLayoutPosition()
{
var canvasObject = new GameObject("DragTestCanvas", typeof(RectTransform), typeof(Canvas));
canvasObject.GetComponent<Canvas>().renderMode = RenderMode.ScreenSpaceOverlay;
var hand = new GameObject("Hand", typeof(RectTransform), typeof(HorizontalLayoutGroup));
hand.transform.SetParent(canvasObject.transform, false);
var handRect = hand.GetComponent<RectTransform>();
handRect.sizeDelta = new Vector2(480, 160);
var layout = hand.GetComponent<HorizontalLayoutGroup>();
layout.spacing = 8;
layout.childControlWidth = false;
layout.childControlHeight = false;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = false;
CreateDragCard(hand.transform, "CardA");
var dragged = CreateDragCard(hand.transform, "CardB");
CreateDragCard(hand.transform, "CardC");
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(handRect);
var originalIndex = dragged.transform.GetSiblingIndex();
var originalPosition = dragged.GetComponent<RectTransform>().anchoredPosition;
var clicked = false;
dragged.GetComponent<Button>().onClick.AddListener(() => clicked = true);
var eventSystem = EventSystem.current;
var ownsEventSystem = eventSystem == null;
if (eventSystem == null)
eventSystem = new GameObject("DragTestEventSystem", typeof(EventSystem)).GetComponent<EventSystem>();
var pointer = new PointerEventData(eventSystem) { position = new Vector2(540, 360), delta = new Vector2(80, 30) };
var handler = dragged.GetComponent<ReplacedCardDragHandler>();
handler.OnBeginDrag(pointer);
handler.OnDrag(pointer);
Assert.That(dragged.transform.GetSiblingIndex(), Is.EqualTo(originalIndex));
Assert.That(dragged.GetComponent<RectTransform>().anchoredPosition, Is.EqualTo(originalPosition));
Assert.That(GameObject.Find("CardDragGhost"), Is.Not.Null);
handler.OnEndDrag(pointer);
Assert.That(dragged.transform.GetSiblingIndex(), Is.EqualTo(originalIndex));
Assert.That(dragged.GetComponent<RectTransform>().anchoredPosition, Is.EqualTo(originalPosition));
Assert.That(clicked, Is.True);
Object.Destroy(canvasObject);
if (ownsEventSystem) Object.Destroy(eventSystem.gameObject);
yield return null;
}
[UnityTest]
public IEnumerator DraggingConfiguredCard_ReportsDropWithoutInvokingClick()
{
var canvasObject = new GameObject("ConfiguredDragCanvas", typeof(RectTransform), typeof(Canvas));
canvasObject.GetComponent<Canvas>().renderMode = RenderMode.ScreenSpaceOverlay;
var dragged = CreateDragCard(canvasObject.transform, "ConfiguredCard");
var clicked = false;
var dropped = false;
var dropPosition = Vector2.zero;
dragged.GetComponent<Button>().onClick.AddListener(() => clicked = true);
dragged.GetComponent<ReplacedCardDragHandler>().Configure(position =>
{
dropped = true;
dropPosition = position;
});
var eventSystem = EventSystem.current;
var ownsEventSystem = eventSystem == null;
if (eventSystem == null)
eventSystem = new GameObject("ConfiguredDragEventSystem", typeof(EventSystem)).GetComponent<EventSystem>();
var start = new PointerEventData(eventSystem) { position = new Vector2(100, 100) };
var end = new PointerEventData(eventSystem) { position = new Vector2(240, 140), delta = new Vector2(140, 40) };
var handler = dragged.GetComponent<ReplacedCardDragHandler>();
handler.OnBeginDrag(start);
handler.OnDrag(end);
handler.OnEndDrag(end);
Assert.That(dropped, Is.True);
Assert.That(clicked, Is.False);
Assert.That(dropPosition, Is.EqualTo(end.position));
yield return null;
Assert.That(GameObject.Find("CardDragGhost"), Is.Null);
Object.Destroy(canvasObject);
if (ownsEventSystem) Object.Destroy(eventSystem.gameObject);
yield return null;
}
[UnityTest]
public IEnumerator BattleHand_DuplicateCopyCancelsAndDragChangesExecutionOrder()
{
SceneManager.LoadScene("ReplacedPerson");
yield return null;
var root = GameObject.Find("ReplacedPersonDemo");
Assert.That(root, Is.Not.Null);
var safe = root.transform.Find("Canvas/SafeAreaRoot")!;
safe.Find("Screens/TitleScreen/Actions/ContinueButton")!.GetComponent<Button>().onClick.Invoke();
safe.Find("Screens/ChapterScreen/Footer/StartBattleButton")!.GetComponent<Button>().onClick.Invoke();
var phaseMotion = safe.Find("Screens/BattleScreen")!.GetComponent<ReplacedBattlePhaseMotion>();
Assert.That(phaseMotion, Is.Not.Null);
Assert.That(phaseMotion!.IsVisible, Is.True);
Assert.That(phaseMotion.CurrentTitle, Is.EqualTo("先手骰"));
var match = ReplacedPersonGameService.Current!.Match!;
var player = match.State.Players[0];
player.NormalHand.Clear();
player.NormalHand.AddRange(new[] { "counter.instant", "counter.instant", "counter.stance", "attack.kill", "dodge.flash" });
player.EndHand.Clear();
player.EndHand.Add("end.inherit");
root.SendMessage("RefreshBattle", SendMessageOptions.DontRequireReceiver);
yield return null;
var selection = safe.Find("Screens/BattleScreen/PlayerArea/SelectionText")!.GetComponent<TMP_Text>();
Card(safe, 0).GetComponent<Button>().onClick.Invoke();
yield return null;
Assert.That(selection.text, Does.Contain("1. 一瞬千击"));
Assert.That(Card(safe, 0).Find("Label")!.GetComponent<TMP_Text>().text, Does.StartWith("[1] 一瞬千击"));
Assert.That(Card(safe, 1).Find("Label")!.GetComponent<TMP_Text>().text, Does.StartWith("一瞬千击"));
Card(safe, 0).GetComponent<Button>().onClick.Invoke();
yield return null;
Assert.That(selection.text, Does.Contain("普通牌执行:未选择"));
foreach (var handIndex in new[] { 0, 2, 3 })
{
Card(safe, handIndex).GetComponent<Button>().onClick.Invoke();
yield return null;
}
Assert.That(selection.text, Does.Contain("1. 一瞬千击 → 2. 架势 → 3. 杀"));
var source = Card(safe, 3);
var target = Card(safe, 0);
var sourceRect = (RectTransform)source;
var targetRect = (RectTransform)target;
var originalPosition = sourceRect.anchoredPosition;
var startPosition = RectTransformUtility.WorldToScreenPoint(null, sourceRect.TransformPoint(sourceRect.rect.center));
var targetCenter = RectTransformUtility.WorldToScreenPoint(null, targetRect.TransformPoint(targetRect.rect.center));
var endPosition = new Vector2(targetCenter.x - targetRect.rect.width * .25f, targetCenter.y);
var begin = new PointerEventData(EventSystem.current) { position = startPosition };
var end = new PointerEventData(EventSystem.current) { position = endPosition, delta = endPosition - startPosition };
var handler = source.GetComponent<ReplacedCardDragHandler>();
handler.OnBeginDrag(begin);
handler.OnDrag(end);
handler.OnEndDrag(end);
Assert.That(sourceRect.anchoredPosition, Is.EqualTo(originalPosition));
yield return null;
Assert.That(selection.text, Does.Contain("1. 杀 → 2. 一瞬千击 → 3. 架势"));
Assert.That(Card(safe, 3).Find("Label")!.GetComponent<TMP_Text>().text, Does.StartWith("[1] 杀"));
Assert.That(Card(safe, 0).Find("Label")!.GetComponent<TMP_Text>().text, Does.StartWith("[2] 一瞬千击"));
Assert.That(Card(safe, 2).Find("Label")!.GetComponent<TMP_Text>().text, Does.StartWith("[3] 架势"));
Assert.That(GameObject.Find("CardDragGhost"), Is.Null);
Assert.That(match.State.Players[0].NormalHand.Count(id => id == "counter.instant"), Is.EqualTo(2));
}
[UnityTest]
public IEnumerator SlotResolutionMotion_PresentsClashDiceAndFinalResult()
{
var slot = new GameObject("AnimatedSlot", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
var textObject = new GameObject("Text", typeof(RectTransform), typeof(CanvasRenderer), typeof(TextMeshProUGUI));
textObject.transform.SetParent(slot.transform, false);
var textRect = textObject.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
var motion = slot.AddComponent<ReplacedSlotResolutionMotion>();
motion.Play(new Core.ReplacedSlotResolution
{
SlotIndex = 0,
PlayerOneType = Core.ReplacedEffectType.Counter,
PlayerTwoType = Core.ReplacedEffectType.Attack,
PlayerOneRoll = 5,
PlayerTwoRoll = 2,
PlayerOneValue = 17,
PlayerTwoValue = 8,
DamageToPlayerTwo = 6,
Summary = "反击 6"
}, 0);
yield return new WaitForSecondsRealtime(.72f);
Assert.That(motion.IsPlaying, Is.True);
Assert.That(slot.transform.Find("PlayerToken")!.gameObject.activeSelf, Is.True);
Assert.That(textObject.GetComponent<TMP_Text>().text, Does.Contain("投掷中"));
Assert.That(slot.transform.Find("PlayerToken/Label")!.GetComponent<TMP_Text>().text, Does.Contain("D6"));
yield return new WaitForSecondsRealtime(1.9f);
Assert.That(motion.IsPlaying, Is.False);
Assert.That(textObject.GetComponent<TMP_Text>().text, Does.Contain("反击 6"));
Assert.That(slot.transform.Find("PlayerToken")!.gameObject.activeSelf, Is.False);
Object.Destroy(slot);
yield return null;
}
private static GameObject CreateDragCard(Transform parent, string name)
{
var card = new GameObject(name, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button),
typeof(LayoutElement), typeof(ReplacedCardDragHandler));
card.transform.SetParent(parent, false);
card.GetComponent<RectTransform>().sizeDelta = new Vector2(120, 140);
card.GetComponent<LayoutElement>().preferredWidth = 120;
return card;
}
private static Transform Card(Transform safeArea, int handIndex)
{
return safeArea.Find("Screens/BattleScreen/PlayerArea/NormalHand/Normal_" + handIndex)!;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 777e057666d478748851b2c92e16e85d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: