Files
card_game/tools/HeadlessBattle/Program.cs
T
chenmoranzhiandCursor ae13f1d06a 落地无头规则引擎与局内内容,便于命令行复现对战。
规则、卡包与测试放在同一套核心里,避免表现层自行结算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 02:06:23 +08:00

431 lines
13 KiB
C#

using System.Collections.Immutable;
using CardGame.Core;
var cardsDir = ResolveContent("cards");
var packsDir = ResolveContent("packs");
var catalog = CardJson.LoadCatalog(cardsDir);
var packs = PackJson.LoadDirectory(packsDir);
var seed = ParseSeed(args);
var auto = args.Any(a => a is "--auto" or "-a");
var runMode = args.Any(a => a is "--run" or "-r");
var fights = ParseFights(args);
if (runMode)
{
RunFull(seed, auto, fights, catalog, packs);
}
else
{
RunSingleBattle(seed, auto, catalog);
}
static void RunSingleBattle(ulong seed, bool auto, IReadOnlyDictionary<string, CardDefinition> catalog)
{
var state = MatchSetup.CreateStartingBattle(seed, catalog, shuffle: true);
Console.WriteLine($"Card Roguelike CLI combat seed={seed} auto={auto}");
Console.WriteLine("Commands: number = choose action, a = auto-step, q = quit");
_ = PlayBattle(state, auto, playerIsHuman: !auto);
}
static void RunFull(
ulong seed,
bool auto,
int fightCount,
IReadOnlyDictionary<string, CardDefinition> catalog,
IReadOnlyList<PackDefinition> packs)
{
var run = RunProgress.Start(seed, catalog);
Console.WriteLine($"Card Roguelike CLI full run seed={seed} fights={fightCount} auto={auto}");
Console.WriteLine("Between fights: reward draft → Holder → simple upgrade → next fight.");
Console.WriteLine($"Starting owned={run.Owned.Count}");
for (var i = 0; i < fightCount; i++)
{
run.FightIndex = i;
Console.WriteLine();
Console.WriteLine($"===== Fight {i + 1}/{fightCount} owned={run.Owned.Count} heal={run.Stats.HealReceived} atk={run.Stats.AttacksDeclared} =====");
var state = MatchSetup.CreateFromRun(run, catalog, shuffle: true);
state = PlayBattle(state, auto, playerIsHuman: !auto);
if (!state.IsOver)
{
Console.WriteLine("Battle stopped early; run aborted.");
return;
}
run.SyncStatsFrom(state, 0);
if (state.WinnerIndex != 0)
{
Console.WriteLine($"Defeat on fight {i + 1}. Wins={run.Wins}");
return;
}
run.Wins++;
Console.WriteLine($"Victory. Wins={run.Wins}");
if (i + 1 >= fightCount)
{
break;
}
OfferReward(run, state, catalog, packs, auto);
OfferUpgrade(run, catalog, auto);
}
Console.WriteLine();
Console.WriteLine($"Run complete. Wins={run.Wins}/{fightCount} owned={run.Owned.Count}");
}
static GameState PlayBattle(GameState state, bool forceAuto, bool playerIsHuman)
{
PrintState(state);
var steps = 0;
const int maxActions = 200;
while (!state.IsOver && steps < maxActions)
{
var legal = RulesEngine.GetLegalActions(state);
if (legal.IsDefaultOrEmpty)
{
break;
}
GameAction action;
var autoNow = forceAuto || state.ActivePlayerIndex == 1 || !playerIsHuman;
if (autoNow)
{
action = TurnBeamSearch.ChooseWithTrace(state, legal).Action;
Console.WriteLine($">> P{state.ActivePlayerIndex} {Describe(state, action)}");
}
else
{
PrintActions(state, legal);
Console.Write("P0> ");
var line = Console.ReadLine();
if (line is null || line.Equals("q", StringComparison.OrdinalIgnoreCase))
{
break;
}
if (line.Equals("a", StringComparison.OrdinalIgnoreCase))
{
action = TurnBeamSearch.ChooseWithTrace(state, legal).Action;
Console.WriteLine($">> P0 {Describe(state, action)}");
}
else if (int.TryParse(line, out var index) && index >= 1 && index <= legal.Length)
{
action = legal[index - 1];
}
else
{
Console.WriteLine("Invalid input.");
continue;
}
}
state = RulesEngine.Execute(state, action);
steps++;
PrintState(state);
}
if (state.IsOver)
{
Console.WriteLine($"Winner=P{state.WinnerIndex} turns={state.TurnNumber} P0={state.Players[0].Life} P1={state.Players[1].Life}");
}
else
{
Console.WriteLine($"Stopped turns={state.TurnNumber} P0={state.Players[0].Life} P1={state.Players[1].Life}");
}
return state;
}
static void OfferReward(
RunProgress run,
GameState battle,
IReadOnlyDictionary<string, CardDefinition> catalog,
IReadOnlyList<PackDefinition> packs,
bool auto)
{
var pool = RunRewards.ActivePool(battle, 0, packs);
var offer = RunRewards.RollOffer(run.Rng, pool, RunProgress.RewardChoices);
Console.WriteLine();
Console.WriteLine($"Reward pool size={pool.Count} unlocked packs={Packs.Unlocked(battle, 0, packs).Count}");
if (offer.Count == 0)
{
Console.WriteLine("No reward cards available.");
return;
}
for (var i = 0; i < offer.Count; i++)
{
var id = offer[i];
var name = catalog.TryGetValue(id, out var def) ? def.Name : id;
Console.WriteLine($" {i + 1}. {name} ({id})");
}
Console.WriteLine(" 0. Skip reward");
var pick = auto ? 1 : ReadIndex("Reward>", 0, offer.Count);
if (pick <= 0)
{
Console.WriteLine("Skipped reward.");
return;
}
var cardId = offer[pick - 1];
if (!catalog.TryGetValue(cardId, out var chosen))
{
Console.WriteLine($"Missing card '{cardId}'.");
return;
}
if (run.Owned.Count >= RunProgress.MaxOwned)
{
Console.WriteLine($"Owned at max ({RunProgress.MaxOwned}). Choose a card to remove:");
PrintOwned(run, catalog);
var remove = auto ? 0 : ReadIndex("Remove>", 0, run.Owned.Count - 1);
if (auto)
{
remove = run.Owned.Count - 1;
}
RunRewards.RemoveAt(run, remove);
Console.WriteLine($"Removed index {remove}.");
}
var holders = RunRewards.HolderChoices(cardId);
string holder;
if (holders.Count == 1)
{
holder = holders[0];
}
else if (auto)
{
holder = holders[0];
}
else
{
Console.WriteLine("Assign Holder:");
for (var i = 0; i < holders.Count; i++)
{
Console.WriteLine($" {i + 1}. {holders[i]}");
}
var h = ReadIndex("Holder>", 1, holders.Count);
holder = holders[h - 1];
}
RunRewards.AddCard(run, chosen, holder);
Console.WriteLine($"Added {chosen.Name} holder={holder.Split('.').Last()} owned={run.Owned.Count}");
}
static void OfferUpgrade(RunProgress run, IReadOnlyDictionary<string, CardDefinition> catalog, bool auto)
{
Console.WriteLine();
Console.WriteLine("Upgrade one owned card (or skip):");
PrintOwned(run, catalog);
Console.WriteLine(" -1. Skip upgrade");
int cardIndex;
if (auto)
{
cardIndex = -1;
for (var i = 0; i < run.Owned.Count; i++)
{
var o = run.Owned[i];
if (!catalog.TryGetValue(o.DefinitionId, out var d))
{
continue;
}
if (RunRewards.LegalUpgrades(o, d).Any(u => u != SimpleUpgrade.Skip))
{
cardIndex = i;
break;
}
}
}
else
{
cardIndex = ReadIndex("Card# (-1 skip)>", -1, run.Owned.Count - 1);
}
if (cardIndex < 0)
{
Console.WriteLine(auto ? "Auto: skipped upgrade." : "Skipped upgrade.");
return;
}
var card = run.Owned[cardIndex];
if (!catalog.TryGetValue(card.DefinitionId, out var def))
{
Console.WriteLine("Missing definition.");
return;
}
var legal = RunRewards.LegalUpgrades(card, def);
for (var i = 0; i < legal.Count; i++)
{
Console.WriteLine($" {i + 1}. {legal[i]}");
}
int upIndex;
if (auto)
{
upIndex = 0;
for (var i = 0; i < legal.Count; i++)
{
if (legal[i] != SimpleUpgrade.Skip)
{
upIndex = i;
break;
}
}
}
else
{
upIndex = ReadIndex("Upgrade>", 1, legal.Count) - 1;
}
var upgrade = legal[upIndex];
RunRewards.ApplyUpgrade(card, def, upgrade);
Console.WriteLine($"Applied {upgrade} → {DescribeOwned(card, def)}");
}
static void PrintOwned(RunProgress run, IReadOnlyDictionary<string, CardDefinition> catalog)
{
for (var i = 0; i < run.Owned.Count; i++)
{
var o = run.Owned[i];
catalog.TryGetValue(o.DefinitionId, out var def);
Console.WriteLine($" {i}. {DescribeOwned(o, def)}");
}
}
static string DescribeOwned(OwnedCard o, CardDefinition? def)
{
var name = def?.Name ?? o.DefinitionId;
var body = def?.Type == CardType.Creature || def?.Type == CardType.FieldCard
? $" {o.Attack}/{o.Health}"
: "";
return $"{name}({o.Cost}){body}[{o.HolderId.Split('.').Last()}]";
}
static int ReadIndex(string prompt, int min, int max)
{
while (true)
{
Console.Write(prompt + " ");
var line = Console.ReadLine();
if (line is not null && int.TryParse(line, out var n) && n >= min && n <= max)
{
return n;
}
Console.WriteLine($"Enter {min}..{max}");
}
}
static string ResolveContent(string folder)
{
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "content", folder));
if (!Directory.Exists(path))
{
path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", folder));
}
return path;
}
static ulong ParseSeed(string[] args)
{
for (var i = 0; i < args.Length - 1; i++)
{
if (args[i] is "--seed" or "-s" && ulong.TryParse(args[i + 1], out var seed))
{
return seed;
}
}
return 20261006UL;
}
static int ParseFights(string[] args)
{
for (var i = 0; i < args.Length - 1; i++)
{
if (args[i] is "--fights" or "-f" && int.TryParse(args[i + 1], out var n) && n > 0)
{
return n;
}
}
return RunProgress.DefaultFightCount;
}
static void PrintState(GameState state)
{
Console.WriteLine();
Console.WriteLine(
$"--- turn {state.TurnNumber} active=P{state.ActivePlayerIndex} P0={state.Players[0].Life}hp/{state.Players[0].Energy}e P1={state.Players[1].Life}hp/{state.Players[1].Energy}e ---");
PrintZone("P0 hand", state.Players[0].Hand, state);
PrintZone("P1 hand", state.Players[1].Hand, state);
Console.WriteLine("Board:");
foreach (var e in state.Board)
{
Console.WriteLine(" " + DescribeEntity(state, e));
}
Console.WriteLine($"P0 deck={state.Players[0].Deck.Count} gy={state.Players[0].Graveyard.Count} | P1 deck={state.Players[1].Deck.Count} gy={state.Players[1].Graveyard.Count}");
}
static void PrintZone(string title, List<Entity> cards, GameState state)
{
Console.WriteLine(title + ": " + string.Join(", ", cards.Select(c => ShortCard(state, c))));
}
static void PrintActions(GameState state, ImmutableArray<GameAction> legal)
{
Console.WriteLine("Legal:");
for (var i = 0; i < legal.Length; i++)
{
Console.WriteLine($" {i + 1}. {Describe(state, legal[i])}");
}
}
static string Describe(GameState state, GameAction action) =>
action switch
{
PlayCardAction play =>
$"Play {ShortCard(state, state.Find(play.CardId)!)}"
+ (play.Target is null ? "" : $" -> {DescribeTarget(state, play.Target)}")
+ (play.Target2 is null ? "" : $" & {DescribeTarget(state, play.Target2)}"),
AttackAction attack =>
$"Attack {ShortCard(state, state.Find(attack.AttackerId)!)} -> {DescribeTarget(state, attack.Target)}",
ActivateAction act =>
$"Activate {ShortCard(state, state.Find(act.SourceId)!)}"
+ (act.Target is null ? "" : $" -> {DescribeTarget(state, act.Target)}"),
EndTurnAction => "EndTurn",
_ => action.ToString() ?? "action"
};
static string DescribeTarget(GameState state, TargetRef target) =>
target switch
{
PlayerTarget p => $"P{p.PlayerIndex}",
EntityTarget e => ShortCard(state, state.Find(e.Id)!),
_ => "?"
};
static string ShortCard(GameState state, Entity e)
{
var atk = e.Definition.Type == CardType.Creature ? state.EffectiveAttack(e) : e.Attack;
return e.Definition.Type switch
{
CardType.Creature => $"{e.Id}:{e.Definition.Name}({e.Cost}) {atk}/{e.Health}[{e.HolderId.Split('.').Last()}]",
CardType.FieldCard => $"{e.Id}:{e.Definition.Name} CD{e.Countdown}{(e.IsSystem ? "*" : "")}",
_ => $"{e.Id}:{e.Definition.Name}({e.Cost})"
};
}
static string DescribeEntity(GameState state, Entity e) =>
$"P{e.ControllerIndex} {ShortCard(state, e)} atkLeft={e.AttacksRemaining} actLeft={e.ActivatesRemaining}";