落地无头规则引擎与局内内容,便于命令行复现对战。
规则、卡包与测试放在同一套核心里,避免表现层自行结算。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "card.cult.believer",
|
||||
"name": "Believer",
|
||||
"type": "Creature",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 2,
|
||||
"tags": ["creature", "death"],
|
||||
"effects": [],
|
||||
"activate": [{ "op": "TransferStatsThenDie" }],
|
||||
"activateNeedsFriendly": true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "card.cult.camp",
|
||||
"name": "Labor Camp",
|
||||
"type": "Field",
|
||||
"cost": 2,
|
||||
"countdown": 4,
|
||||
"tags": ["field", "supply", "countdown", "enter"],
|
||||
"effects": [],
|
||||
"triggers": [
|
||||
{ "when": "TurnStart", "effects": [{ "op": "GenerateEnergy", "amount": 1 }] },
|
||||
{ "when": "OpponentTurnEnd", "effects": [{ "op": "Summon", "cardId": "card.cult.civilian" }] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "card.cult.civilian",
|
||||
"name": "Civilian",
|
||||
"type": "Creature",
|
||||
"cost": 0,
|
||||
"attack": 0,
|
||||
"health": 1,
|
||||
"tags": ["creature", "enter", "death"],
|
||||
"effects": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "card.cult.mine",
|
||||
"name": "Labor Mine",
|
||||
"type": "Field",
|
||||
"cost": 1,
|
||||
"countdown": 3,
|
||||
"tags": ["field", "supply", "countdown", "enter"],
|
||||
"effects": [],
|
||||
"triggers": [
|
||||
{
|
||||
"when": "OnEnterBoard",
|
||||
"effects": [
|
||||
{ "op": "GenerateEnergy", "amount": 1 },
|
||||
{ "op": "ModifyCountdown", "amount": -1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "card.cult.vilefire",
|
||||
"name": "Vile Fire",
|
||||
"type": "Spell",
|
||||
"cost": 2,
|
||||
"tags": ["damage", "death"],
|
||||
"effects": [{ "customEffect": "vile_fire" }],
|
||||
"playNeedsFriendly": true,
|
||||
"playNeedsEnemy": true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "card.example.fuse",
|
||||
"name": "Fuse",
|
||||
"type": "Field",
|
||||
"cost": 1,
|
||||
"countdown": 1,
|
||||
"tags": ["field", "countdown"],
|
||||
"effects": [],
|
||||
"triggers": [
|
||||
{
|
||||
"when": "OnDestroyed",
|
||||
"effects": [{ "op": "Damage", "amount": 2, "target": "OpponentPlayer" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "card.example.lantern",
|
||||
"name": "Lantern",
|
||||
"type": "Field",
|
||||
"cost": 1,
|
||||
"countdown": 3,
|
||||
"tags": ["field", "supply", "countdown"],
|
||||
"effects": [],
|
||||
"triggers": [
|
||||
{
|
||||
"when": "TurnStart",
|
||||
"effects": [{ "op": "GenerateEnergy", "amount": 1 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "card.example.salve",
|
||||
"name": "Salve",
|
||||
"type": "Spell",
|
||||
"cost": 1,
|
||||
"tags": ["heal"],
|
||||
"effects": [
|
||||
{ "op": "Heal", "amount": 2, "target": "SelfPlayer" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "card.example.spark",
|
||||
"name": "Spark",
|
||||
"type": "Spell",
|
||||
"cost": 1,
|
||||
"tags": ["damage"],
|
||||
"effects": [
|
||||
{ "op": "Damage", "amount": 1, "target": "ChosenEnemy" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "card.example.ward",
|
||||
"name": "Ward",
|
||||
"type": "Field",
|
||||
"cost": 1,
|
||||
"countdown": 0,
|
||||
"tags": ["field"],
|
||||
"effects": []
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "card.example.wisp",
|
||||
"name": "Wisp",
|
||||
"type": "Creature",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 1,
|
||||
"tags": ["creature"],
|
||||
"effects": []
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "card.guard.captain",
|
||||
"name": "Captain",
|
||||
"type": "Creature",
|
||||
"cost": 3,
|
||||
"attack": 3,
|
||||
"health": 4,
|
||||
"tags": ["creature"],
|
||||
"effects": []
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "card.guard.conscript",
|
||||
"name": "Conscript Crossbow",
|
||||
"type": "Spell",
|
||||
"cost": 2,
|
||||
"tags": ["creature"],
|
||||
"effects": [{ "op": "CreateInHand", "cardId": "card.guard.crossbow", "amount": 1 }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "card.guard.crossbow",
|
||||
"name": "Crossbowman",
|
||||
"type": "Creature",
|
||||
"cost": 3,
|
||||
"attack": 2,
|
||||
"health": 4,
|
||||
"tags": ["creature"],
|
||||
"effects": [],
|
||||
"activate": [{ "op": "Damage", "amount": -1, "target": "ChosenEnemy" }],
|
||||
"activateNeedsEnemy": true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "card.guard.farm",
|
||||
"name": "Farmstead",
|
||||
"type": "Field",
|
||||
"cost": 2,
|
||||
"countdown": 0,
|
||||
"tags": ["field", "supply", "draw"],
|
||||
"effects": [],
|
||||
"triggers": [
|
||||
{ "when": "TurnStart", "effects": [{ "op": "GenerateEnergy", "amount": 1 }] },
|
||||
{ "when": "OnSupply", "effects": [{ "op": "ShuffleIntoDeck", "cardId": "card.guard.militia" }] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "card.guard.militia",
|
||||
"name": "Militia",
|
||||
"type": "Creature",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 1,
|
||||
"tags": ["creature", "enter", "death"],
|
||||
"effects": []
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "card.guard.orders",
|
||||
"name": "Orders",
|
||||
"type": "Spell",
|
||||
"cost": 2,
|
||||
"tags": ["creature"],
|
||||
"effects": [{ "op": "BuffAllFriendly", "amount": 1, "amount2": 1 }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "card.guard.patrol",
|
||||
"name": "Patrol",
|
||||
"type": "Creature",
|
||||
"cost": 1,
|
||||
"attack": 1,
|
||||
"health": 2,
|
||||
"tags": ["creature", "enter"],
|
||||
"effects": [{ "op": "Summon", "cardId": "card.guard.militia" }]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "card.guard.ration",
|
||||
"name": "Ration Order",
|
||||
"type": "Spell",
|
||||
"cost": 3,
|
||||
"tags": ["draw"],
|
||||
"effects": [{ "op": "Draw", "amount": 2 }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "card.system.base_field",
|
||||
"name": "Base Camp",
|
||||
"type": "Field",
|
||||
"cost": 0,
|
||||
"countdown": 3,
|
||||
"tags": ["field", "supply", "countdown"],
|
||||
"effects": [],
|
||||
"triggers": [
|
||||
{ "when": "TurnStart", "effects": [{ "op": "GenerateEnergy", "amount": 1 }] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "char.example.ash",
|
||||
"name": "Ash",
|
||||
"coreActions": ["Damage", "Heal"],
|
||||
"bridge": "Damage+Heal",
|
||||
"skill": {
|
||||
"id": "skill.ash.ember",
|
||||
"summary": "Healing you receive also nudges damage spells. Combat effect not wired yet."
|
||||
},
|
||||
"startingDeck": [
|
||||
"card.example.spark",
|
||||
"card.example.spark",
|
||||
"card.example.salve",
|
||||
"card.example.wisp"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "pack.example.aftermath",
|
||||
"name": "Aftermath",
|
||||
"base": false,
|
||||
"coreActions": ["Destroy", "Countdown"],
|
||||
"cards": ["card.example.fuse"],
|
||||
"allOf": [
|
||||
{ "kind": "DeckTagAtLeast", "tag": "countdown", "amount": 1 },
|
||||
{ "kind": "FriendlyDestroyedAtLeast", "amount": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "pack.example.base",
|
||||
"name": "Common Stock",
|
||||
"base": true,
|
||||
"coreActions": [],
|
||||
"cards": ["card.example.spark", "card.example.wisp"],
|
||||
"allOf": []
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "pack.example.vitality",
|
||||
"name": "Vitality",
|
||||
"base": false,
|
||||
"coreActions": ["Heal"],
|
||||
"cards": ["card.example.salve"],
|
||||
"allOf": [
|
||||
{ "kind": "HealReceivedAtLeast", "amount": 2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://cardgame.local/schemas/card.json",
|
||||
"title": "Card",
|
||||
"type": "object",
|
||||
"required": ["id", "name", "type", "cost", "effects"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"type": { "enum": ["Creature", "Spell", "Field"] },
|
||||
"cost": { "type": "integer", "minimum": 0 },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }
|
||||
},
|
||||
"attack": { "type": "integer" },
|
||||
"health": { "type": "integer" },
|
||||
"countdown": { "type": "integer", "minimum": 0 },
|
||||
"effects": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/effect" }
|
||||
},
|
||||
"triggers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["when", "effects"],
|
||||
"properties": {
|
||||
"when": { "enum": ["TurnStart", "OnDestroyed"] },
|
||||
"effects": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/effect" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"effect": {
|
||||
"type": "object",
|
||||
"required": ["op"],
|
||||
"properties": {
|
||||
"op": { "type": "string" },
|
||||
"amount": { "type": "integer" },
|
||||
"target": { "type": "string" },
|
||||
"customEffect": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
namespace CardGame.Core;
|
||||
|
||||
internal static class Apply
|
||||
{
|
||||
public static void Effects(GameState state, Entity source, TargetRef? chosen, TargetRef? chosen2 = null)
|
||||
{
|
||||
foreach (var effect in source.Definition.Effects)
|
||||
{
|
||||
One(state, source, effect, chosen, chosen2);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ActivateEffects(GameState state, Entity source, TargetRef? chosen, TargetRef? chosen2 = null)
|
||||
{
|
||||
foreach (var effect in source.Definition.ActivateList)
|
||||
{
|
||||
One(state, source, effect, chosen, chosen2);
|
||||
}
|
||||
}
|
||||
|
||||
public static void One(
|
||||
GameState state,
|
||||
Entity source,
|
||||
Effect effect,
|
||||
TargetRef? chosen,
|
||||
TargetRef? chosen2 = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(effect.CustomHandler))
|
||||
{
|
||||
CustomEffects.Run(state, source, effect.CustomHandler!, chosen, chosen2);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (effect.Op)
|
||||
{
|
||||
case EffectOp.Damage:
|
||||
{
|
||||
var amount = effect.Amount < 0 ? state.EffectiveAttack(source) : effect.Amount;
|
||||
Damage(state, ResolveTarget(state, source, effect.Target, chosen, chosen2), amount);
|
||||
break;
|
||||
}
|
||||
case EffectOp.Heal:
|
||||
Heal(state, ResolveTarget(state, source, effect.Target, chosen, chosen2), effect.Amount);
|
||||
break;
|
||||
case EffectOp.Draw:
|
||||
MatchSetup.DrawCards(state, source.ControllerIndex, Math.Max(1, effect.Amount));
|
||||
break;
|
||||
case EffectOp.Destroy:
|
||||
{
|
||||
if (ResolveTarget(state, source, effect.Target, chosen, chosen2) is EntityTarget entityTarget)
|
||||
{
|
||||
var victim = state.Find(entityTarget.Id);
|
||||
if (victim is not null && victim.Zone == Zone.Board)
|
||||
{
|
||||
Destroy(state, victim);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case EffectOp.GenerateEnergy:
|
||||
GenerateEnergy(state, source, effect.Amount);
|
||||
break;
|
||||
case EffectOp.ModifyCountdown:
|
||||
ChangeCountdown(state, source, effect.Amount);
|
||||
break;
|
||||
case EffectOp.Summon:
|
||||
Summon(state, source, effect.CardId ?? throw new InvalidOperationException("Summon needs CardId."));
|
||||
break;
|
||||
case EffectOp.BuffAllFriendly:
|
||||
foreach (var unit in state.Board
|
||||
.Where(e => e.ControllerIndex == source.ControllerIndex && e.Definition.Type == CardType.Creature)
|
||||
.ToList())
|
||||
{
|
||||
unit.Attack += effect.Amount;
|
||||
unit.Health += effect.Amount2 != 0 ? effect.Amount2 : effect.Amount;
|
||||
}
|
||||
|
||||
break;
|
||||
case EffectOp.SetCost:
|
||||
source.Cost = effect.Amount;
|
||||
break;
|
||||
case EffectOp.CreateInHand:
|
||||
CreateInHand(state, source, effect.CardId ?? throw new InvalidOperationException("CreateInHand needs CardId."), effect.Amount);
|
||||
break;
|
||||
case EffectOp.ShuffleIntoDeck:
|
||||
ShuffleCopyIntoDeck(state, source, effect.CardId ?? throw new InvalidOperationException("ShuffleIntoDeck needs CardId."));
|
||||
break;
|
||||
case EffectOp.TransferStatsThenDie:
|
||||
TransferStatsThenDie(state, source, chosen);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Effect {effect.Op} is not implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Triggers(GameState state, Entity source, TriggerWhen when, TargetRef? chosen = null)
|
||||
{
|
||||
foreach (var trigger in source.Definition.TriggerList.Where(t => t.When == when))
|
||||
{
|
||||
foreach (var effect in trigger.Effects)
|
||||
{
|
||||
One(state, source, effect, chosen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void FireBoardTriggers(GameState state, int controllerIndex, TriggerWhen when)
|
||||
{
|
||||
foreach (var entity in state.Board.Where(e => e.ControllerIndex == controllerIndex).ToList())
|
||||
{
|
||||
Triggers(state, entity, when);
|
||||
}
|
||||
}
|
||||
|
||||
public static void FireOnEnterListeners(GameState state, Entity entered)
|
||||
{
|
||||
if (entered.Definition.Type != CardType.Creature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var listener in state.Board
|
||||
.Where(e => e.ControllerIndex == entered.ControllerIndex && e.Id != entered.Id)
|
||||
.ToList())
|
||||
{
|
||||
Triggers(state, listener, TriggerWhen.OnEnterBoard);
|
||||
}
|
||||
}
|
||||
|
||||
public static void TickCountdowns(GameState state, int playerIndex)
|
||||
{
|
||||
foreach (var entity in state.Board.Where(e => e.ControllerIndex == playerIndex && e.Countdown > 0).ToList())
|
||||
{
|
||||
ChangeCountdown(state, entity, -1);
|
||||
}
|
||||
}
|
||||
|
||||
public static void GenerateEnergy(GameState state, Entity source, int amount)
|
||||
{
|
||||
var owner = state.Players[source.ControllerIndex];
|
||||
owner.Energy = Math.Min(GameState.EnergyCap, owner.Energy + amount);
|
||||
state.EventLog.Add(new SupplyGeneratedEvent(owner.Index, source.Id, amount));
|
||||
state.EventLog.Add(new EnergySetEvent(owner.Index, owner.Energy));
|
||||
Triggers(state, source, TriggerWhen.OnSupply);
|
||||
}
|
||||
|
||||
public static void ChangeCountdown(GameState state, Entity entity, int delta)
|
||||
{
|
||||
if (entity.Zone != Zone.Board)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entity.Countdown = Math.Max(0, entity.Countdown + delta);
|
||||
state.EventLog.Add(new CountdownChangedEvent(entity.Id, entity.Countdown));
|
||||
if (entity.Countdown <= 0 && (entity.Definition.Countdown > 0 || entity.IsSystem))
|
||||
{
|
||||
Destroy(state, entity);
|
||||
}
|
||||
}
|
||||
|
||||
public static Entity? Summon(GameState state, Entity source, string cardId)
|
||||
{
|
||||
if (state.Board.Count >= GameState.BoardCapacity)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!state.Catalog.TryGetValue(cardId, out var def))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown card '{cardId}'.");
|
||||
}
|
||||
|
||||
var token = state.NewEntity(def, source.ControllerIndex, Zone.Board, source.HolderId);
|
||||
EnterBoard(state, token, grantActivate: true, grantAttack: false);
|
||||
return token;
|
||||
}
|
||||
|
||||
public static void CreateInHand(GameState state, Entity source, string cardId, int setCost)
|
||||
{
|
||||
if (!state.Catalog.TryGetValue(cardId, out var def))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown card '{cardId}'.");
|
||||
}
|
||||
|
||||
var card = state.NewEntity(def, source.ControllerIndex, Zone.Hand, source.HolderId);
|
||||
if (setCost >= 0)
|
||||
{
|
||||
card.Cost = setCost;
|
||||
}
|
||||
|
||||
state.Players[source.ControllerIndex].Hand.Add(card);
|
||||
}
|
||||
|
||||
public static void ShuffleCopyIntoDeck(GameState state, Entity source, string cardId)
|
||||
{
|
||||
if (!state.Catalog.TryGetValue(cardId, out var def))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown card '{cardId}'.");
|
||||
}
|
||||
|
||||
var player = state.Players[source.ControllerIndex];
|
||||
var card = state.NewEntity(def, source.ControllerIndex, Zone.Deck, source.HolderId);
|
||||
var index = player.Deck.Count == 0 ? 0 : state.Rng.NextInt(player.Deck.Count + 1);
|
||||
player.Deck.Insert(index, card);
|
||||
}
|
||||
|
||||
public static void EnterBoard(GameState state, Entity entity, bool grantActivate, bool grantAttack)
|
||||
{
|
||||
entity.Zone = Zone.Board;
|
||||
entity.EnteredOnTurn = state.TurnNumber;
|
||||
entity.AttacksRemaining = grantAttack ? 1 : 0;
|
||||
entity.ActivatesRemaining = grantActivate && entity.Definition.HasActivate ? 1 : 0;
|
||||
if (!state.Board.Contains(entity))
|
||||
{
|
||||
state.Board.Add(entity);
|
||||
}
|
||||
|
||||
state.EventLog.Add(new EnteredBoardEvent(entity.ControllerIndex, entity.Id, entity.Definition.Id));
|
||||
FireOnEnterListeners(state, entity);
|
||||
}
|
||||
|
||||
public static void TransferStatsThenDie(GameState state, Entity source, TargetRef? chosen)
|
||||
{
|
||||
if (chosen is not EntityTarget entityTarget)
|
||||
{
|
||||
throw new InvalidOperationException("TransferStatsThenDie needs a friendly target.");
|
||||
}
|
||||
|
||||
var target = state.Find(entityTarget.Id)
|
||||
?? throw new InvalidOperationException("Missing transfer target.");
|
||||
if (target.Zone != Zone.Board || target.ControllerIndex != source.ControllerIndex || target.Id == source.Id)
|
||||
{
|
||||
throw new InvalidOperationException("Illegal transfer target.");
|
||||
}
|
||||
|
||||
var atk = state.EffectiveAttack(source);
|
||||
var hp = source.Health;
|
||||
target.Attack += atk;
|
||||
target.Health += hp;
|
||||
Destroy(state, source);
|
||||
}
|
||||
|
||||
public static TargetRef ResolveTarget(
|
||||
GameState state,
|
||||
Entity source,
|
||||
EffectTarget kind,
|
||||
TargetRef? chosen,
|
||||
TargetRef? chosen2 = null)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
EffectTarget.None => new PlayerTarget(source.ControllerIndex),
|
||||
EffectTarget.SelfPlayer => new PlayerTarget(source.ControllerIndex),
|
||||
EffectTarget.OpponentPlayer => new PlayerTarget(state.OpponentIndex(source.ControllerIndex)),
|
||||
EffectTarget.Self => new EntityTarget(source.Id),
|
||||
EffectTarget.ChosenEnemy => chosen ?? chosen2 ?? throw new InvalidOperationException("Missing enemy target."),
|
||||
EffectTarget.ChosenFriendly => chosen ?? throw new InvalidOperationException("Missing friendly target."),
|
||||
EffectTarget.AllFriendlyCreatures => new PlayerTarget(source.ControllerIndex),
|
||||
_ => throw new InvalidOperationException($"Unsupported target {kind}.")
|
||||
};
|
||||
}
|
||||
|
||||
public static void Damage(GameState state, TargetRef target, int amount)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case PlayerTarget player:
|
||||
DamagePlayer(state, player.PlayerIndex, amount);
|
||||
break;
|
||||
case EntityTarget entityTarget:
|
||||
{
|
||||
var victim = state.Find(entityTarget.Id);
|
||||
if (victim is null || victim.Zone != Zone.Board)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var before = victim.Health;
|
||||
victim.Health = Math.Max(0, victim.Health - amount);
|
||||
var applied = before - victim.Health;
|
||||
state.EventLog.Add(new DamageEvent(victim.ControllerIndex, victim.Id, amount, applied, victim.Health));
|
||||
if (victim.Health <= 0)
|
||||
{
|
||||
Destroy(state, victim);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void DamagePlayer(GameState state, int playerIndex, int amount)
|
||||
{
|
||||
var player = state.Players[playerIndex];
|
||||
var before = player.Life;
|
||||
player.Life = Math.Max(0, player.Life - amount);
|
||||
var applied = before - player.Life;
|
||||
state.EventLog.Add(new DamageEvent(playerIndex, null, amount, applied, player.Life));
|
||||
MatchSetup.CheckWinner(state);
|
||||
}
|
||||
|
||||
public static void Heal(GameState state, TargetRef target, int amount)
|
||||
{
|
||||
if (target is not PlayerTarget playerTarget)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var player = state.Players[playerTarget.PlayerIndex];
|
||||
var before = player.Life;
|
||||
player.Life = Math.Min(player.MaxLife, player.Life + amount);
|
||||
var applied = player.Life - before;
|
||||
player.Run.HealReceived += applied;
|
||||
state.EventLog.Add(new HealEvent(player.Index, null, amount, applied, player.Life));
|
||||
}
|
||||
|
||||
public static void Destroy(GameState state, Entity entity)
|
||||
{
|
||||
if (entity.Zone != Zone.Board)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var owner = state.Players[entity.ControllerIndex];
|
||||
owner.Run.FriendlyDestroyed++;
|
||||
var killer = state.Players[state.OpponentIndex(entity.ControllerIndex)];
|
||||
foreach (var tag in entity.Definition.Tags)
|
||||
{
|
||||
killer.Run.KillsByTag[tag] = killer.Run.Kills(tag) + 1;
|
||||
}
|
||||
|
||||
state.Board.RemoveAll(e => e.Id == entity.Id);
|
||||
entity.Zone = Zone.Graveyard;
|
||||
entity.AttacksRemaining = 0;
|
||||
entity.ActivatesRemaining = 0;
|
||||
state.Players[entity.ControllerIndex].Graveyard.Add(entity);
|
||||
state.EventLog.Add(new DestroyedEvent(entity.ControllerIndex, entity.Id, entity.Definition.Id));
|
||||
Triggers(state, entity, TriggerWhen.OnDestroyed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CardGame.Core;
|
||||
|
||||
public static class CardJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static CardDefinition Parse(string json)
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<CardDto>(json, Options)
|
||||
?? throw new InvalidOperationException("Card JSON was empty.");
|
||||
return dto.ToDefinition();
|
||||
}
|
||||
|
||||
public static List<CardDefinition> LoadDirectory(string directory)
|
||||
{
|
||||
var cards = new List<CardDefinition>();
|
||||
foreach (var path in Directory.GetFiles(directory, "*.json"))
|
||||
{
|
||||
cards.Add(Parse(File.ReadAllText(path)));
|
||||
}
|
||||
|
||||
return cards.OrderBy(c => c.Id, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
public static Dictionary<string, CardDefinition> LoadCatalog(string directory) =>
|
||||
LoadDirectory(directory).ToDictionary(c => c.Id, StringComparer.Ordinal);
|
||||
|
||||
private sealed class CardDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public int Cost { get; set; }
|
||||
public string[]? Tags { get; set; }
|
||||
public int Attack { get; set; }
|
||||
public int Health { get; set; }
|
||||
public int Countdown { get; set; }
|
||||
public EffectDto[]? Effects { get; set; }
|
||||
public TriggerDto[]? Triggers { get; set; }
|
||||
public EffectDto[]? Activate { get; set; }
|
||||
public bool ActivateNeedsFriendly { get; set; }
|
||||
public bool ActivateNeedsEnemy { get; set; }
|
||||
public bool PlayNeedsFriendly { get; set; }
|
||||
public bool PlayNeedsEnemy { get; set; }
|
||||
public bool PlayNeedsEnemyPlayerOrCreature { get; set; }
|
||||
|
||||
public CardDefinition ToDefinition()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Id) || string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
throw new InvalidOperationException("Card id and name are required.");
|
||||
}
|
||||
|
||||
var type = Type switch
|
||||
{
|
||||
"Creature" => CardType.Creature,
|
||||
"Spell" => CardType.Spell,
|
||||
"Field" => CardType.FieldCard,
|
||||
"FieldCard" => CardType.FieldCard,
|
||||
_ => throw new InvalidOperationException($"Unknown card type '{Type}' on {Id}.")
|
||||
};
|
||||
|
||||
var tags = (Tags ?? Array.Empty<string>()).ToImmutableArray();
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(tag, "^[a-z0-9]+(-[a-z0-9]+)*$"))
|
||||
{
|
||||
throw new InvalidOperationException($"Tag '{tag}' on {Id} must be kebab-case.");
|
||||
}
|
||||
}
|
||||
|
||||
return new CardDefinition(
|
||||
Id,
|
||||
Name,
|
||||
type,
|
||||
Cost,
|
||||
(Effects ?? Array.Empty<EffectDto>()).Select(e => e.ToEffect()).ToImmutableArray(),
|
||||
tags,
|
||||
Attack,
|
||||
Health,
|
||||
Countdown,
|
||||
(Triggers ?? Array.Empty<TriggerDto>()).Select(t => t.ToTrigger()).ToImmutableArray(),
|
||||
(Activate ?? Array.Empty<EffectDto>()).Select(e => e.ToEffect()).ToImmutableArray(),
|
||||
ActivateNeedsFriendly,
|
||||
ActivateNeedsEnemy,
|
||||
PlayNeedsFriendly,
|
||||
PlayNeedsEnemy,
|
||||
PlayNeedsEnemyPlayerOrCreature);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TriggerDto
|
||||
{
|
||||
public string When { get; set; } = "";
|
||||
public EffectDto[]? Effects { get; set; }
|
||||
|
||||
public Trigger ToTrigger()
|
||||
{
|
||||
if (!Enum.TryParse<TriggerWhen>(When, ignoreCase: true, out var when))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown trigger '{When}'.");
|
||||
}
|
||||
|
||||
return new Trigger(when, (Effects ?? Array.Empty<EffectDto>()).Select(e => e.ToEffect()).ToImmutableArray());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EffectDto
|
||||
{
|
||||
public string Op { get; set; } = "";
|
||||
public int Amount { get; set; }
|
||||
public int Amount2 { get; set; }
|
||||
public string? Target { get; set; }
|
||||
public string? CardId { get; set; }
|
||||
[JsonPropertyName("customEffect")]
|
||||
public string? CustomEffect { get; set; }
|
||||
|
||||
public Effect ToEffect()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CustomEffect))
|
||||
{
|
||||
return new Effect(EffectOp.Damage, 0, EffectTarget.None, CustomHandler: CustomEffect);
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<EffectOp>(Op, ignoreCase: true, out var op))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown effect op '{Op}'.");
|
||||
}
|
||||
|
||||
var target = string.IsNullOrWhiteSpace(Target)
|
||||
? EffectTarget.None
|
||||
: Enum.Parse<EffectTarget>(Target, ignoreCase: true);
|
||||
return new Effect(op, Amount, target, CardId, Amount2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace CardGame.Core;
|
||||
|
||||
internal static class CustomEffects
|
||||
{
|
||||
public static void Run(
|
||||
GameState state,
|
||||
Entity source,
|
||||
string handler,
|
||||
TargetRef? chosen,
|
||||
TargetRef? chosen2)
|
||||
{
|
||||
switch (handler)
|
||||
{
|
||||
case "vile_fire":
|
||||
VileFire(state, source, chosen, chosen2);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown custom effect '{handler}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void VileFire(GameState state, Entity source, TargetRef? friendly, TargetRef? enemy)
|
||||
{
|
||||
if (friendly is not EntityTarget friendlyTarget)
|
||||
{
|
||||
throw new InvalidOperationException("邪火 needs a friendly creature.");
|
||||
}
|
||||
|
||||
if (enemy is not EntityTarget enemyTarget)
|
||||
{
|
||||
throw new InvalidOperationException("邪火 needs an enemy creature.");
|
||||
}
|
||||
|
||||
Apply.Damage(state, friendlyTarget, 3);
|
||||
Apply.DamagePlayer(state, state.OpponentIndex(source.ControllerIndex), 3);
|
||||
Apply.Damage(state, enemyTarget, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>CardGame.Core</RootNamespace>
|
||||
<AssemblyName>Game.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,219 @@
|
||||
namespace CardGame.Core;
|
||||
|
||||
public static class MatchSetup
|
||||
{
|
||||
public static GameState Create(
|
||||
ulong seed,
|
||||
IReadOnlyList<CardDefinition> player0Deck,
|
||||
IReadOnlyList<CardDefinition> player1Deck,
|
||||
IReadOnlyDictionary<string, CardDefinition>? catalog = null,
|
||||
bool shuffle = true,
|
||||
bool placeGuaranteedField = true)
|
||||
{
|
||||
var state = new GameState(seed);
|
||||
var all = catalog ?? BuildCatalog(player0Deck.Concat(player1Deck));
|
||||
state.Catalog = all;
|
||||
FillDeck(state, 0, player0Deck, Factions.Guard);
|
||||
FillDeck(state, 1, player1Deck, Factions.Guard);
|
||||
if (shuffle)
|
||||
{
|
||||
Shuffle(state.Players[0].Deck, state.Rng);
|
||||
Shuffle(state.Players[1].Deck, state.Rng);
|
||||
}
|
||||
|
||||
if (placeGuaranteedField && all.TryGetValue("card.system.base_field", out var field))
|
||||
{
|
||||
PlaceSystemField(state, 0, field);
|
||||
PlaceSystemField(state, 1, field);
|
||||
}
|
||||
|
||||
DrawCards(state, 0, GameState.OpeningHand);
|
||||
DrawCards(state, 1, GameState.OpeningHand);
|
||||
BeginTurn(state, 0);
|
||||
CheckWinner(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
public static GameState CreateStartingBattle(
|
||||
ulong seed,
|
||||
IReadOnlyDictionary<string, CardDefinition> catalog,
|
||||
bool shuffle = true)
|
||||
{
|
||||
var deck0 = StartingPackage.BuildSharedDeck(catalog);
|
||||
var deck1 = StartingPackage.BuildSharedDeck(catalog);
|
||||
return Create(seed, deck0, deck1, catalog, shuffle, placeGuaranteedField: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a fight from run-owned cards for P0; P1 uses the shared starting package.
|
||||
/// Carries <see cref="RunProgress.Stats"/> into P0. Battle seed comes from <see cref="RunProgress.Rng"/>.
|
||||
/// </summary>
|
||||
public static GameState CreateFromRun(
|
||||
RunProgress run,
|
||||
IReadOnlyDictionary<string, CardDefinition> catalog,
|
||||
bool shuffle = true,
|
||||
bool placeGuaranteedField = true)
|
||||
{
|
||||
var battleSeed = run.Rng.NextUInt32();
|
||||
if (battleSeed == 0)
|
||||
{
|
||||
battleSeed = 1;
|
||||
}
|
||||
|
||||
var state = new GameState(battleSeed) { Catalog = catalog };
|
||||
state.Players[0].Run = run.Stats.Clone();
|
||||
FillDeckFromOwned(state, 0, run.Owned, catalog);
|
||||
FillDeck(state, 1, StartingPackage.BuildSharedDeck(catalog), Factions.Guard);
|
||||
|
||||
if (shuffle)
|
||||
{
|
||||
Shuffle(state.Players[0].Deck, state.Rng);
|
||||
Shuffle(state.Players[1].Deck, state.Rng);
|
||||
}
|
||||
|
||||
if (placeGuaranteedField && catalog.TryGetValue("card.system.base_field", out var field))
|
||||
{
|
||||
PlaceSystemField(state, 0, field);
|
||||
PlaceSystemField(state, 1, field);
|
||||
}
|
||||
|
||||
DrawCards(state, 0, GameState.OpeningHand);
|
||||
DrawCards(state, 1, GameState.OpeningHand);
|
||||
BeginTurn(state, 0);
|
||||
CheckWinner(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
public static void FillDeck(
|
||||
GameState state,
|
||||
int playerIndex,
|
||||
IReadOnlyList<CardDefinition> cards,
|
||||
string defaultHolder)
|
||||
{
|
||||
var player = state.Players[playerIndex];
|
||||
player.Deck.Clear();
|
||||
foreach (var def in cards)
|
||||
{
|
||||
var holder = StartingPackage.HolderFor(def.Id) ?? defaultHolder;
|
||||
var entity = state.NewEntity(def, playerIndex, Zone.Deck, holder);
|
||||
player.Deck.Add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
public static void FillDeckFromOwned(
|
||||
GameState state,
|
||||
int playerIndex,
|
||||
IReadOnlyList<OwnedCard> owned,
|
||||
IReadOnlyDictionary<string, CardDefinition> catalog)
|
||||
{
|
||||
var player = state.Players[playerIndex];
|
||||
player.Deck.Clear();
|
||||
foreach (var card in owned)
|
||||
{
|
||||
if (!catalog.TryGetValue(card.DefinitionId, out var def))
|
||||
{
|
||||
throw new InvalidOperationException($"Missing owned card '{card.DefinitionId}'.");
|
||||
}
|
||||
|
||||
var entity = state.NewEntity(def, playerIndex, Zone.Deck, card.HolderId);
|
||||
entity.Cost = card.Cost;
|
||||
entity.Attack = card.Attack;
|
||||
entity.Health = card.Health;
|
||||
entity.Countdown = card.Countdown;
|
||||
player.Deck.Add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
public static void PlaceSystemField(GameState state, int playerIndex, CardDefinition field)
|
||||
{
|
||||
var entity = state.NewEntity(field, playerIndex, Zone.Board, Factions.System);
|
||||
entity.IsSystem = true;
|
||||
entity.AttacksRemaining = 0;
|
||||
entity.ActivatesRemaining = 0;
|
||||
entity.EnteredOnTurn = 0;
|
||||
state.Board.Add(entity);
|
||||
}
|
||||
|
||||
public static void Shuffle(List<Entity> deck, GameRng rng)
|
||||
{
|
||||
for (var i = deck.Count - 1; i > 0; i--)
|
||||
{
|
||||
var j = rng.NextInt(i + 1);
|
||||
(deck[i], deck[j]) = (deck[j], deck[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public static void DrawCards(GameState state, int playerIndex, int count)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
DrawOne(state, playerIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void DrawOne(GameState state, int playerIndex)
|
||||
{
|
||||
var player = state.Players[playerIndex];
|
||||
if (player.Deck.Count == 0)
|
||||
{
|
||||
player.FatigueCount++;
|
||||
player.Run.FatigueTicks++;
|
||||
Apply.DamagePlayer(state, playerIndex, player.FatigueCount);
|
||||
state.EventLog.Add(new FatigueEvent(playerIndex, player.FatigueCount, player.Life));
|
||||
return;
|
||||
}
|
||||
|
||||
var card = player.Deck[0];
|
||||
player.Deck.RemoveAt(0);
|
||||
card.Zone = Zone.Hand;
|
||||
player.Hand.Add(card);
|
||||
state.EventLog.Add(new CardDrawnEvent(playerIndex, card.Id, card.Definition.Id));
|
||||
}
|
||||
|
||||
public static void BeginTurn(GameState state, int playerIndex)
|
||||
{
|
||||
state.ActivePlayerIndex = playerIndex;
|
||||
foreach (var entity in state.Board.Where(e => e.ControllerIndex == playerIndex))
|
||||
{
|
||||
if (entity.Definition.Type == CardType.Creature)
|
||||
{
|
||||
entity.AttacksRemaining = 1;
|
||||
}
|
||||
|
||||
entity.ActivatesRemaining = entity.Definition.HasActivate ? 1 : 0;
|
||||
}
|
||||
|
||||
state.EventLog.Add(new TurnStartedEvent(playerIndex, state.TurnNumber));
|
||||
Apply.FireBoardTriggers(state, playerIndex, TriggerWhen.TurnStart);
|
||||
DrawOne(state, playerIndex);
|
||||
}
|
||||
|
||||
public static void CheckWinner(GameState state)
|
||||
{
|
||||
if (state.Players[0].Life <= 0 && state.Players[1].Life <= 0)
|
||||
{
|
||||
state.WinnerIndex = state.ActivePlayerIndex == 0 ? 1 : 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.Players[0].Life <= 0)
|
||||
{
|
||||
state.WinnerIndex = 1;
|
||||
}
|
||||
else if (state.Players[1].Life <= 0)
|
||||
{
|
||||
state.WinnerIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, CardDefinition> BuildCatalog(IEnumerable<CardDefinition> cards)
|
||||
{
|
||||
var map = new Dictionary<string, CardDefinition>(StringComparer.Ordinal);
|
||||
foreach (var card in cards)
|
||||
{
|
||||
map[card.Id] = card;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace CardGame.Core;
|
||||
|
||||
public enum Zone
|
||||
{
|
||||
None = 0,
|
||||
Deck,
|
||||
Hand,
|
||||
Board,
|
||||
Graveyard
|
||||
}
|
||||
|
||||
public enum CardType
|
||||
{
|
||||
Creature,
|
||||
Spell,
|
||||
FieldCard
|
||||
}
|
||||
|
||||
public enum EffectOp
|
||||
{
|
||||
Damage,
|
||||
Heal,
|
||||
Draw,
|
||||
Discard,
|
||||
Destroy,
|
||||
Summon,
|
||||
MoveCard,
|
||||
ModifyStat,
|
||||
ModifyCountdown,
|
||||
AddKeyword,
|
||||
RemoveKeyword,
|
||||
GenerateEnergy,
|
||||
AddStatus,
|
||||
RemoveStatus,
|
||||
BuffAllFriendly,
|
||||
SetCost,
|
||||
CreateInHand,
|
||||
ShuffleIntoDeck,
|
||||
TransferStatsThenDie
|
||||
}
|
||||
|
||||
public enum EffectTarget
|
||||
{
|
||||
None = 0,
|
||||
SelfPlayer,
|
||||
OpponentPlayer,
|
||||
ChosenEnemy,
|
||||
ChosenFriendly,
|
||||
Self,
|
||||
AllFriendlyCreatures
|
||||
}
|
||||
|
||||
public enum TriggerWhen
|
||||
{
|
||||
TurnStart,
|
||||
OnDestroyed,
|
||||
OnEnterBoard,
|
||||
OpponentTurnEnd,
|
||||
OnSupply
|
||||
}
|
||||
|
||||
public readonly record struct EntityId(int Value)
|
||||
{
|
||||
public override string ToString() => $"#{Value}";
|
||||
}
|
||||
|
||||
public abstract record TargetRef;
|
||||
|
||||
public sealed record PlayerTarget(int PlayerIndex) : TargetRef;
|
||||
|
||||
public sealed record EntityTarget(EntityId Id) : TargetRef;
|
||||
|
||||
public sealed class GameRng
|
||||
{
|
||||
private ulong _state;
|
||||
|
||||
public GameRng(ulong seed)
|
||||
{
|
||||
_state = seed == 0 ? 0x9E3779B97F4A7C15UL : seed;
|
||||
}
|
||||
|
||||
public ulong State => _state;
|
||||
|
||||
public GameRng Clone() => (GameRng)MemberwiseClone();
|
||||
|
||||
public uint NextUInt32()
|
||||
{
|
||||
_state += 0x9E3779B97F4A7C15UL;
|
||||
var z = _state;
|
||||
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
|
||||
return (uint)(z ^ (z >> 31));
|
||||
}
|
||||
|
||||
public int NextInt(int exclusiveMax)
|
||||
{
|
||||
if (exclusiveMax <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(exclusiveMax));
|
||||
}
|
||||
|
||||
return (int)(NextUInt32() % (uint)exclusiveMax);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record Effect(
|
||||
EffectOp Op,
|
||||
int Amount = 0,
|
||||
EffectTarget Target = EffectTarget.None,
|
||||
string? CardId = null,
|
||||
int Amount2 = 0,
|
||||
string? CustomHandler = null);
|
||||
|
||||
public sealed record Trigger(TriggerWhen When, ImmutableArray<Effect> Effects);
|
||||
|
||||
public static class Tags
|
||||
{
|
||||
public const string Damage = "damage";
|
||||
public const string Heal = "heal";
|
||||
public const string Creature = "creature";
|
||||
public const string Field = "field";
|
||||
public const string Supply = "supply";
|
||||
public const string Countdown = "countdown";
|
||||
public const string Enter = "enter";
|
||||
public const string Death = "death";
|
||||
public const string Draw = "draw";
|
||||
public const string Graveyard = "graveyard";
|
||||
|
||||
public static readonly HashSet<string> Known =
|
||||
new(StringComparer.Ordinal)
|
||||
{
|
||||
Damage, Heal, Creature, Field, Supply, Countdown, Enter, Death, Draw, Graveyard
|
||||
};
|
||||
|
||||
public static bool IsKnown(string tag) => Known.Contains(tag);
|
||||
}
|
||||
|
||||
public static class Factions
|
||||
{
|
||||
public const string System = "faction.system";
|
||||
public const string Guard = "faction.guard";
|
||||
public const string Cult = "faction.cult";
|
||||
}
|
||||
|
||||
public sealed record CardDefinition(
|
||||
string Id,
|
||||
string Name,
|
||||
CardType Type,
|
||||
int Cost,
|
||||
ImmutableArray<Effect> Effects,
|
||||
ImmutableArray<string> Tags,
|
||||
int Attack = 0,
|
||||
int Health = 0,
|
||||
int Countdown = 0,
|
||||
ImmutableArray<Trigger> Triggers = default,
|
||||
ImmutableArray<Effect> ActivateEffects = default,
|
||||
bool ActivateNeedsFriendly = false,
|
||||
bool ActivateNeedsEnemy = false,
|
||||
bool PlayNeedsFriendly = false,
|
||||
bool PlayNeedsEnemy = false,
|
||||
bool PlayNeedsEnemyPlayerOrCreature = false)
|
||||
{
|
||||
public ImmutableArray<Trigger> TriggerList =>
|
||||
Triggers.IsDefaultOrEmpty ? ImmutableArray<Trigger>.Empty : Triggers;
|
||||
|
||||
public ImmutableArray<Effect> ActivateList =>
|
||||
ActivateEffects.IsDefaultOrEmpty ? ImmutableArray<Effect>.Empty : ActivateEffects;
|
||||
|
||||
public bool HasActivate => !ActivateList.IsDefaultOrEmpty;
|
||||
|
||||
public bool HasTag(string tag) => Tags.Contains(tag, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class Entity
|
||||
{
|
||||
public Entity(EntityId id, CardDefinition definition, int controllerIndex, Zone zone, string holderId)
|
||||
{
|
||||
Id = id;
|
||||
Definition = definition;
|
||||
ControllerIndex = controllerIndex;
|
||||
Zone = zone;
|
||||
HolderId = holderId;
|
||||
Cost = definition.Cost;
|
||||
Attack = definition.Attack;
|
||||
Health = definition.Health;
|
||||
Countdown = definition.Countdown;
|
||||
}
|
||||
|
||||
public EntityId Id { get; }
|
||||
public CardDefinition Definition { get; }
|
||||
public int ControllerIndex { get; set; }
|
||||
public string HolderId { get; set; }
|
||||
public Zone Zone { get; set; }
|
||||
public int Cost { get; set; }
|
||||
public int Attack { get; set; }
|
||||
public int Health { get; set; }
|
||||
public int Countdown { get; set; }
|
||||
public int EnteredOnTurn { get; set; }
|
||||
public int AttacksRemaining { get; set; }
|
||||
public int ActivatesRemaining { get; set; }
|
||||
public bool IsSystem { get; set; }
|
||||
|
||||
public Entity Clone() =>
|
||||
new(Id, Definition, ControllerIndex, Zone, HolderId)
|
||||
{
|
||||
Cost = Cost,
|
||||
Attack = Attack,
|
||||
Health = Health,
|
||||
Countdown = Countdown,
|
||||
EnteredOnTurn = EnteredOnTurn,
|
||||
AttacksRemaining = AttacksRemaining,
|
||||
ActivatesRemaining = ActivatesRemaining,
|
||||
IsSystem = IsSystem
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class PlayerState
|
||||
{
|
||||
public PlayerState(int index, int life)
|
||||
{
|
||||
Index = index;
|
||||
Life = life;
|
||||
MaxLife = life;
|
||||
}
|
||||
|
||||
public int Index { get; }
|
||||
public int Life { get; set; }
|
||||
public int MaxLife { get; set; }
|
||||
public int Energy { get; set; }
|
||||
public int FatigueCount { get; set; }
|
||||
public RunStats Run { get; set; } = new();
|
||||
public List<Entity> Deck { get; } = new();
|
||||
public List<Entity> Hand { get; } = new();
|
||||
public List<Entity> Graveyard { get; } = new();
|
||||
|
||||
public PlayerState Clone()
|
||||
{
|
||||
var copy = new PlayerState(Index, Life)
|
||||
{
|
||||
MaxLife = MaxLife,
|
||||
Energy = Energy,
|
||||
FatigueCount = FatigueCount,
|
||||
Run = Run.Clone()
|
||||
};
|
||||
copy.Deck.AddRange(Deck.Select(c => c.Clone()));
|
||||
copy.Hand.AddRange(Hand.Select(c => c.Clone()));
|
||||
copy.Graveyard.AddRange(Graveyard.Select(c => c.Clone()));
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract record GameAction;
|
||||
|
||||
public sealed record PlayCardAction(
|
||||
int PlayerIndex,
|
||||
EntityId CardId,
|
||||
TargetRef? Target = null,
|
||||
TargetRef? Target2 = null) : GameAction;
|
||||
|
||||
public sealed record AttackAction(int PlayerIndex, EntityId AttackerId, TargetRef Target) : GameAction;
|
||||
|
||||
public sealed record ActivateAction(
|
||||
int PlayerIndex,
|
||||
EntityId SourceId,
|
||||
TargetRef? Target = null,
|
||||
TargetRef? Target2 = null) : GameAction;
|
||||
|
||||
public sealed record EndTurnAction(int PlayerIndex) : GameAction;
|
||||
|
||||
public abstract record GameEvent;
|
||||
|
||||
public sealed record TurnStartedEvent(int PlayerIndex, int TurnNumber) : GameEvent;
|
||||
|
||||
public sealed record TurnEndedEvent(int PlayerIndex) : GameEvent;
|
||||
|
||||
public sealed record EnergySetEvent(int PlayerIndex, int Energy) : GameEvent;
|
||||
|
||||
public sealed record CardDrawnEvent(int PlayerIndex, EntityId CardId, string CardIdName) : GameEvent;
|
||||
|
||||
public sealed record FatigueEvent(int PlayerIndex, int Amount, int LifeAfter) : GameEvent;
|
||||
|
||||
public sealed record CardPlayedEvent(int PlayerIndex, EntityId CardId, string CardIdName) : GameEvent;
|
||||
|
||||
public sealed record EnteredBoardEvent(int PlayerIndex, EntityId EntityId, string CardIdName) : GameEvent;
|
||||
|
||||
public sealed record DamageEvent(int PlayerIndex, EntityId? VictimId, int Requested, int Applied, int Remaining) : GameEvent;
|
||||
|
||||
public sealed record HealEvent(int PlayerIndex, EntityId? TargetId, int Requested, int Applied, int LifeAfter) : GameEvent;
|
||||
|
||||
public sealed record DestroyedEvent(int PlayerIndex, EntityId EntityId, string CardIdName) : GameEvent;
|
||||
|
||||
public sealed record AttackDeclaredEvent(EntityId AttackerId, TargetRef Target) : GameEvent;
|
||||
|
||||
public sealed record ActivatedEvent(EntityId SourceId, TargetRef? Target) : GameEvent;
|
||||
|
||||
public sealed record CountdownChangedEvent(EntityId EntityId, int Value) : GameEvent;
|
||||
|
||||
public sealed record SupplyGeneratedEvent(int PlayerIndex, EntityId SourceId, int Amount) : GameEvent;
|
||||
|
||||
public sealed class ResponseWindow
|
||||
{
|
||||
public ResponseWindow(string timing, EntityId? source)
|
||||
{
|
||||
Timing = timing;
|
||||
Source = source;
|
||||
}
|
||||
|
||||
public string Timing { get; }
|
||||
public EntityId? Source { get; }
|
||||
public bool IsOpen { get; set; }
|
||||
|
||||
public ResponseWindow Clone() => new(Timing, Source) { IsOpen = IsOpen };
|
||||
}
|
||||
|
||||
public sealed class GameState
|
||||
{
|
||||
public const int BoardCapacity = 10;
|
||||
public const int EnergyCap = 20;
|
||||
public const int OpeningHand = 3;
|
||||
public const int DefaultLife = 20;
|
||||
|
||||
public GameState(ulong seed, int activePlayerIndex = 0)
|
||||
{
|
||||
Rng = new GameRng(seed);
|
||||
Seed = seed;
|
||||
ActivePlayerIndex = activePlayerIndex;
|
||||
Players.Add(new PlayerState(0, DefaultLife));
|
||||
Players.Add(new PlayerState(1, DefaultLife));
|
||||
}
|
||||
|
||||
public ulong Seed { get; }
|
||||
public GameRng Rng { get; private set; }
|
||||
public int ActivePlayerIndex { get; set; }
|
||||
public int TurnNumber { get; set; } = 1;
|
||||
public List<PlayerState> Players { get; } = new();
|
||||
public List<Entity> Board { get; } = new();
|
||||
public List<GameEvent> EventLog { get; } = new();
|
||||
public int NextEntityId { get; set; } = 1;
|
||||
public ResponseWindow? OpenResponse { get; set; }
|
||||
public int? WinnerIndex { get; set; }
|
||||
public IReadOnlyDictionary<string, CardDefinition> Catalog { get; set; } =
|
||||
new Dictionary<string, CardDefinition>(StringComparer.Ordinal);
|
||||
|
||||
public bool IsOver => WinnerIndex is not null;
|
||||
public PlayerState ActivePlayer => Players[ActivePlayerIndex];
|
||||
|
||||
public int OpponentIndex(int playerIndex) => 1 - playerIndex;
|
||||
|
||||
public GameState Clone()
|
||||
{
|
||||
var copy = new GameState(Seed, ActivePlayerIndex)
|
||||
{
|
||||
TurnNumber = TurnNumber,
|
||||
NextEntityId = NextEntityId,
|
||||
Rng = Rng.Clone(),
|
||||
OpenResponse = OpenResponse?.Clone(),
|
||||
WinnerIndex = WinnerIndex,
|
||||
Catalog = Catalog
|
||||
};
|
||||
copy.Players.Clear();
|
||||
copy.Players.AddRange(Players.Select(p => p.Clone()));
|
||||
copy.Board.AddRange(Board.Select(e => e.Clone()));
|
||||
copy.EventLog.AddRange(EventLog);
|
||||
return copy;
|
||||
}
|
||||
|
||||
public Entity NewEntity(CardDefinition definition, int controller, Zone zone, string holderId)
|
||||
{
|
||||
return new Entity(new EntityId(NextEntityId++), definition, controller, zone, holderId);
|
||||
}
|
||||
|
||||
public Entity? Find(EntityId id)
|
||||
{
|
||||
var onBoard = Board.FirstOrDefault(e => e.Id == id);
|
||||
if (onBoard is not null)
|
||||
{
|
||||
return onBoard;
|
||||
}
|
||||
|
||||
foreach (var player in Players)
|
||||
{
|
||||
var found = player.Deck.FirstOrDefault(e => e.Id == id)
|
||||
?? player.Hand.FirstOrDefault(e => e.Id == id)
|
||||
?? player.Graveyard.FirstOrDefault(e => e.Id == id);
|
||||
if (found is not null)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public int BoardCount(int playerIndex) => Board.Count(e => e.ControllerIndex == playerIndex);
|
||||
|
||||
public int EffectiveAttack(Entity entity)
|
||||
{
|
||||
var attack = entity.Attack;
|
||||
if (entity.Definition.Type == CardType.Creature
|
||||
&& entity.Zone == Zone.Board
|
||||
&& entity.Cost == 1
|
||||
&& Board.Any(e =>
|
||||
e.ControllerIndex == entity.ControllerIndex
|
||||
&& e.Zone == Zone.Board
|
||||
&& e.Definition.Id == "card.guard.captain"))
|
||||
{
|
||||
attack += 1;
|
||||
}
|
||||
|
||||
return attack;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CardGame.Core;
|
||||
|
||||
public static class PackJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static PackDefinition Parse(string json)
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<PackDto>(json, Options)
|
||||
?? throw new InvalidOperationException("Pack JSON was empty.");
|
||||
return dto.ToDefinition();
|
||||
}
|
||||
|
||||
public static List<PackDefinition> LoadDirectory(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return new List<PackDefinition>();
|
||||
}
|
||||
|
||||
return Directory.GetFiles(directory, "*.json")
|
||||
.Select(path => Parse(File.ReadAllText(path)))
|
||||
.OrderBy(p => p.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed class PackDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public bool Base { get; set; }
|
||||
public string[]? CoreActions { get; set; }
|
||||
public string[]? Cards { get; set; }
|
||||
public ConditionDto[]? AllOf { get; set; }
|
||||
|
||||
public PackDefinition ToDefinition()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Id) || string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
throw new InvalidOperationException("Pack id and name are required.");
|
||||
}
|
||||
|
||||
return new PackDefinition(
|
||||
Id,
|
||||
Name,
|
||||
Base,
|
||||
(CoreActions ?? Array.Empty<string>()).ToImmutableArray(),
|
||||
(Cards ?? Array.Empty<string>()).ToImmutableArray(),
|
||||
(AllOf ?? Array.Empty<ConditionDto>()).Select(c => c.ToCondition()).ToImmutableArray());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ConditionDto
|
||||
{
|
||||
public string Kind { get; set; } = "";
|
||||
public int Amount { get; set; }
|
||||
public string? Tag { get; set; }
|
||||
|
||||
public PackCondition ToCondition()
|
||||
{
|
||||
if (!Enum.TryParse<PackConditionKind>(Kind, ignoreCase: true, out var kind))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown pack condition '{Kind}'.");
|
||||
}
|
||||
|
||||
return new PackCondition(kind, Amount, Tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace CardGame.Core;
|
||||
|
||||
public sealed class RunStats
|
||||
{
|
||||
public int HealReceived { get; set; }
|
||||
public int AttacksDeclared { get; set; }
|
||||
public int FriendlyDestroyed { get; set; }
|
||||
public int FatigueTicks { get; set; }
|
||||
public Dictionary<string, int> KillsByTag { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public int Kills(string tag) => KillsByTag.GetValueOrDefault(tag);
|
||||
|
||||
public RunStats Clone()
|
||||
{
|
||||
var copy = new RunStats
|
||||
{
|
||||
HealReceived = HealReceived,
|
||||
AttacksDeclared = AttacksDeclared,
|
||||
FriendlyDestroyed = FriendlyDestroyed,
|
||||
FatigueTicks = FatigueTicks
|
||||
};
|
||||
foreach (var (key, value) in KillsByTag)
|
||||
{
|
||||
copy.KillsByTag[key] = value;
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
public enum PackConditionKind
|
||||
{
|
||||
HealReceivedAtLeast,
|
||||
AttacksAtLeast,
|
||||
FriendlyDestroyedAtLeast,
|
||||
FatigueAtLeast,
|
||||
DeckTagAtLeast,
|
||||
KillTagAtLeast
|
||||
}
|
||||
|
||||
public sealed record PackCondition(PackConditionKind Kind, int Amount = 0, string? Tag = null);
|
||||
|
||||
public sealed record PackDefinition(
|
||||
string Id,
|
||||
string Name,
|
||||
bool Base,
|
||||
ImmutableArray<string> CoreActions,
|
||||
ImmutableArray<string> Cards,
|
||||
ImmutableArray<PackCondition> AllOf);
|
||||
|
||||
public static class Packs
|
||||
{
|
||||
public static bool IsUnlocked(GameState state, int playerIndex, PackDefinition pack)
|
||||
{
|
||||
if (pack.Base)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return pack.AllOf.All(c => Met(state, playerIndex, c));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<PackDefinition> Unlocked(GameState state, int playerIndex, IEnumerable<PackDefinition> catalog) =>
|
||||
catalog.Where(p => IsUnlocked(state, playerIndex, p)).ToList();
|
||||
|
||||
public static int CountTag(GameState state, int playerIndex, string tag)
|
||||
{
|
||||
var player = state.Players[playerIndex];
|
||||
return player.Deck.Concat(player.Hand).Concat(player.Graveyard)
|
||||
.Concat(state.Board.Where(e => e.ControllerIndex == playerIndex))
|
||||
.Count(e => e.Definition.HasTag(tag));
|
||||
}
|
||||
|
||||
private static bool Met(GameState state, int playerIndex, PackCondition condition)
|
||||
{
|
||||
var run = state.Players[playerIndex].Run;
|
||||
return condition.Kind switch
|
||||
{
|
||||
PackConditionKind.HealReceivedAtLeast => run.HealReceived >= condition.Amount,
|
||||
PackConditionKind.AttacksAtLeast => run.AttacksDeclared >= condition.Amount,
|
||||
PackConditionKind.FriendlyDestroyedAtLeast => run.FriendlyDestroyed >= condition.Amount,
|
||||
PackConditionKind.FatigueAtLeast => run.FatigueTicks >= condition.Amount,
|
||||
PackConditionKind.DeckTagAtLeast => CountTag(state, playerIndex, condition.Tag ?? "") >= condition.Amount,
|
||||
PackConditionKind.KillTagAtLeast => run.Kills(condition.Tag ?? "") >= condition.Amount,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace CardGame.Core;
|
||||
|
||||
public static class RulesEngine
|
||||
{
|
||||
public static ImmutableArray<GameAction> GetLegalActions(GameState state)
|
||||
{
|
||||
if (state.IsOver || state.OpenResponse is { IsOpen: true })
|
||||
{
|
||||
return ImmutableArray<GameAction>.Empty;
|
||||
}
|
||||
|
||||
var player = state.ActivePlayer;
|
||||
var actions = new List<GameAction>();
|
||||
|
||||
foreach (var card in player.Hand)
|
||||
{
|
||||
actions.AddRange(LegalPlays(state, card));
|
||||
}
|
||||
|
||||
foreach (var attacker in state.Board.Where(e => CanAttack(state, e)))
|
||||
{
|
||||
var enemy = state.OpponentIndex(player.Index);
|
||||
actions.Add(new AttackAction(player.Index, attacker.Id, new PlayerTarget(enemy)));
|
||||
foreach (var blocker in state.Board.Where(e => e.ControllerIndex == enemy && e.Definition.Type == CardType.Creature))
|
||||
{
|
||||
actions.Add(new AttackAction(player.Index, attacker.Id, new EntityTarget(blocker.Id)));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var source in state.Board.Where(e => CanActivate(state, e)))
|
||||
{
|
||||
actions.AddRange(LegalActivates(state, source));
|
||||
}
|
||||
|
||||
actions.Add(new EndTurnAction(player.Index));
|
||||
return actions.ToImmutableArray();
|
||||
}
|
||||
|
||||
public static GameState CloneState(GameState state) => state.Clone();
|
||||
|
||||
public static GameState Execute(GameState state, GameAction action)
|
||||
{
|
||||
if (!GetLegalActions(state).Any(legal => SameAction(legal, action)))
|
||||
{
|
||||
throw new InvalidOperationException($"Illegal action: {action}");
|
||||
}
|
||||
|
||||
var next = state.Clone();
|
||||
switch (action)
|
||||
{
|
||||
case PlayCardAction play:
|
||||
Play(next, play);
|
||||
break;
|
||||
case AttackAction attack:
|
||||
Attack(next, attack);
|
||||
break;
|
||||
case ActivateAction activate:
|
||||
Activate(next, activate);
|
||||
break;
|
||||
case EndTurnAction:
|
||||
EndTurn(next);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown action: {action}");
|
||||
}
|
||||
|
||||
return ResolveUntilStable(next);
|
||||
}
|
||||
|
||||
public static GameState ResolveUntilStable(GameState state)
|
||||
{
|
||||
state.OpenResponse = null;
|
||||
MatchSetup.CheckWinner(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private static bool SameAction(GameAction a, GameAction b) =>
|
||||
(a, b) switch
|
||||
{
|
||||
(PlayCardAction x, PlayCardAction y) =>
|
||||
x.PlayerIndex == y.PlayerIndex && x.CardId == y.CardId
|
||||
&& Equals(x.Target, y.Target) && Equals(x.Target2, y.Target2),
|
||||
(AttackAction x, AttackAction y) =>
|
||||
x.PlayerIndex == y.PlayerIndex && x.AttackerId == y.AttackerId && Equals(x.Target, y.Target),
|
||||
(ActivateAction x, ActivateAction y) =>
|
||||
x.PlayerIndex == y.PlayerIndex && x.SourceId == y.SourceId
|
||||
&& Equals(x.Target, y.Target) && Equals(x.Target2, y.Target2),
|
||||
(EndTurnAction x, EndTurnAction y) => x.PlayerIndex == y.PlayerIndex,
|
||||
_ => false
|
||||
};
|
||||
|
||||
private static IEnumerable<GameAction> LegalPlays(GameState state, Entity card)
|
||||
{
|
||||
var player = state.Players[card.ControllerIndex];
|
||||
if (card.Zone != Zone.Hand || card.Cost > player.Energy)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var occupiesBoard = card.Definition.Type is CardType.Creature or CardType.FieldCard;
|
||||
if (occupiesBoard && state.Board.Count >= GameState.BoardCapacity)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var def = card.Definition;
|
||||
if (def.PlayNeedsFriendly && def.PlayNeedsEnemy)
|
||||
{
|
||||
foreach (var friendly in state.Board.Where(e =>
|
||||
e.ControllerIndex == player.Index && e.Definition.Type == CardType.Creature))
|
||||
{
|
||||
foreach (var enemy in state.Board.Where(e =>
|
||||
e.ControllerIndex == state.OpponentIndex(player.Index)
|
||||
&& e.Definition.Type == CardType.Creature))
|
||||
{
|
||||
yield return new PlayCardAction(
|
||||
player.Index,
|
||||
card.Id,
|
||||
new EntityTarget(friendly.Id),
|
||||
new EntityTarget(enemy.Id));
|
||||
}
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (def.PlayNeedsFriendly)
|
||||
{
|
||||
foreach (var friendly in state.Board.Where(e =>
|
||||
e.ControllerIndex == player.Index && e.Definition.Type == CardType.Creature))
|
||||
{
|
||||
yield return new PlayCardAction(player.Index, card.Id, new EntityTarget(friendly.Id));
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (def.PlayNeedsEnemy || def.PlayNeedsEnemyPlayerOrCreature || NeedsChosenEnemy(def))
|
||||
{
|
||||
var enemy = state.OpponentIndex(player.Index);
|
||||
yield return new PlayCardAction(player.Index, card.Id, new PlayerTarget(enemy));
|
||||
foreach (var unit in state.Board.Where(e =>
|
||||
e.ControllerIndex == enemy && e.Definition.Type == CardType.Creature))
|
||||
{
|
||||
yield return new PlayCardAction(player.Index, card.Id, new EntityTarget(unit.Id));
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new PlayCardAction(player.Index, card.Id);
|
||||
}
|
||||
|
||||
private static bool NeedsChosenEnemy(CardDefinition def) =>
|
||||
def.Effects.Any(e => e.Target == EffectTarget.ChosenEnemy);
|
||||
|
||||
private static IEnumerable<GameAction> LegalActivates(GameState state, Entity source)
|
||||
{
|
||||
var playerIndex = source.ControllerIndex;
|
||||
var def = source.Definition;
|
||||
if (def.ActivateNeedsFriendly)
|
||||
{
|
||||
foreach (var friendly in state.Board.Where(e =>
|
||||
e.ControllerIndex == playerIndex
|
||||
&& e.Id != source.Id
|
||||
&& e.Definition.Type == CardType.Creature))
|
||||
{
|
||||
yield return new ActivateAction(playerIndex, source.Id, new EntityTarget(friendly.Id));
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (def.ActivateNeedsEnemy)
|
||||
{
|
||||
var enemy = state.OpponentIndex(playerIndex);
|
||||
foreach (var unit in state.Board.Where(e =>
|
||||
e.ControllerIndex == enemy && e.Definition.Type == CardType.Creature))
|
||||
{
|
||||
yield return new ActivateAction(playerIndex, source.Id, new EntityTarget(unit.Id));
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new ActivateAction(playerIndex, source.Id);
|
||||
}
|
||||
|
||||
private static bool CanAttack(GameState state, Entity entity) =>
|
||||
entity.Zone == Zone.Board
|
||||
&& entity.ControllerIndex == state.ActivePlayerIndex
|
||||
&& entity.Definition.Type == CardType.Creature
|
||||
&& entity.AttacksRemaining > 0
|
||||
&& state.EffectiveAttack(entity) > 0
|
||||
&& !state.IsOver;
|
||||
|
||||
private static bool CanActivate(GameState state, Entity entity) =>
|
||||
entity.Zone == Zone.Board
|
||||
&& entity.ControllerIndex == state.ActivePlayerIndex
|
||||
&& entity.Definition.HasActivate
|
||||
&& entity.ActivatesRemaining > 0
|
||||
&& !state.IsOver;
|
||||
|
||||
private static void Play(GameState state, PlayCardAction play)
|
||||
{
|
||||
var player = state.Players[play.PlayerIndex];
|
||||
var card = player.Hand.First(c => c.Id == play.CardId);
|
||||
player.Hand.Remove(card);
|
||||
player.Energy -= card.Cost;
|
||||
state.EventLog.Add(new EnergySetEvent(player.Index, player.Energy));
|
||||
state.EventLog.Add(new CardPlayedEvent(player.Index, card.Id, card.Definition.Id));
|
||||
|
||||
if (card.Definition.Type is CardType.Creature or CardType.FieldCard)
|
||||
{
|
||||
Apply.EnterBoard(state, card, grantActivate: true, grantAttack: false);
|
||||
Apply.Effects(state, card, play.Target, play.Target2);
|
||||
}
|
||||
else
|
||||
{
|
||||
Apply.Effects(state, card, play.Target, play.Target2);
|
||||
card.Zone = Zone.Graveyard;
|
||||
player.Graveyard.Add(card);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Attack(GameState state, AttackAction attack)
|
||||
{
|
||||
var attacker = state.Find(attack.AttackerId) ?? throw new InvalidOperationException("Missing attacker.");
|
||||
attacker.AttacksRemaining--;
|
||||
state.Players[attacker.ControllerIndex].Run.AttacksDeclared++;
|
||||
state.EventLog.Add(new AttackDeclaredEvent(attacker.Id, attack.Target));
|
||||
Apply.Damage(state, attack.Target, state.EffectiveAttack(attacker));
|
||||
}
|
||||
|
||||
private static void Activate(GameState state, ActivateAction activate)
|
||||
{
|
||||
var source = state.Find(activate.SourceId) ?? throw new InvalidOperationException("Missing activate source.");
|
||||
source.ActivatesRemaining--;
|
||||
state.EventLog.Add(new ActivatedEvent(source.Id, activate.Target));
|
||||
Apply.ActivateEffects(state, source, activate.Target, activate.Target2);
|
||||
}
|
||||
|
||||
private static void EndTurn(GameState state)
|
||||
{
|
||||
var ending = state.ActivePlayerIndex;
|
||||
Apply.TickCountdowns(state, ending);
|
||||
if (state.IsOver)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
state.EventLog.Add(new TurnEndedEvent(ending));
|
||||
var nextPlayer = state.OpponentIndex(ending);
|
||||
Apply.FireBoardTriggers(state, nextPlayer, TriggerWhen.OpponentTurnEnd);
|
||||
if (state.IsOver)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextPlayer == 0)
|
||||
{
|
||||
state.TurnNumber++;
|
||||
}
|
||||
|
||||
MatchSetup.BeginTurn(state, nextPlayer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
namespace CardGame.Core;
|
||||
|
||||
/// <summary>Persistent card copy for a run (between fights).</summary>
|
||||
public sealed class OwnedCard
|
||||
{
|
||||
public OwnedCard(string definitionId, string holderId, int cost, int attack, int health, int countdown)
|
||||
{
|
||||
DefinitionId = definitionId;
|
||||
HolderId = holderId;
|
||||
Cost = cost;
|
||||
Attack = attack;
|
||||
Health = health;
|
||||
Countdown = countdown;
|
||||
}
|
||||
|
||||
public string DefinitionId { get; }
|
||||
public string HolderId { get; set; }
|
||||
public int Cost { get; set; }
|
||||
public int Attack { get; set; }
|
||||
public int Health { get; set; }
|
||||
public int Countdown { get; set; }
|
||||
|
||||
public static OwnedCard FromDefinition(CardDefinition def, string holderId) =>
|
||||
new(def.Id, holderId, def.Cost, def.Attack, def.Health, def.Countdown);
|
||||
|
||||
public OwnedCard Clone() =>
|
||||
new(DefinitionId, HolderId, Cost, Attack, Health, Countdown);
|
||||
}
|
||||
|
||||
public enum SimpleUpgrade
|
||||
{
|
||||
Skip = 0,
|
||||
ReduceCost,
|
||||
PlusAttack,
|
||||
PlusHealth
|
||||
}
|
||||
|
||||
/// <summary>In-run progression: owned cards, live pack stats, fight index. Resets when the run ends.</summary>
|
||||
public sealed class RunProgress
|
||||
{
|
||||
public const int MaxOwned = 20;
|
||||
public const int DefaultFightCount = 3;
|
||||
public const int RewardChoices = 3;
|
||||
|
||||
public RunProgress(ulong seed)
|
||||
{
|
||||
Seed = seed;
|
||||
Rng = new GameRng(seed);
|
||||
}
|
||||
|
||||
public ulong Seed { get; }
|
||||
public GameRng Rng { get; }
|
||||
public RunStats Stats { get; set; } = new();
|
||||
public List<OwnedCard> Owned { get; } = new();
|
||||
public int FightIndex { get; set; }
|
||||
public int Wins { get; set; }
|
||||
|
||||
public static RunProgress Start(ulong seed, IReadOnlyDictionary<string, CardDefinition> catalog)
|
||||
{
|
||||
var run = new RunProgress(seed);
|
||||
foreach (var (cardId, count, holder) in StartingPackage.Manifest)
|
||||
{
|
||||
if (!catalog.TryGetValue(cardId, out var def))
|
||||
{
|
||||
throw new InvalidOperationException($"Missing starting card '{cardId}'.");
|
||||
}
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
run.Owned.Add(OwnedCard.FromDefinition(def, holder));
|
||||
}
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
public void SyncStatsFrom(GameState state, int playerIndex) =>
|
||||
Stats = state.Players[playerIndex].Run.Clone();
|
||||
}
|
||||
|
||||
public static class RunRewards
|
||||
{
|
||||
public static IReadOnlyList<string> ActivePool(
|
||||
GameState state,
|
||||
int playerIndex,
|
||||
IEnumerable<PackDefinition> packs) =>
|
||||
Packs.Unlocked(state, playerIndex, packs)
|
||||
.SelectMany(p => p.Cards)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
public static List<string> RollOffer(GameRng rng, IReadOnlyList<string> pool, int count)
|
||||
{
|
||||
if (pool.Count == 0 || count <= 0)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var bag = pool.ToList();
|
||||
var offer = new List<string>();
|
||||
var n = Math.Min(count, bag.Count);
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var pick = rng.NextInt(bag.Count);
|
||||
offer.Add(bag[pick]);
|
||||
bag.RemoveAt(pick);
|
||||
}
|
||||
|
||||
return offer;
|
||||
}
|
||||
|
||||
public static string DefaultHolder(string cardId) =>
|
||||
StartingPackage.HolderFor(cardId) ?? Factions.Guard;
|
||||
|
||||
public static IReadOnlyList<string> HolderChoices(string cardId)
|
||||
{
|
||||
var known = StartingPackage.HolderFor(cardId);
|
||||
if (known is not null)
|
||||
{
|
||||
return new[] { known };
|
||||
}
|
||||
|
||||
return new[] { Factions.Guard, Factions.Cult };
|
||||
}
|
||||
|
||||
public static void RemoveAt(RunProgress run, int index)
|
||||
{
|
||||
if (index < 0 || index >= run.Owned.Count)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
|
||||
run.Owned.RemoveAt(index);
|
||||
}
|
||||
|
||||
public static OwnedCard AddCard(RunProgress run, CardDefinition def, string holderId)
|
||||
{
|
||||
if (run.Owned.Count >= RunProgress.MaxOwned)
|
||||
{
|
||||
throw new InvalidOperationException($"Owned cards already at max ({RunProgress.MaxOwned}). Remove one first.");
|
||||
}
|
||||
|
||||
var card = OwnedCard.FromDefinition(def, holderId);
|
||||
run.Owned.Add(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<SimpleUpgrade> LegalUpgrades(OwnedCard card, CardDefinition def)
|
||||
{
|
||||
var list = new List<SimpleUpgrade> { SimpleUpgrade.Skip };
|
||||
if (card.Cost > 0)
|
||||
{
|
||||
list.Add(SimpleUpgrade.ReduceCost);
|
||||
}
|
||||
|
||||
if (def.Type == CardType.Creature)
|
||||
{
|
||||
list.Add(SimpleUpgrade.PlusAttack);
|
||||
list.Add(SimpleUpgrade.PlusHealth);
|
||||
}
|
||||
else if (def.Type == CardType.FieldCard)
|
||||
{
|
||||
list.Add(SimpleUpgrade.PlusHealth);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public static bool ApplyUpgrade(OwnedCard card, CardDefinition def, SimpleUpgrade upgrade)
|
||||
{
|
||||
switch (upgrade)
|
||||
{
|
||||
case SimpleUpgrade.Skip:
|
||||
return true;
|
||||
case SimpleUpgrade.ReduceCost:
|
||||
if (card.Cost <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
card.Cost--;
|
||||
return true;
|
||||
case SimpleUpgrade.PlusAttack:
|
||||
if (def.Type != CardType.Creature)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
card.Attack++;
|
||||
return true;
|
||||
case SimpleUpgrade.PlusHealth:
|
||||
if (def.Type is not (CardType.Creature or CardType.FieldCard))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
card.Health++;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace CardGame.Core;
|
||||
|
||||
public static class SimpleAutoPlayer
|
||||
{
|
||||
public static GameAction Choose(GameState state, ImmutableArray<GameAction> legal)
|
||||
{
|
||||
var face = legal.OfType<AttackAction>().FirstOrDefault(a => a.Target is PlayerTarget);
|
||||
if (face is not null)
|
||||
{
|
||||
return face;
|
||||
}
|
||||
|
||||
var activate = legal.OfType<ActivateAction>().FirstOrDefault();
|
||||
if (activate is not null && state.Rng.NextInt(100) < 35)
|
||||
{
|
||||
return activate;
|
||||
}
|
||||
|
||||
var play = legal.OfType<PlayCardAction>()
|
||||
.OrderBy(p => state.Find(p.CardId)!.Cost)
|
||||
.FirstOrDefault();
|
||||
if (play is not null)
|
||||
{
|
||||
return play;
|
||||
}
|
||||
|
||||
var anyAttack = legal.OfType<AttackAction>().FirstOrDefault();
|
||||
if (anyAttack is not null)
|
||||
{
|
||||
return anyAttack;
|
||||
}
|
||||
|
||||
return legal.OfType<EndTurnAction>().First();
|
||||
}
|
||||
|
||||
public static GameState PlayUntil(GameState state, int playerIndex, int maxSteps = 40)
|
||||
{
|
||||
var steps = 0;
|
||||
while (!state.IsOver && state.ActivePlayerIndex != playerIndex && steps < maxSteps)
|
||||
{
|
||||
var legal = RulesEngine.GetLegalActions(state);
|
||||
if (legal.IsDefaultOrEmpty)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
state = RulesEngine.Execute(state, Choose(state, legal));
|
||||
steps++;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace CardGame.Core;
|
||||
|
||||
public static class StartingPackage
|
||||
{
|
||||
public static List<(string CardId, int Count, string Holder)> Manifest { get; } =
|
||||
[
|
||||
("card.guard.patrol", 2, Factions.Guard),
|
||||
("card.guard.captain", 1, Factions.Guard),
|
||||
("card.guard.farm", 1, Factions.Guard),
|
||||
("card.guard.ration", 1, Factions.Guard),
|
||||
("card.guard.orders", 2, Factions.Guard),
|
||||
("card.guard.conscript", 1, Factions.Guard),
|
||||
("card.cult.believer", 5, Factions.Cult),
|
||||
("card.cult.mine", 1, Factions.Cult),
|
||||
("card.cult.camp", 1, Factions.Cult),
|
||||
("card.cult.vilefire", 1, Factions.Cult)
|
||||
];
|
||||
|
||||
public static string? HolderFor(string cardId)
|
||||
{
|
||||
foreach (var (id, _, holder) in Manifest)
|
||||
{
|
||||
if (id == cardId)
|
||||
{
|
||||
return holder;
|
||||
}
|
||||
}
|
||||
|
||||
return cardId.StartsWith("card.guard.", StringComparison.Ordinal) ? Factions.Guard
|
||||
: cardId.StartsWith("card.cult.", StringComparison.Ordinal) ? Factions.Cult
|
||||
: null;
|
||||
}
|
||||
|
||||
public static List<CardDefinition> BuildSharedDeck(IReadOnlyDictionary<string, CardDefinition> catalog)
|
||||
{
|
||||
var deck = new List<CardDefinition>();
|
||||
foreach (var (cardId, count, _) in Manifest)
|
||||
{
|
||||
if (!catalog.TryGetValue(cardId, out var def))
|
||||
{
|
||||
throw new InvalidOperationException($"Missing starting card '{cardId}'.");
|
||||
}
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
deck.Add(def);
|
||||
}
|
||||
}
|
||||
|
||||
return deck;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace CardGame.Core;
|
||||
|
||||
public sealed record AiCandidate(string Label, double Score, bool Chosen);
|
||||
|
||||
public sealed record AiStep(
|
||||
string ChosenLabel,
|
||||
double ChosenScore,
|
||||
IReadOnlyList<AiCandidate> TopCandidates);
|
||||
|
||||
public sealed record AiTurnTrace(IReadOnlyList<AiStep> Steps);
|
||||
|
||||
public static class TurnBeamSearch
|
||||
{
|
||||
public const double WinScore = 10_000;
|
||||
public const int BeamWidth = 4;
|
||||
public const int MaxDepth = 6;
|
||||
public const double NearOptimal = 0.92;
|
||||
|
||||
public static (GameAction Action, IReadOnlyList<AiCandidate> Top) ChooseWithTrace(
|
||||
GameState state,
|
||||
ImmutableArray<GameAction> legal)
|
||||
{
|
||||
if (legal.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new InvalidOperationException("No legal actions.");
|
||||
}
|
||||
|
||||
var actor = state.ActivePlayerIndex;
|
||||
var scored = new List<(GameAction Action, double Score, string Label)>();
|
||||
foreach (var action in legal)
|
||||
{
|
||||
var next = RulesEngine.Execute(state, action);
|
||||
var score = action is EndTurnAction
|
||||
? Evaluate(next, actor)
|
||||
: BeamBest(next, actor, depth: 1);
|
||||
scored.Add((action, score, Describe(state, action)));
|
||||
}
|
||||
|
||||
var best = scored.Max(s => s.Score);
|
||||
// Abs margin so negative best scores still keep the top band (best * threshold fails when best < 0).
|
||||
var floor = best - Math.Abs(best) * (1.0 - NearOptimal);
|
||||
var near = scored.Where(s => s.Score >= floor).ToList();
|
||||
if (near.Count == 0)
|
||||
{
|
||||
near = scored.Where(s => s.Score >= best).ToList();
|
||||
}
|
||||
|
||||
var pick = near[state.Rng.NextInt(near.Count)];
|
||||
var top = scored
|
||||
.OrderByDescending(s => s.Score)
|
||||
.Take(6)
|
||||
.Select(s => new AiCandidate(s.Label, Math.Round(s.Score, 1), s.Action == pick.Action))
|
||||
.ToList();
|
||||
return (pick.Action, top);
|
||||
}
|
||||
|
||||
public static (GameState State, AiTurnTrace Trace) PlayUntil(
|
||||
GameState state,
|
||||
int untilPlayerIndex,
|
||||
int maxSteps = 40)
|
||||
{
|
||||
var steps = new List<AiStep>();
|
||||
var n = 0;
|
||||
while (!state.IsOver && state.ActivePlayerIndex != untilPlayerIndex && n < maxSteps)
|
||||
{
|
||||
var legal = RulesEngine.GetLegalActions(state);
|
||||
if (legal.IsDefaultOrEmpty)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var (action, top) = ChooseWithTrace(state, legal);
|
||||
var chosen = top.First(t => t.Chosen);
|
||||
steps.Add(new AiStep(chosen.Label, chosen.Score, top));
|
||||
state = RulesEngine.Execute(state, action);
|
||||
n++;
|
||||
}
|
||||
|
||||
return (state, new AiTurnTrace(steps));
|
||||
}
|
||||
|
||||
private static double BeamBest(GameState state, int actor, int depth)
|
||||
{
|
||||
if (state.IsOver || depth >= MaxDepth || state.ActivePlayerIndex != actor)
|
||||
{
|
||||
return Evaluate(state, actor);
|
||||
}
|
||||
|
||||
var legal = RulesEngine.GetLegalActions(state);
|
||||
if (legal.IsDefaultOrEmpty)
|
||||
{
|
||||
return Evaluate(state, actor);
|
||||
}
|
||||
|
||||
var scored = new List<(GameAction Action, double Score)>(legal.Length);
|
||||
foreach (var action in legal)
|
||||
{
|
||||
var next = RulesEngine.Execute(state, action);
|
||||
scored.Add((action, Evaluate(next, actor)));
|
||||
}
|
||||
|
||||
var beam = scored.OrderByDescending(s => s.Score).Take(BeamWidth).ToList();
|
||||
var best = double.NegativeInfinity;
|
||||
foreach (var (action, _) in beam)
|
||||
{
|
||||
if (action is EndTurnAction)
|
||||
{
|
||||
best = Math.Max(best, Evaluate(RulesEngine.Execute(state, action), actor));
|
||||
continue;
|
||||
}
|
||||
|
||||
var next = RulesEngine.Execute(state, action);
|
||||
best = Math.Max(best, BeamBest(next, actor, depth + 1));
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public static double Evaluate(GameState state, int actor)
|
||||
{
|
||||
var foe = 1 - actor;
|
||||
if (state.WinnerIndex == actor)
|
||||
{
|
||||
return WinScore;
|
||||
}
|
||||
|
||||
if (state.WinnerIndex == foe)
|
||||
{
|
||||
return -WinScore;
|
||||
}
|
||||
|
||||
var me = state.Players[actor];
|
||||
var them = state.Players[foe];
|
||||
double score = (me.Life - them.Life) * 12.0;
|
||||
score += me.Energy * 1.5;
|
||||
score += me.Hand.Count * 1.2;
|
||||
score += BoardValue(state, actor) * 3.0;
|
||||
score -= BoardValue(state, foe) * 3.2;
|
||||
score += me.Deck.Count * 0.05;
|
||||
score -= them.Life <= 5 ? (5 - them.Life) * 4 : 0;
|
||||
return score;
|
||||
}
|
||||
|
||||
private static double BoardValue(GameState state, int player) =>
|
||||
state.Board
|
||||
.Where(e => e.ControllerIndex == player && e.Definition.Type == CardType.Creature)
|
||||
.Sum(e => state.EffectiveAttack(e) + e.Health * 0.8);
|
||||
|
||||
private static string Describe(GameState state, GameAction action) =>
|
||||
action switch
|
||||
{
|
||||
PlayCardAction play =>
|
||||
"打出 " + CardName(state, play.CardId) + Target(state, play.Target) + Target(state, play.Target2),
|
||||
AttackAction attack =>
|
||||
"攻击 " + CardName(state, attack.AttackerId) + Target(state, attack.Target),
|
||||
ActivateAction act =>
|
||||
"激活 " + CardName(state, act.SourceId) + Target(state, act.Target),
|
||||
EndTurnAction => "结束回合",
|
||||
_ => action.ToString() ?? "?"
|
||||
};
|
||||
|
||||
private static string CardName(GameState state, EntityId id)
|
||||
{
|
||||
var e = state.Find(id);
|
||||
return e?.Definition.Name ?? id.ToString();
|
||||
}
|
||||
|
||||
private static string Target(GameState state, TargetRef? target) =>
|
||||
target switch
|
||||
{
|
||||
null => "",
|
||||
PlayerTarget p => p.PlayerIndex == 0 ? " → 己方主战者" : " → 敌方主战者",
|
||||
EntityTarget e => " → " + CardName(state, e.Id),
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>HeadlessBattle</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Game.Core\Game.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\content\**\*" CopyToOutputDirectory="PreserveNewest" Link="content\%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,430 @@
|
||||
using System.Collections.Immutable;
|
||||
using CardGame.Core;
|
||||
|
||||
var cardsDir = ResolveContent("cards");
|
||||
var packsDir = ResolveContent("packs");
|
||||
var catalog = CardJson.LoadCatalog(cardsDir);
|
||||
var packs = PackJson.LoadDirectory(packsDir);
|
||||
var seed = ParseSeed(args);
|
||||
var auto = args.Any(a => a is "--auto" or "-a");
|
||||
var runMode = args.Any(a => a is "--run" or "-r");
|
||||
var fights = ParseFights(args);
|
||||
|
||||
if (runMode)
|
||||
{
|
||||
RunFull(seed, auto, fights, catalog, packs);
|
||||
}
|
||||
else
|
||||
{
|
||||
RunSingleBattle(seed, auto, catalog);
|
||||
}
|
||||
|
||||
static void RunSingleBattle(ulong seed, bool auto, IReadOnlyDictionary<string, CardDefinition> catalog)
|
||||
{
|
||||
var state = MatchSetup.CreateStartingBattle(seed, catalog, shuffle: true);
|
||||
Console.WriteLine($"Card Roguelike CLI combat seed={seed} auto={auto}");
|
||||
Console.WriteLine("Commands: number = choose action, a = auto-step, q = quit");
|
||||
_ = PlayBattle(state, auto, playerIsHuman: !auto);
|
||||
}
|
||||
|
||||
static void RunFull(
|
||||
ulong seed,
|
||||
bool auto,
|
||||
int fightCount,
|
||||
IReadOnlyDictionary<string, CardDefinition> catalog,
|
||||
IReadOnlyList<PackDefinition> packs)
|
||||
{
|
||||
var run = RunProgress.Start(seed, catalog);
|
||||
Console.WriteLine($"Card Roguelike CLI full run seed={seed} fights={fightCount} auto={auto}");
|
||||
Console.WriteLine("Between fights: reward draft → Holder → simple upgrade → next fight.");
|
||||
Console.WriteLine($"Starting owned={run.Owned.Count}");
|
||||
|
||||
for (var i = 0; i < fightCount; i++)
|
||||
{
|
||||
run.FightIndex = i;
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"===== Fight {i + 1}/{fightCount} owned={run.Owned.Count} heal={run.Stats.HealReceived} atk={run.Stats.AttacksDeclared} =====");
|
||||
var state = MatchSetup.CreateFromRun(run, catalog, shuffle: true);
|
||||
state = PlayBattle(state, auto, playerIsHuman: !auto);
|
||||
if (!state.IsOver)
|
||||
{
|
||||
Console.WriteLine("Battle stopped early; run aborted.");
|
||||
return;
|
||||
}
|
||||
|
||||
run.SyncStatsFrom(state, 0);
|
||||
if (state.WinnerIndex != 0)
|
||||
{
|
||||
Console.WriteLine($"Defeat on fight {i + 1}. Wins={run.Wins}");
|
||||
return;
|
||||
}
|
||||
|
||||
run.Wins++;
|
||||
Console.WriteLine($"Victory. Wins={run.Wins}");
|
||||
if (i + 1 >= fightCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
OfferReward(run, state, catalog, packs, auto);
|
||||
OfferUpgrade(run, catalog, auto);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Run complete. Wins={run.Wins}/{fightCount} owned={run.Owned.Count}");
|
||||
}
|
||||
|
||||
static GameState PlayBattle(GameState state, bool forceAuto, bool playerIsHuman)
|
||||
{
|
||||
PrintState(state);
|
||||
var steps = 0;
|
||||
const int maxActions = 200;
|
||||
while (!state.IsOver && steps < maxActions)
|
||||
{
|
||||
var legal = RulesEngine.GetLegalActions(state);
|
||||
if (legal.IsDefaultOrEmpty)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
GameAction action;
|
||||
var autoNow = forceAuto || state.ActivePlayerIndex == 1 || !playerIsHuman;
|
||||
if (autoNow)
|
||||
{
|
||||
action = TurnBeamSearch.ChooseWithTrace(state, legal).Action;
|
||||
Console.WriteLine($">> P{state.ActivePlayerIndex} {Describe(state, action)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
PrintActions(state, legal);
|
||||
Console.Write("P0> ");
|
||||
var line = Console.ReadLine();
|
||||
if (line is null || line.Equals("q", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.Equals("a", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
action = TurnBeamSearch.ChooseWithTrace(state, legal).Action;
|
||||
Console.WriteLine($">> P0 {Describe(state, action)}");
|
||||
}
|
||||
else if (int.TryParse(line, out var index) && index >= 1 && index <= legal.Length)
|
||||
{
|
||||
action = legal[index - 1];
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Invalid input.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
state = RulesEngine.Execute(state, action);
|
||||
steps++;
|
||||
PrintState(state);
|
||||
}
|
||||
|
||||
if (state.IsOver)
|
||||
{
|
||||
Console.WriteLine($"Winner=P{state.WinnerIndex} turns={state.TurnNumber} P0={state.Players[0].Life} P1={state.Players[1].Life}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Stopped turns={state.TurnNumber} P0={state.Players[0].Life} P1={state.Players[1].Life}");
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
static void OfferReward(
|
||||
RunProgress run,
|
||||
GameState battle,
|
||||
IReadOnlyDictionary<string, CardDefinition> catalog,
|
||||
IReadOnlyList<PackDefinition> packs,
|
||||
bool auto)
|
||||
{
|
||||
var pool = RunRewards.ActivePool(battle, 0, packs);
|
||||
var offer = RunRewards.RollOffer(run.Rng, pool, RunProgress.RewardChoices);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Reward pool size={pool.Count} unlocked packs={Packs.Unlocked(battle, 0, packs).Count}");
|
||||
if (offer.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No reward cards available.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < offer.Count; i++)
|
||||
{
|
||||
var id = offer[i];
|
||||
var name = catalog.TryGetValue(id, out var def) ? def.Name : id;
|
||||
Console.WriteLine($" {i + 1}. {name} ({id})");
|
||||
}
|
||||
|
||||
Console.WriteLine(" 0. Skip reward");
|
||||
|
||||
var pick = auto ? 1 : ReadIndex("Reward>", 0, offer.Count);
|
||||
if (pick <= 0)
|
||||
{
|
||||
Console.WriteLine("Skipped reward.");
|
||||
return;
|
||||
}
|
||||
|
||||
var cardId = offer[pick - 1];
|
||||
if (!catalog.TryGetValue(cardId, out var chosen))
|
||||
{
|
||||
Console.WriteLine($"Missing card '{cardId}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.Owned.Count >= RunProgress.MaxOwned)
|
||||
{
|
||||
Console.WriteLine($"Owned at max ({RunProgress.MaxOwned}). Choose a card to remove:");
|
||||
PrintOwned(run, catalog);
|
||||
var remove = auto ? 0 : ReadIndex("Remove>", 0, run.Owned.Count - 1);
|
||||
if (auto)
|
||||
{
|
||||
remove = run.Owned.Count - 1;
|
||||
}
|
||||
|
||||
RunRewards.RemoveAt(run, remove);
|
||||
Console.WriteLine($"Removed index {remove}.");
|
||||
}
|
||||
|
||||
var holders = RunRewards.HolderChoices(cardId);
|
||||
string holder;
|
||||
if (holders.Count == 1)
|
||||
{
|
||||
holder = holders[0];
|
||||
}
|
||||
else if (auto)
|
||||
{
|
||||
holder = holders[0];
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Assign Holder:");
|
||||
for (var i = 0; i < holders.Count; i++)
|
||||
{
|
||||
Console.WriteLine($" {i + 1}. {holders[i]}");
|
||||
}
|
||||
|
||||
var h = ReadIndex("Holder>", 1, holders.Count);
|
||||
holder = holders[h - 1];
|
||||
}
|
||||
|
||||
RunRewards.AddCard(run, chosen, holder);
|
||||
Console.WriteLine($"Added {chosen.Name} holder={holder.Split('.').Last()} owned={run.Owned.Count}");
|
||||
}
|
||||
|
||||
static void OfferUpgrade(RunProgress run, IReadOnlyDictionary<string, CardDefinition> catalog, bool auto)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Upgrade one owned card (or skip):");
|
||||
PrintOwned(run, catalog);
|
||||
Console.WriteLine(" -1. Skip upgrade");
|
||||
|
||||
int cardIndex;
|
||||
if (auto)
|
||||
{
|
||||
cardIndex = -1;
|
||||
for (var i = 0; i < run.Owned.Count; i++)
|
||||
{
|
||||
var o = run.Owned[i];
|
||||
if (!catalog.TryGetValue(o.DefinitionId, out var d))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RunRewards.LegalUpgrades(o, d).Any(u => u != SimpleUpgrade.Skip))
|
||||
{
|
||||
cardIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cardIndex = ReadIndex("Card# (-1 skip)>", -1, run.Owned.Count - 1);
|
||||
}
|
||||
|
||||
if (cardIndex < 0)
|
||||
{
|
||||
Console.WriteLine(auto ? "Auto: skipped upgrade." : "Skipped upgrade.");
|
||||
return;
|
||||
}
|
||||
|
||||
var card = run.Owned[cardIndex];
|
||||
if (!catalog.TryGetValue(card.DefinitionId, out var def))
|
||||
{
|
||||
Console.WriteLine("Missing definition.");
|
||||
return;
|
||||
}
|
||||
|
||||
var legal = RunRewards.LegalUpgrades(card, def);
|
||||
for (var i = 0; i < legal.Count; i++)
|
||||
{
|
||||
Console.WriteLine($" {i + 1}. {legal[i]}");
|
||||
}
|
||||
|
||||
int upIndex;
|
||||
if (auto)
|
||||
{
|
||||
upIndex = 0;
|
||||
for (var i = 0; i < legal.Count; i++)
|
||||
{
|
||||
if (legal[i] != SimpleUpgrade.Skip)
|
||||
{
|
||||
upIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
upIndex = ReadIndex("Upgrade>", 1, legal.Count) - 1;
|
||||
}
|
||||
|
||||
var upgrade = legal[upIndex];
|
||||
RunRewards.ApplyUpgrade(card, def, upgrade);
|
||||
Console.WriteLine($"Applied {upgrade} → {DescribeOwned(card, def)}");
|
||||
}
|
||||
|
||||
static void PrintOwned(RunProgress run, IReadOnlyDictionary<string, CardDefinition> catalog)
|
||||
{
|
||||
for (var i = 0; i < run.Owned.Count; i++)
|
||||
{
|
||||
var o = run.Owned[i];
|
||||
catalog.TryGetValue(o.DefinitionId, out var def);
|
||||
Console.WriteLine($" {i}. {DescribeOwned(o, def)}");
|
||||
}
|
||||
}
|
||||
|
||||
static string DescribeOwned(OwnedCard o, CardDefinition? def)
|
||||
{
|
||||
var name = def?.Name ?? o.DefinitionId;
|
||||
var body = def?.Type == CardType.Creature || def?.Type == CardType.FieldCard
|
||||
? $" {o.Attack}/{o.Health}"
|
||||
: "";
|
||||
return $"{name}({o.Cost}){body}[{o.HolderId.Split('.').Last()}]";
|
||||
}
|
||||
|
||||
static int ReadIndex(string prompt, int min, int max)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Console.Write(prompt + " ");
|
||||
var line = Console.ReadLine();
|
||||
if (line is not null && int.TryParse(line, out var n) && n >= min && n <= max)
|
||||
{
|
||||
return n;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Enter {min}..{max}");
|
||||
}
|
||||
}
|
||||
|
||||
static string ResolveContent(string folder)
|
||||
{
|
||||
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "content", folder));
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", folder));
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
static ulong ParseSeed(string[] args)
|
||||
{
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i] is "--seed" or "-s" && ulong.TryParse(args[i + 1], out var seed))
|
||||
{
|
||||
return seed;
|
||||
}
|
||||
}
|
||||
|
||||
return 20261006UL;
|
||||
}
|
||||
|
||||
static int ParseFights(string[] args)
|
||||
{
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i] is "--fights" or "-f" && int.TryParse(args[i + 1], out var n) && n > 0)
|
||||
{
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
return RunProgress.DefaultFightCount;
|
||||
}
|
||||
|
||||
static void PrintState(GameState state)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(
|
||||
$"--- turn {state.TurnNumber} active=P{state.ActivePlayerIndex} P0={state.Players[0].Life}hp/{state.Players[0].Energy}e P1={state.Players[1].Life}hp/{state.Players[1].Energy}e ---");
|
||||
PrintZone("P0 hand", state.Players[0].Hand, state);
|
||||
PrintZone("P1 hand", state.Players[1].Hand, state);
|
||||
Console.WriteLine("Board:");
|
||||
foreach (var e in state.Board)
|
||||
{
|
||||
Console.WriteLine(" " + DescribeEntity(state, e));
|
||||
}
|
||||
|
||||
Console.WriteLine($"P0 deck={state.Players[0].Deck.Count} gy={state.Players[0].Graveyard.Count} | P1 deck={state.Players[1].Deck.Count} gy={state.Players[1].Graveyard.Count}");
|
||||
}
|
||||
|
||||
static void PrintZone(string title, List<Entity> cards, GameState state)
|
||||
{
|
||||
Console.WriteLine(title + ": " + string.Join(", ", cards.Select(c => ShortCard(state, c))));
|
||||
}
|
||||
|
||||
static void PrintActions(GameState state, ImmutableArray<GameAction> legal)
|
||||
{
|
||||
Console.WriteLine("Legal:");
|
||||
for (var i = 0; i < legal.Length; i++)
|
||||
{
|
||||
Console.WriteLine($" {i + 1}. {Describe(state, legal[i])}");
|
||||
}
|
||||
}
|
||||
|
||||
static string Describe(GameState state, GameAction action) =>
|
||||
action switch
|
||||
{
|
||||
PlayCardAction play =>
|
||||
$"Play {ShortCard(state, state.Find(play.CardId)!)}"
|
||||
+ (play.Target is null ? "" : $" -> {DescribeTarget(state, play.Target)}")
|
||||
+ (play.Target2 is null ? "" : $" & {DescribeTarget(state, play.Target2)}"),
|
||||
AttackAction attack =>
|
||||
$"Attack {ShortCard(state, state.Find(attack.AttackerId)!)} -> {DescribeTarget(state, attack.Target)}",
|
||||
ActivateAction act =>
|
||||
$"Activate {ShortCard(state, state.Find(act.SourceId)!)}"
|
||||
+ (act.Target is null ? "" : $" -> {DescribeTarget(state, act.Target)}"),
|
||||
EndTurnAction => "EndTurn",
|
||||
_ => action.ToString() ?? "action"
|
||||
};
|
||||
|
||||
static string DescribeTarget(GameState state, TargetRef target) =>
|
||||
target switch
|
||||
{
|
||||
PlayerTarget p => $"P{p.PlayerIndex}",
|
||||
EntityTarget e => ShortCard(state, state.Find(e.Id)!),
|
||||
_ => "?"
|
||||
};
|
||||
|
||||
static string ShortCard(GameState state, Entity e)
|
||||
{
|
||||
var atk = e.Definition.Type == CardType.Creature ? state.EffectiveAttack(e) : e.Attack;
|
||||
return e.Definition.Type switch
|
||||
{
|
||||
CardType.Creature => $"{e.Id}:{e.Definition.Name}({e.Cost}) {atk}/{e.Health}[{e.HolderId.Split('.').Last()}]",
|
||||
CardType.FieldCard => $"{e.Id}:{e.Definition.Name} CD{e.Countdown}{(e.IsSystem ? "*" : "")}",
|
||||
_ => $"{e.Id}:{e.Definition.Name}({e.Cost})"
|
||||
};
|
||||
}
|
||||
|
||||
static string DescribeEntity(GameState state, Entity e) =>
|
||||
$"P{e.ControllerIndex} {ShortCard(state, e)} atkLeft={e.AttacksRemaining} actLeft={e.ActivatesRemaining}";
|
||||
Reference in New Issue
Block a user