diff --git a/content/cards/card.cult.believer.json b/content/cards/card.cult.believer.json new file mode 100644 index 0000000..23f58ca --- /dev/null +++ b/content/cards/card.cult.believer.json @@ -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 +} diff --git a/content/cards/card.cult.camp.json b/content/cards/card.cult.camp.json new file mode 100644 index 0000000..377d4f7 --- /dev/null +++ b/content/cards/card.cult.camp.json @@ -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" }] } + ] +} diff --git a/content/cards/card.cult.civilian.json b/content/cards/card.cult.civilian.json new file mode 100644 index 0000000..f177870 --- /dev/null +++ b/content/cards/card.cult.civilian.json @@ -0,0 +1,10 @@ +{ + "id": "card.cult.civilian", + "name": "Civilian", + "type": "Creature", + "cost": 0, + "attack": 0, + "health": 1, + "tags": ["creature", "enter", "death"], + "effects": [] +} diff --git a/content/cards/card.cult.mine.json b/content/cards/card.cult.mine.json new file mode 100644 index 0000000..5f540d8 --- /dev/null +++ b/content/cards/card.cult.mine.json @@ -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 } + ] + } + ] +} diff --git a/content/cards/card.cult.vilefire.json b/content/cards/card.cult.vilefire.json new file mode 100644 index 0000000..c4f96d2 --- /dev/null +++ b/content/cards/card.cult.vilefire.json @@ -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 +} diff --git a/content/cards/card.example.fuse.json b/content/cards/card.example.fuse.json new file mode 100644 index 0000000..6eb3e77 --- /dev/null +++ b/content/cards/card.example.fuse.json @@ -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" }] + } + ] +} diff --git a/content/cards/card.example.lantern.json b/content/cards/card.example.lantern.json new file mode 100644 index 0000000..25f076a --- /dev/null +++ b/content/cards/card.example.lantern.json @@ -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 }] + } + ] +} diff --git a/content/cards/card.example.salve.json b/content/cards/card.example.salve.json new file mode 100644 index 0000000..4857328 --- /dev/null +++ b/content/cards/card.example.salve.json @@ -0,0 +1,10 @@ +{ + "id": "card.example.salve", + "name": "Salve", + "type": "Spell", + "cost": 1, + "tags": ["heal"], + "effects": [ + { "op": "Heal", "amount": 2, "target": "SelfPlayer" } + ] +} diff --git a/content/cards/card.example.spark.json b/content/cards/card.example.spark.json new file mode 100644 index 0000000..db752f1 --- /dev/null +++ b/content/cards/card.example.spark.json @@ -0,0 +1,10 @@ +{ + "id": "card.example.spark", + "name": "Spark", + "type": "Spell", + "cost": 1, + "tags": ["damage"], + "effects": [ + { "op": "Damage", "amount": 1, "target": "ChosenEnemy" } + ] +} diff --git a/content/cards/card.example.ward.json b/content/cards/card.example.ward.json new file mode 100644 index 0000000..d8b24d2 --- /dev/null +++ b/content/cards/card.example.ward.json @@ -0,0 +1,9 @@ +{ + "id": "card.example.ward", + "name": "Ward", + "type": "Field", + "cost": 1, + "countdown": 0, + "tags": ["field"], + "effects": [] +} diff --git a/content/cards/card.example.wisp.json b/content/cards/card.example.wisp.json new file mode 100644 index 0000000..0175149 --- /dev/null +++ b/content/cards/card.example.wisp.json @@ -0,0 +1,10 @@ +{ + "id": "card.example.wisp", + "name": "Wisp", + "type": "Creature", + "cost": 1, + "attack": 1, + "health": 1, + "tags": ["creature"], + "effects": [] +} diff --git a/content/cards/card.guard.captain.json b/content/cards/card.guard.captain.json new file mode 100644 index 0000000..2cfad52 --- /dev/null +++ b/content/cards/card.guard.captain.json @@ -0,0 +1,10 @@ +{ + "id": "card.guard.captain", + "name": "Captain", + "type": "Creature", + "cost": 3, + "attack": 3, + "health": 4, + "tags": ["creature"], + "effects": [] +} diff --git a/content/cards/card.guard.conscript.json b/content/cards/card.guard.conscript.json new file mode 100644 index 0000000..407d9b9 --- /dev/null +++ b/content/cards/card.guard.conscript.json @@ -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 }] +} diff --git a/content/cards/card.guard.crossbow.json b/content/cards/card.guard.crossbow.json new file mode 100644 index 0000000..75eff57 --- /dev/null +++ b/content/cards/card.guard.crossbow.json @@ -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 +} diff --git a/content/cards/card.guard.farm.json b/content/cards/card.guard.farm.json new file mode 100644 index 0000000..ce02986 --- /dev/null +++ b/content/cards/card.guard.farm.json @@ -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" }] } + ] +} diff --git a/content/cards/card.guard.militia.json b/content/cards/card.guard.militia.json new file mode 100644 index 0000000..f06da82 --- /dev/null +++ b/content/cards/card.guard.militia.json @@ -0,0 +1,10 @@ +{ + "id": "card.guard.militia", + "name": "Militia", + "type": "Creature", + "cost": 1, + "attack": 1, + "health": 1, + "tags": ["creature", "enter", "death"], + "effects": [] +} diff --git a/content/cards/card.guard.orders.json b/content/cards/card.guard.orders.json new file mode 100644 index 0000000..842825c --- /dev/null +++ b/content/cards/card.guard.orders.json @@ -0,0 +1,8 @@ +{ + "id": "card.guard.orders", + "name": "Orders", + "type": "Spell", + "cost": 2, + "tags": ["creature"], + "effects": [{ "op": "BuffAllFriendly", "amount": 1, "amount2": 1 }] +} diff --git a/content/cards/card.guard.patrol.json b/content/cards/card.guard.patrol.json new file mode 100644 index 0000000..3388b75 --- /dev/null +++ b/content/cards/card.guard.patrol.json @@ -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" }] +} diff --git a/content/cards/card.guard.ration.json b/content/cards/card.guard.ration.json new file mode 100644 index 0000000..939d72a --- /dev/null +++ b/content/cards/card.guard.ration.json @@ -0,0 +1,8 @@ +{ + "id": "card.guard.ration", + "name": "Ration Order", + "type": "Spell", + "cost": 3, + "tags": ["draw"], + "effects": [{ "op": "Draw", "amount": 2 }] +} diff --git a/content/cards/card.system.base_field.json b/content/cards/card.system.base_field.json new file mode 100644 index 0000000..a625b28 --- /dev/null +++ b/content/cards/card.system.base_field.json @@ -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 }] } + ] +} diff --git a/content/characters/char.example.ash.json b/content/characters/char.example.ash.json new file mode 100644 index 0000000..32cf8e5 --- /dev/null +++ b/content/characters/char.example.ash.json @@ -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" + ] +} diff --git a/content/encounters/.gitkeep b/content/encounters/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/content/enemies/.gitkeep b/content/enemies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/content/packs/pack.example.aftermath.json b/content/packs/pack.example.aftermath.json new file mode 100644 index 0000000..40a0738 --- /dev/null +++ b/content/packs/pack.example.aftermath.json @@ -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 } + ] +} diff --git a/content/packs/pack.example.base.json b/content/packs/pack.example.base.json new file mode 100644 index 0000000..8923b43 --- /dev/null +++ b/content/packs/pack.example.base.json @@ -0,0 +1,8 @@ +{ + "id": "pack.example.base", + "name": "Common Stock", + "base": true, + "coreActions": [], + "cards": ["card.example.spark", "card.example.wisp"], + "allOf": [] +} diff --git a/content/packs/pack.example.vitality.json b/content/packs/pack.example.vitality.json new file mode 100644 index 0000000..faaca27 --- /dev/null +++ b/content/packs/pack.example.vitality.json @@ -0,0 +1,10 @@ +{ + "id": "pack.example.vitality", + "name": "Vitality", + "base": false, + "coreActions": ["Heal"], + "cards": ["card.example.salve"], + "allOf": [ + { "kind": "HealReceivedAtLeast", "amount": 2 } + ] +} diff --git a/schemas/card.json b/schemas/card.json new file mode 100644 index 0000000..ff88afc --- /dev/null +++ b/schemas/card.json @@ -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" } + } + } + } +} diff --git a/src/Game.Core/Apply.cs b/src/Game.Core/Apply.cs new file mode 100644 index 0000000..adc79f4 --- /dev/null +++ b/src/Game.Core/Apply.cs @@ -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); + } +} diff --git a/src/Game.Core/CardJson.cs b/src/Game.Core/CardJson.cs new file mode 100644 index 0000000..88e2075 --- /dev/null +++ b/src/Game.Core/CardJson.cs @@ -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(json, Options) + ?? throw new InvalidOperationException("Card JSON was empty."); + return dto.ToDefinition(); + } + + public static List LoadDirectory(string directory) + { + var cards = new List(); + 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 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()).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()).Select(e => e.ToEffect()).ToImmutableArray(), + tags, + Attack, + Health, + Countdown, + (Triggers ?? Array.Empty()).Select(t => t.ToTrigger()).ToImmutableArray(), + (Activate ?? Array.Empty()).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(When, ignoreCase: true, out var when)) + { + throw new InvalidOperationException($"Unknown trigger '{When}'."); + } + + return new Trigger(when, (Effects ?? Array.Empty()).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(Op, ignoreCase: true, out var op)) + { + throw new InvalidOperationException($"Unknown effect op '{Op}'."); + } + + var target = string.IsNullOrWhiteSpace(Target) + ? EffectTarget.None + : Enum.Parse(Target, ignoreCase: true); + return new Effect(op, Amount, target, CardId, Amount2); + } + } +} diff --git a/src/Game.Core/CustomEffects.cs b/src/Game.Core/CustomEffects.cs new file mode 100644 index 0000000..c1e2c37 --- /dev/null +++ b/src/Game.Core/CustomEffects.cs @@ -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); + } +} diff --git a/src/Game.Core/Game.Core.csproj b/src/Game.Core/Game.Core.csproj new file mode 100644 index 0000000..2301305 --- /dev/null +++ b/src/Game.Core/Game.Core.csproj @@ -0,0 +1,7 @@ + + + net8.0 + CardGame.Core + Game.Core + + diff --git a/src/Game.Core/MatchSetup.cs b/src/Game.Core/MatchSetup.cs new file mode 100644 index 0000000..8eaaca5 --- /dev/null +++ b/src/Game.Core/MatchSetup.cs @@ -0,0 +1,219 @@ +namespace CardGame.Core; + +public static class MatchSetup +{ + public static GameState Create( + ulong seed, + IReadOnlyList player0Deck, + IReadOnlyList player1Deck, + IReadOnlyDictionary? 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 catalog, + bool shuffle = true) + { + var deck0 = StartingPackage.BuildSharedDeck(catalog); + var deck1 = StartingPackage.BuildSharedDeck(catalog); + return Create(seed, deck0, deck1, catalog, shuffle, placeGuaranteedField: true); + } + + /// + /// Build a fight from run-owned cards for P0; P1 uses the shared starting package. + /// Carries into P0. Battle seed comes from . + /// + public static GameState CreateFromRun( + RunProgress run, + IReadOnlyDictionary 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 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 owned, + IReadOnlyDictionary 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 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 BuildCatalog(IEnumerable cards) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var card in cards) + { + map[card.Id] = card; + } + + return map; + } +} diff --git a/src/Game.Core/Model.cs b/src/Game.Core/Model.cs new file mode 100644 index 0000000..fb945f9 --- /dev/null +++ b/src/Game.Core/Model.cs @@ -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 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 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 Effects, + ImmutableArray Tags, + int Attack = 0, + int Health = 0, + int Countdown = 0, + ImmutableArray Triggers = default, + ImmutableArray ActivateEffects = default, + bool ActivateNeedsFriendly = false, + bool ActivateNeedsEnemy = false, + bool PlayNeedsFriendly = false, + bool PlayNeedsEnemy = false, + bool PlayNeedsEnemyPlayerOrCreature = false) +{ + public ImmutableArray TriggerList => + Triggers.IsDefaultOrEmpty ? ImmutableArray.Empty : Triggers; + + public ImmutableArray ActivateList => + ActivateEffects.IsDefaultOrEmpty ? ImmutableArray.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 Deck { get; } = new(); + public List Hand { get; } = new(); + public List 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 Players { get; } = new(); + public List Board { get; } = new(); + public List EventLog { get; } = new(); + public int NextEntityId { get; set; } = 1; + public ResponseWindow? OpenResponse { get; set; } + public int? WinnerIndex { get; set; } + public IReadOnlyDictionary Catalog { get; set; } = + new Dictionary(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; + } +} diff --git a/src/Game.Core/PackJson.cs b/src/Game.Core/PackJson.cs new file mode 100644 index 0000000..3276e9d --- /dev/null +++ b/src/Game.Core/PackJson.cs @@ -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(json, Options) + ?? throw new InvalidOperationException("Pack JSON was empty."); + return dto.ToDefinition(); + } + + public static List LoadDirectory(string directory) + { + if (!Directory.Exists(directory)) + { + return new List(); + } + + 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()).ToImmutableArray(), + (Cards ?? Array.Empty()).ToImmutableArray(), + (AllOf ?? Array.Empty()).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(Kind, ignoreCase: true, out var kind)) + { + throw new InvalidOperationException($"Unknown pack condition '{Kind}'."); + } + + return new PackCondition(kind, Amount, Tag); + } + } +} diff --git a/src/Game.Core/Packs.cs b/src/Game.Core/Packs.cs new file mode 100644 index 0000000..e880708 --- /dev/null +++ b/src/Game.Core/Packs.cs @@ -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 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 CoreActions, + ImmutableArray Cards, + ImmutableArray 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 Unlocked(GameState state, int playerIndex, IEnumerable 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 + }; + } +} diff --git a/src/Game.Core/RulesEngine.cs b/src/Game.Core/RulesEngine.cs new file mode 100644 index 0000000..e039f5e --- /dev/null +++ b/src/Game.Core/RulesEngine.cs @@ -0,0 +1,269 @@ +using System.Collections.Immutable; + +namespace CardGame.Core; + +public static class RulesEngine +{ + public static ImmutableArray GetLegalActions(GameState state) + { + if (state.IsOver || state.OpenResponse is { IsOpen: true }) + { + return ImmutableArray.Empty; + } + + var player = state.ActivePlayer; + var actions = new List(); + + 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 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 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); + } +} diff --git a/src/Game.Core/RunProgress.cs b/src/Game.Core/RunProgress.cs new file mode 100644 index 0000000..8d80937 --- /dev/null +++ b/src/Game.Core/RunProgress.cs @@ -0,0 +1,204 @@ +namespace CardGame.Core; + +/// Persistent card copy for a run (between fights). +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 +} + +/// In-run progression: owned cards, live pack stats, fight index. Resets when the run ends. +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 Owned { get; } = new(); + public int FightIndex { get; set; } + public int Wins { get; set; } + + public static RunProgress Start(ulong seed, IReadOnlyDictionary 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 ActivePool( + GameState state, + int playerIndex, + IEnumerable packs) => + Packs.Unlocked(state, playerIndex, packs) + .SelectMany(p => p.Cards) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList(); + + public static List RollOffer(GameRng rng, IReadOnlyList pool, int count) + { + if (pool.Count == 0 || count <= 0) + { + return new List(); + } + + var bag = pool.ToList(); + var offer = new List(); + 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 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 LegalUpgrades(OwnedCard card, CardDefinition def) + { + var list = new List { 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; + } + } +} diff --git a/src/Game.Core/SimpleAutoPlayer.cs b/src/Game.Core/SimpleAutoPlayer.cs new file mode 100644 index 0000000..2866a6a --- /dev/null +++ b/src/Game.Core/SimpleAutoPlayer.cs @@ -0,0 +1,55 @@ +using System.Collections.Immutable; + +namespace CardGame.Core; + +public static class SimpleAutoPlayer +{ + public static GameAction Choose(GameState state, ImmutableArray legal) + { + var face = legal.OfType().FirstOrDefault(a => a.Target is PlayerTarget); + if (face is not null) + { + return face; + } + + var activate = legal.OfType().FirstOrDefault(); + if (activate is not null && state.Rng.NextInt(100) < 35) + { + return activate; + } + + var play = legal.OfType() + .OrderBy(p => state.Find(p.CardId)!.Cost) + .FirstOrDefault(); + if (play is not null) + { + return play; + } + + var anyAttack = legal.OfType().FirstOrDefault(); + if (anyAttack is not null) + { + return anyAttack; + } + + return legal.OfType().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; + } +} diff --git a/src/Game.Core/StartingPackage.cs b/src/Game.Core/StartingPackage.cs new file mode 100644 index 0000000..6cb5804 --- /dev/null +++ b/src/Game.Core/StartingPackage.cs @@ -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 BuildSharedDeck(IReadOnlyDictionary catalog) + { + var deck = new List(); + 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; + } +} diff --git a/src/Game.Core/TurnBeamSearch.cs b/src/Game.Core/TurnBeamSearch.cs new file mode 100644 index 0000000..71fb7fe --- /dev/null +++ b/src/Game.Core/TurnBeamSearch.cs @@ -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 TopCandidates); + +public sealed record AiTurnTrace(IReadOnlyList 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 Top) ChooseWithTrace( + GameState state, + ImmutableArray 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(); + 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), + _ => "" + }; +} diff --git a/tests/Game.Core.Tests/AiSearchTests.cs b/tests/Game.Core.Tests/AiSearchTests.cs new file mode 100644 index 0000000..a6e5ec1 --- /dev/null +++ b/tests/Game.Core.Tests/AiSearchTests.cs @@ -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(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); + } +} diff --git a/tests/Game.Core.Tests/Cards.cs b/tests/Game.Core.Tests/Cards.cs new file mode 100644 index 0000000..46df45f --- /dev/null +++ b/tests/Game.Core.Tests/Cards.cs @@ -0,0 +1,30 @@ +using CardGame.Core; + +namespace Game.Core.Tests; + +internal static class Cards +{ + private static readonly Dictionary ById = CardJson + .LoadCatalog(Path.Combine(AppContext.BaseDirectory, "content", "cards")); + + public static IReadOnlyDictionary 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 Repeat(CardDefinition card, int count) => + Enumerable.Repeat(card, count).ToList(); +} diff --git a/tests/Game.Core.Tests/Game.Core.Tests.csproj b/tests/Game.Core.Tests/Game.Core.Tests.csproj new file mode 100644 index 0000000..12ee742 --- /dev/null +++ b/tests/Game.Core.Tests/Game.Core.Tests.csproj @@ -0,0 +1,21 @@ + + + net8.0 + false + Game.Core.Tests + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/Game.Core.Tests/PackTests.cs b/tests/Game.Core.Tests/PackTests.cs new file mode 100644 index 0000000..689dcf4 --- /dev/null +++ b/tests/Game.Core.Tests/PackTests.cs @@ -0,0 +1,57 @@ +using CardGame.Core; +using Xunit; + +namespace Game.Core.Tests; + +public sealed class PackTests +{ + private static List 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"); + } +} diff --git a/tests/Game.Core.Tests/PhaseATests.cs b/tests/Game.Core.Tests/PhaseATests.cs new file mode 100644 index 0000000..b3b88a8 --- /dev/null +++ b/tests/Game.Core.Tests/PhaseATests.cs @@ -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); + } +} diff --git a/tests/Game.Core.Tests/PhaseBTests.cs b/tests/Game.Core.Tests/PhaseBTests.cs new file mode 100644 index 0000000..c62a321 --- /dev/null +++ b/tests/Game.Core.Tests/PhaseBTests.cs @@ -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().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); + } +} diff --git a/tests/Game.Core.Tests/PhaseCTests.cs b/tests/Game.Core.Tests/PhaseCTests.cs new file mode 100644 index 0000000..b9b9221 --- /dev/null +++ b/tests/Game.Core.Tests/PhaseCTests.cs @@ -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); + } +} diff --git a/tests/Game.Core.Tests/RunTests.cs b/tests/Game.Core.Tests/RunTests.cs new file mode 100644 index 0000000..409dd56 --- /dev/null +++ b/tests/Game.Core.Tests/RunTests.cs @@ -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); + } +} diff --git a/tests/Game.Core.Tests/StartingCombatTests.cs b/tests/Game.Core.Tests/StartingCombatTests.cs new file mode 100644 index 0000000..12153e1 --- /dev/null +++ b/tests/Game.Core.Tests/StartingCombatTests.cs @@ -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 { 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 { 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)); + } +} diff --git a/tools/HeadlessBattle/HeadlessBattle.csproj b/tools/HeadlessBattle/HeadlessBattle.csproj new file mode 100644 index 0000000..49ce89d --- /dev/null +++ b/tools/HeadlessBattle/HeadlessBattle.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + HeadlessBattle + + + + + + + + diff --git a/tools/HeadlessBattle/Program.cs b/tools/HeadlessBattle/Program.cs new file mode 100644 index 0000000..cee7c0a --- /dev/null +++ b/tools/HeadlessBattle/Program.cs @@ -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 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 catalog, + IReadOnlyList 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 catalog, + IReadOnlyList 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 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 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 cards, GameState state) +{ + Console.WriteLine(title + ": " + string.Join(", ", cards.Select(c => ShortCard(state, c)))); +} + +static void PrintActions(GameState state, ImmutableArray 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}";