规则、卡包与测试放在同一套核心里,避免表现层自行结算。 Co-authored-by: Cursor <cursoragent@cursor.com>
431 lines
13 KiB
C#
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}";
|