落地无头规则引擎与局内内容,便于命令行复现对战。

规则、卡包与测试放在同一套核心里,避免表现层自行结算。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-09 02:06:23 +08:00
co-authored by Cursor
parent ce2b61fc6a
commit ae13f1d06a
51 changed files with 3429 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
using CardGame.Core;
using Xunit;
namespace Game.Core.Tests;
public sealed class AiSearchTests
{
[Fact]
public void Prefers_face_damage_when_lethal_or_high_value()
{
var state = new GameState(9) { Catalog = Cards.Catalog };
MatchSetup.PlaceSystemField(state, 0, Cards.BaseField);
MatchSetup.PlaceSystemField(state, 1, Cards.BaseField);
state.Players[1].Life = 1;
var attacker = state.NewEntity(Cards.Wisp, 0, Zone.Board, Factions.Guard);
attacker.EnteredOnTurn = 0;
attacker.AttacksRemaining = 1;
state.Board.Add(attacker);
state.ActivePlayerIndex = 0;
state.TurnNumber = 2;
var legal = RulesEngine.GetLegalActions(state);
var (action, top) = TurnBeamSearch.ChooseWithTrace(state, legal);
Assert.IsType<AttackAction>(action);
Assert.Contains(top, t => t.Chosen && t.Label.Contains("敌方主战者", StringComparison.Ordinal));
}
[Fact]
public void PlayUntil_returns_step_trace()
{
var state = MatchSetup.CreateStartingBattle(11, Cards.Catalog, shuffle: false);
state = RulesEngine.Execute(state, new EndTurnAction(0));
var (_, trace) = TurnBeamSearch.PlayUntil(state, untilPlayerIndex: 0);
Assert.NotEmpty(trace.Steps);
Assert.All(trace.Steps, s => Assert.NotEmpty(s.TopCandidates));
}
[Fact]
public void ChooseWithTrace_works_when_all_scores_are_negative()
{
var state = MatchSetup.CreateStartingBattle(13, Cards.Catalog, shuffle: false);
state.Players[0].Life = 1;
state.Players[1].Life = 20;
var legal = RulesEngine.GetLegalActions(state);
var (action, top) = TurnBeamSearch.ChooseWithTrace(state, legal);
Assert.NotNull(action);
Assert.Contains(top, t => t.Chosen);
}
}
+30
View File
@@ -0,0 +1,30 @@
using CardGame.Core;
namespace Game.Core.Tests;
internal static class Cards
{
private static readonly Dictionary<string, CardDefinition> ById = CardJson
.LoadCatalog(Path.Combine(AppContext.BaseDirectory, "content", "cards"));
public static IReadOnlyDictionary<string, CardDefinition> Catalog => ById;
public static CardDefinition Get(string id) => ById[id];
public static CardDefinition Spark => ById["card.example.spark"];
public static CardDefinition Wisp => ById["card.example.wisp"];
public static CardDefinition Salve => ById["card.example.salve"];
public static CardDefinition Ward => ById["card.example.ward"];
public static CardDefinition Lantern => ById["card.example.lantern"];
public static CardDefinition Fuse => ById["card.example.fuse"];
public static CardDefinition BaseField => ById["card.system.base_field"];
public static CardDefinition Patrol => ById["card.guard.patrol"];
public static CardDefinition Captain => ById["card.guard.captain"];
public static CardDefinition Believer => ById["card.cult.believer"];
public static CardDefinition Camp => ById["card.cult.camp"];
public static CardDefinition Crossbow => ById["card.guard.crossbow"];
public static CardDefinition Conscript => ById["card.guard.conscript"];
public static List<CardDefinition> Repeat(CardDefinition card, int count) =>
Enumerable.Repeat(card, count).ToList();
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<RootNamespace>Game.Core.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Game.Core\Game.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\content\**\*" CopyToOutputDirectory="PreserveNewest" Link="content\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
</Project>
+57
View File
@@ -0,0 +1,57 @@
using CardGame.Core;
using Xunit;
namespace Game.Core.Tests;
public sealed class PackTests
{
private static List<PackDefinition> Catalog() =>
PackJson.LoadDirectory(Path.Combine(AppContext.BaseDirectory, "content", "packs"));
[Fact]
public void Base_pack_is_always_in_the_run_pool()
{
var state = MatchSetup.Create(
1,
Cards.Repeat(Cards.Wisp, 10),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
var unlocked = Packs.Unlocked(state, 0, Catalog());
Assert.Contains(unlocked, p => p.Id == "pack.example.base");
Assert.DoesNotContain(unlocked, p => p.Id == "pack.example.vitality");
}
[Fact]
public void Healing_unlocks_vitality_pack()
{
var state = MatchSetup.Create(
1,
Cards.Repeat(Cards.Salve, 10),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
state.Players[0].Life = 10;
state.Players[0].Energy = 1;
var salve = state.Players[0].Hand[0];
state = RulesEngine.Execute(state, new PlayCardAction(0, salve.Id));
Assert.Equal(2, state.Players[0].Run.HealReceived);
Assert.Contains(Packs.Unlocked(state, 0, Catalog()), p => p.Id == "pack.example.vitality");
}
[Fact]
public void Fuse_expiry_unlocks_aftermath_for_the_owner()
{
var state = MatchSetup.Create(
1,
Cards.Repeat(Cards.Fuse, 10),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
var fuse = state.Players[0].Hand[0];
state = RulesEngine.Execute(state, new PlayCardAction(0, fuse.Id));
state = RulesEngine.Execute(state, new EndTurnAction(0));
Assert.Equal(1, state.Players[0].Run.FriendlyDestroyed);
Assert.Contains(Packs.Unlocked(state, 0, Catalog()), p => p.Id == "pack.example.aftermath");
}
}
+54
View File
@@ -0,0 +1,54 @@
using CardGame.Core;
using Xunit;
namespace Game.Core.Tests;
public sealed class PhaseATests
{
[Fact]
public void Clone_is_independent()
{
var state = new GameState(seed: 1) { Catalog = Cards.Catalog };
state.Players[0].Life = 12;
var clone = RulesEngine.CloneState(state);
clone.Players[0].Life = 7;
Assert.Equal(12, state.Players[0].Life);
Assert.Equal(7, clone.Players[0].Life);
}
[Fact]
public void Rng_is_deterministic_for_same_seed()
{
var a = new GameRng(42);
var b = new GameRng(42);
Assert.Equal(a.NextUInt32(), b.NextUInt32());
Assert.Equal(a.NextInt(10), b.NextInt(10));
}
[Fact]
public void EndTurn_passes_priority_and_base_field_supplies()
{
var state = MatchSetup.Create(
7,
Cards.Repeat(Cards.Wisp, 10),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
Assert.Equal(1, state.Players[0].Energy);
state = RulesEngine.Execute(state, new EndTurnAction(0));
Assert.Equal(1, state.ActivePlayerIndex);
Assert.Equal(1, state.Players[1].Energy);
}
[Fact]
public void GetLegalActions_offers_end_turn()
{
var state = MatchSetup.Create(
1,
Cards.Repeat(Cards.Wisp, 10),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
Assert.Contains(RulesEngine.GetLegalActions(state), a => a is EndTurnAction);
}
}
+84
View File
@@ -0,0 +1,84 @@
using CardGame.Core;
using Xunit;
namespace Game.Core.Tests;
public sealed class PhaseBTests
{
private static GameState NewMatch(CardDefinition card, int count = 10) =>
MatchSetup.Create(11, Cards.Repeat(card, count), Cards.Repeat(card, count), Cards.Catalog, shuffle: false);
[Fact]
public void Opening_draw_and_base_supply()
{
var state = NewMatch(Cards.Wisp);
Assert.Equal(4, state.Players[0].Hand.Count);
Assert.Equal(3, state.Players[1].Hand.Count);
Assert.Equal(1, state.Players[0].Energy);
}
[Fact]
public void Play_creature_pays_current_cost()
{
var state = NewMatch(Cards.Wisp);
var wisp = state.Players[0].Hand[0];
state = RulesEngine.Execute(state, new PlayCardAction(0, wisp.Id));
Assert.Equal(0, state.Players[0].Energy);
Assert.Contains(state.Board, e => e.Id == wisp.Id && e.Zone == Zone.Board);
Assert.Equal(0, state.Find(wisp.Id)!.AttacksRemaining);
}
[Fact]
public void Spell_damages_and_goes_to_graveyard()
{
var state = NewMatch(Cards.Spark);
var spark = state.Players[0].Hand[0];
state = RulesEngine.Execute(state, new PlayCardAction(0, spark.Id, new PlayerTarget(1)));
Assert.Equal(19, state.Players[1].Life);
Assert.Contains(state.Players[0].Graveyard, e => e.Id == spark.Id);
}
[Fact]
public void Creature_attacks_after_summoning_sickness()
{
var state = NewMatch(Cards.Wisp, 20);
var wisp = state.Players[0].Hand[0];
state = RulesEngine.Execute(state, new PlayCardAction(0, wisp.Id));
Assert.DoesNotContain(RulesEngine.GetLegalActions(state), a => a is AttackAction);
state = RulesEngine.Execute(state, new EndTurnAction(0));
state = RulesEngine.Execute(state, new EndTurnAction(1));
var attacker = state.Board.Single(e => e.ControllerIndex == 0 && e.Definition.Id == Cards.Wisp.Id);
state = RulesEngine.Execute(state, new AttackAction(0, attacker.Id, new PlayerTarget(1)));
Assert.Equal(19, state.Players[1].Life);
}
[Fact]
public void Shared_board_caps_at_ten()
{
var state = NewMatch(Cards.Wisp, 30);
for (var turn = 0; turn < 8 && !state.IsOver; turn++)
{
while (true)
{
var play = RulesEngine.GetLegalActions(state).OfType<PlayCardAction>().FirstOrDefault();
if (play is null)
{
break;
}
state = RulesEngine.Execute(state, play);
}
if (state.IsOver)
{
break;
}
state = RulesEngine.Execute(state, new EndTurnAction(state.ActivePlayerIndex));
}
Assert.True(state.Board.Count(e => e.Definition.Type != CardType.FieldCard || !e.IsSystem) <= GameState.BoardCapacity);
}
}
+42
View File
@@ -0,0 +1,42 @@
using CardGame.Core;
using Xunit;
namespace Game.Core.Tests;
public sealed class PhaseCTests
{
[Fact]
public void Lantern_supplies_on_turn_start()
{
var state = MatchSetup.Create(
9,
Cards.Repeat(Cards.Lantern, 10),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
var lantern = state.Players[0].Hand[0];
Assert.Equal(1, state.Players[0].Energy);
state = RulesEngine.Execute(state, new PlayCardAction(0, lantern.Id));
Assert.Equal(0, state.Players[0].Energy);
state = RulesEngine.Execute(state, new EndTurnAction(0));
state = RulesEngine.Execute(state, new EndTurnAction(1));
// base field +1, lantern +1
Assert.Equal(2, state.Players[0].Energy);
}
[Fact]
public void Fuse_expires_and_damages()
{
var state = MatchSetup.Create(
4,
Cards.Repeat(Cards.Fuse, 10),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
var fuse = state.Players[0].Hand[0];
state = RulesEngine.Execute(state, new PlayCardAction(0, fuse.Id));
state = RulesEngine.Execute(state, new EndTurnAction(0));
Assert.DoesNotContain(state.Board, e => e.Definition.Id == Cards.Fuse.Id);
Assert.Equal(18, state.Players[1].Life);
}
}
+88
View File
@@ -0,0 +1,88 @@
using CardGame.Core;
using Xunit;
namespace Game.Core.Tests;
public sealed class RunTests
{
[Fact]
public void Start_builds_starting_package_with_holders()
{
var run = RunProgress.Start(42, Cards.Catalog);
Assert.Equal(16, run.Owned.Count);
Assert.Contains(run.Owned, c => c.DefinitionId == "card.guard.patrol" && c.HolderId == Factions.Guard);
Assert.Contains(run.Owned, c => c.DefinitionId == "card.cult.believer" && c.HolderId == Factions.Cult);
}
[Fact]
public void CreateFromRun_applies_owned_stat_overrides()
{
var run = RunProgress.Start(7, Cards.Catalog);
var patrol = run.Owned.First(c => c.DefinitionId == "card.guard.patrol");
patrol.Cost = 0;
patrol.Attack = 9;
var state = MatchSetup.CreateFromRun(run, Cards.Catalog, shuffle: false);
var inDeckOrHand = state.Players[0].Deck.Concat(state.Players[0].Hand)
.First(e => e.Definition.Id == "card.guard.patrol" && e.Attack == 9);
Assert.Equal(0, inDeckOrHand.Cost);
Assert.Equal(9, inDeckOrHand.Attack);
Assert.Equal(Factions.Guard, inDeckOrHand.HolderId);
Assert.Equal(run.Stats.HealReceived, state.Players[0].Run.HealReceived);
}
[Fact]
public void Reward_pool_includes_base_pack_cards()
{
var run = RunProgress.Start(1, Cards.Catalog);
var state = MatchSetup.CreateFromRun(run, Cards.Catalog, shuffle: false);
var packs = PackJson.LoadDirectory(Path.Combine(AppContext.BaseDirectory, "content", "packs"));
var pool = RunRewards.ActivePool(state, 0, packs);
Assert.Contains("card.example.spark", pool);
Assert.Contains("card.example.wisp", pool);
}
[Fact]
public void Adding_reward_with_holder_grows_owned_and_survives_next_fight_setup()
{
var run = RunProgress.Start(3, Cards.Catalog);
var before = run.Owned.Count;
RunRewards.AddCard(run, Cards.Spark, Factions.Guard);
Assert.Equal(before + 1, run.Owned.Count);
var upgraded = run.Owned[^1];
Assert.True(RunRewards.ApplyUpgrade(upgraded, Cards.Spark, SimpleUpgrade.ReduceCost));
Assert.Equal(Cards.Spark.Cost - 1, upgraded.Cost);
var state = MatchSetup.CreateFromRun(run, Cards.Catalog, shuffle: false);
var spark = state.Players[0].Deck.Concat(state.Players[0].Hand)
.First(e => e.Definition.Id == Cards.Spark.Id);
Assert.Equal(Cards.Spark.Cost - 1, spark.Cost);
Assert.Equal(Factions.Guard, spark.HolderId);
}
[Fact]
public void SyncStatsFrom_carries_pack_progress_into_next_fight()
{
var run = RunProgress.Start(9, Cards.Catalog);
var state = MatchSetup.CreateFromRun(run, Cards.Catalog, shuffle: false);
state.Players[0].Run.HealReceived = 4;
state.Players[0].Run.AttacksDeclared = 2;
run.SyncStatsFrom(state, 0);
var next = MatchSetup.CreateFromRun(run, Cards.Catalog, shuffle: false);
Assert.Equal(4, next.Players[0].Run.HealReceived);
Assert.Equal(2, next.Players[0].Run.AttacksDeclared);
}
[Fact]
public void RollOffer_is_deterministic_for_same_rng_stream()
{
var pool = new[] { "a", "b", "c", "d" };
var a = RunRewards.RollOffer(new GameRng(99), pool, 3);
var b = RunRewards.RollOffer(new GameRng(99), pool, 3);
Assert.Equal(a, b);
Assert.Equal(3, a.Count);
Assert.Equal(a.Distinct().Count(), a.Count);
}
}
@@ -0,0 +1,153 @@
using CardGame.Core;
using Xunit;
namespace Game.Core.Tests;
public sealed class StartingCombatTests
{
[Fact]
public void Starting_package_is_sixteen_cards()
{
var deck = StartingPackage.BuildSharedDeck(Cards.Catalog);
Assert.Equal(16, deck.Count);
}
[Fact]
public void Patrol_summons_militia_on_play()
{
var state = MatchSetup.Create(
1,
Cards.Repeat(Cards.Patrol, 10),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
var patrol = state.Players[0].Hand[0];
state = RulesEngine.Execute(state, new PlayCardAction(0, patrol.Id));
Assert.Equal(2, state.Board.Count(e => e.ControllerIndex == 0 && e.Definition.Type == CardType.Creature));
Assert.Contains(state.Board, e => e.Definition.Id == "card.guard.militia");
}
[Fact]
public void Conscript_creates_1_cost_crossbow_that_keeps_cost_in_zones()
{
var state = MatchSetup.Create(
1,
new List<CardDefinition> { Cards.Conscript }.Concat(Cards.Repeat(Cards.Wisp, 15)).ToList(),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
state.Players[0].Energy = 5;
var spell = state.Players[0].Hand.First(c => c.Definition.Id == Cards.Conscript.Id);
state = RulesEngine.Execute(state, new PlayCardAction(0, spell.Id));
var bow = state.Players[0].Hand.Single(c => c.Definition.Id == Cards.Crossbow.Id);
Assert.Equal(1, bow.Cost);
Assert.Equal(3, bow.Definition.Cost);
state = RulesEngine.Execute(state, new PlayCardAction(0, bow.Id));
var onBoard = state.Board.Single(e => e.Definition.Id == Cards.Crossbow.Id);
Assert.Equal(1, onBoard.Cost);
}
[Fact]
public void Captain_aura_buffs_current_1_cost_creatures()
{
var state = MatchSetup.Create(
1,
new List<CardDefinition> { Cards.Captain, Cards.Patrol }.Concat(Cards.Repeat(Cards.Wisp, 14)).ToList(),
Cards.Repeat(Cards.Wisp, 10),
Cards.Catalog,
shuffle: false);
state.Players[0].Energy = 10;
var captain = state.Players[0].Hand.First(c => c.Definition.Id == Cards.Captain.Id);
var patrol = state.Players[0].Hand.First(c => c.Definition.Id == Cards.Patrol.Id);
state = RulesEngine.Execute(state, new PlayCardAction(0, captain.Id));
state = RulesEngine.Execute(state, new PlayCardAction(0, patrol.Id));
var militia = state.Board.Single(e => e.Definition.Id == "card.guard.militia");
Assert.Equal(2, state.EffectiveAttack(militia));
Assert.Equal(2, state.EffectiveAttack(state.Find(patrol.Id)!));
}
[Fact]
public void Believer_value_chain_attacks_then_transfers()
{
var state = new GameState(3) { Catalog = Cards.Catalog };
MatchSetup.PlaceSystemField(state, 0, Cards.BaseField);
MatchSetup.PlaceSystemField(state, 1, Cards.BaseField);
var a = state.NewEntity(Cards.Believer, 0, Zone.Board, Factions.Cult);
var b = state.NewEntity(Cards.Believer, 0, Zone.Board, Factions.Cult);
a.EnteredOnTurn = 0;
b.EnteredOnTurn = 0;
a.AttacksRemaining = 1;
b.AttacksRemaining = 1;
a.ActivatesRemaining = 1;
b.ActivatesRemaining = 1;
state.Board.Add(a);
state.Board.Add(b);
var civilian = state.NewEntity(Cards.Get("card.cult.civilian"), 0, Zone.Board, Factions.Cult);
civilian.EnteredOnTurn = 0;
civilian.AttacksRemaining = 1;
state.Board.Add(civilian);
state.ActivePlayerIndex = 0;
state.TurnNumber = 2;
state = RulesEngine.Execute(state, new AttackAction(0, a.Id, new PlayerTarget(1)));
Assert.Equal(19, state.Players[1].Life);
state = RulesEngine.Execute(state, new ActivateAction(0, a.Id, new EntityTarget(b.Id)));
Assert.DoesNotContain(state.Board, e => e.Id == a.Id);
Assert.Equal(2, state.Find(b.Id)!.Attack);
Assert.Equal(4, state.Find(b.Id)!.Health);
state = RulesEngine.Execute(state, new AttackAction(0, b.Id, new PlayerTarget(1)));
Assert.Equal(17, state.Players[1].Life);
state = RulesEngine.Execute(state, new ActivateAction(0, b.Id, new EntityTarget(civilian.Id)));
var civ = state.Find(civilian.Id)!;
Assert.Equal(2, civ.Attack);
Assert.Equal(5, civ.Health);
Assert.Equal(1, civ.AttacksRemaining);
state = RulesEngine.Execute(state, new AttackAction(0, civilian.Id, new PlayerTarget(1)));
Assert.Equal(15, state.Players[1].Life);
}
[Fact]
public void Can_activate_on_enter_turn_but_not_attack()
{
var state = MatchSetup.Create(
1,
Cards.Repeat(Cards.Believer, 10),
Cards.Repeat(Cards.Believer, 10),
Cards.Catalog,
shuffle: false);
var first = state.Players[0].Hand[0];
state = RulesEngine.Execute(state, new PlayCardAction(0, first.Id));
var played = state.Find(first.Id)!;
Assert.Equal(0, played.AttacksRemaining);
Assert.Equal(1, played.ActivatesRemaining);
Assert.DoesNotContain(RulesEngine.GetLegalActions(state), a => a is AttackAction);
Assert.DoesNotContain(
RulesEngine.GetLegalActions(state),
a => a is ActivateAction act && act.SourceId == first.Id);
// only one believer on board — activate needs another friendly
var second = state.Players[0].Hand[0];
state.Players[0].Energy = 1;
state = RulesEngine.Execute(state, new PlayCardAction(0, second.Id));
Assert.Contains(
RulesEngine.GetLegalActions(state),
a => a is ActivateAction act && act.SourceId == first.Id);
}
[Fact]
public void Starting_battle_boots()
{
var state = MatchSetup.CreateStartingBattle(20261006, Cards.Catalog, shuffle: false);
Assert.Equal(16 - GameState.OpeningHand - 1, state.Players[0].Deck.Count);
Assert.Equal(GameState.OpeningHand + 1, state.Players[0].Hand.Count);
Assert.Equal(1, state.Players[0].Energy);
Assert.Contains(state.Board, e => e.IsSystem);
Assert.NotEmpty(RulesEngine.GetLegalActions(state));
}
}