diff --git a/src/DamagePerTurnSim.cs b/src/DamagePerTurnSim.cs index aa48eef..c1a68e7 100644 --- a/src/DamagePerTurnSim.cs +++ b/src/DamagePerTurnSim.cs @@ -26,7 +26,7 @@ internal sealed class DamagePerTurnSim public IPlayPolicy Policy { get; init; } = new GreedyAttackPolicy(); public uint? PolicyRngSeed { get; init; } = null; - public sealed record TurnResult(int Turn, int Damage, IReadOnlyList Hand, IReadOnlyList CardsPlayed); + public sealed record TurnResult(int Turn, int Damage, IReadOnlyList Events); public sealed record TrialResult(uint Seed, IReadOnlyList Turns) { @@ -99,11 +99,10 @@ internal sealed class DamagePerTurnSim if (c.EnergyCost.CostsX) c.EnergyCost.CapturedXValue = 0; } - var played = new List(); - // Capture autoplays (Hellraiser drawn-strike, Havoc, etc.) into the - // same list so the turn log matches reality. Capture must be installed - // BEFORE the draw because Hellraiser fires mid-draw. - PlayCapture.Start(played); + // Chronological per-turn event log: every draw and every play (manual + // or auto) recorded in the order it actually happened. + var events = new List(); + PlayCapture.Start(events); // Use CardPileCmd.Draw so Hook.AfterCardDrawn fires — that's what powers // like Hellraiser hook into to autoplay Strikes mid-draw. @@ -111,12 +110,7 @@ internal sealed class DamagePerTurnSim if (needed > 0) await CardPileCmd.Draw(harness.Ctx, needed, harness.Player); - // Snapshot hand AFTER autoplays have resolved (those cards aren't - // really "drawn into hand" from the player's perspective — they were - // drawn and immediately played out). - var handSnapshot = hand.Cards.Select(CardLabels.Format).ToList(); - - await PlayPhase(harness, pcs, played, policyRng); + await PlayPhase(harness, pcs, policyRng); PlayCapture.Stop(); var hpAfter = harness.Dummy.CurrentHp; @@ -136,10 +130,10 @@ internal sealed class DamagePerTurnSim // Heal dummy back to full so HP doesn't ever hit 0. Reflect.HealToFull(harness.Dummy); - return new TurnResult(roundNumber, hpBefore - hpAfter, handSnapshot, played); + return new TurnResult(roundNumber, hpBefore - hpAfter, events); } - private async Task PlayPhase(Harness.CombatHarness harness, MegaCrit.Sts2.Core.Entities.Players.PlayerCombatState pcs, List played, Random policyRng) + private async Task PlayPhase(Harness.CombatHarness harness, MegaCrit.Sts2.Core.Entities.Players.PlayerCombatState pcs, Random policyRng) { while (pcs.Energy > 0) { @@ -164,8 +158,8 @@ internal sealed class DamagePerTurnSim // ourselves; cards that gain energy mid-OnPlay (Offering, Bloodletting) // will be visible in the next iter. pcs.LoseEnergy(cost); + PlayCapture.RecordManualPlay(card); await card.OnPlayWrapper(harness.Ctx, target, isAutoPlay: true, resources, skipCardPileVisuals: true); - played.Add(CardLabels.Format(card)); } } } diff --git a/src/GodotShims.cs b/src/GodotShims.cs index 388231e..d5b4967 100644 --- a/src/GodotShims.cs +++ b/src/GodotShims.cs @@ -45,6 +45,12 @@ internal static class GodotShims // turn log even though they deal real damage. PatchPrefix(harmony, typeof(CardCmd), "AutoPlay", nameof(CardCmd_AutoPlay_Prefix)); + // Hook.AfterCardDrawn fires once per drawn card from CardPileCmd.Draw. + // Patching it as a prefix means we record the draw BEFORE Hellraiser- + // style autoplay fires, giving us a chronological event timeline: + // "drew Pommel Strike" → "played Pommel Strike (auto)" → "drew Strike". + PatchPrefix(harmony, typeof(MegaCrit.Sts2.Core.Hooks.Hook), "AfterCardDrawn", nameof(Hook_AfterCardDrawn_Prefix)); + // CardPileCmd.Shuffle calls Engine.GetMainLoop() for animation pacing // (sleeps between adding cards back to draw pile). We replace the whole // method with a synchronous shuffle that does the same logical work @@ -100,6 +106,9 @@ internal static class GodotShims private static void CardCmd_AutoPlay_Prefix(CardModel card) => PlayCapture.RecordAutoPlay(card); + private static void Hook_AfterCardDrawn_Prefix(CardModel card) + => PlayCapture.RecordDraw(card); + private static bool Creature_ToString_Prefix(Creature __instance, ref string __result) { __result = __instance.IsMonster diff --git a/src/PlayCapture.cs b/src/PlayCapture.cs index fec55ed..f5587b3 100644 --- a/src/PlayCapture.cs +++ b/src/PlayCapture.cs @@ -4,26 +4,44 @@ using MegaCrit.Sts2.Core.Models; namespace StS2Sim; /// -/// Per-turn record of every card play, including auto-plays (Hellraiser -/// drawn-strike, Havoc top-of-deck, etc.). The autoplay Harmony prefix in -/// appends here whenever CardCmd.AutoPlay -/// fires; the policy loop appends manual plays. Both feed the same list so -/// the final order matches what actually happened in combat. +/// Per-turn chronological log of card events: draws and plays interleaved in +/// the order they actually occurred. Lets the UI show cascades like +/// "drew Pommel Strike → played Pommel Strike (auto) → drew Strike (from Pommel)". +/// +/// Sources that append: +/// - Hook.AfterCardDrawn Harmony prefix → "draw" event +/// - CardCmd.AutoPlay Harmony prefix → "play" event (auto) +/// - policy loop → "play" event (manual) /// /// Thread-static: each parallel sim worker (if/when we add them) gets its /// own capture sink without locking. /// internal static class PlayCapture { - [System.ThreadStatic] private static List? _sink; + public enum EventKind { Draw, Play } + + public sealed record Event(EventKind Kind, string Label, bool Auto); + + [System.ThreadStatic] private static List? _sink; - public static void Start(List sink) => _sink = sink; + public static void Start(List sink) => _sink = sink; public static void Stop() => _sink = null; - /// Called from the AutoPlay Harmony prefix. No-op if no capture is active. + public static void RecordDraw(CardModel card) + { + if (_sink == null) return; + _sink.Add(new Event(EventKind.Draw, CardLabels.Format(card), Auto: false)); + } + public static void RecordAutoPlay(CardModel card) { if (_sink == null) return; - _sink.Add(CardLabels.Format(card) + " (auto)"); + _sink.Add(new Event(EventKind.Play, CardLabels.Format(card), Auto: true)); + } + + public static void RecordManualPlay(CardModel card) + { + if (_sink == null) return; + _sink.Add(new Event(EventKind.Play, CardLabels.Format(card), Auto: false)); } } diff --git a/src/Server/SimJob.cs b/src/Server/SimJob.cs index 2db40bd..6db3482 100644 --- a/src/Server/SimJob.cs +++ b/src/Server/SimJob.cs @@ -103,8 +103,15 @@ internal sealed class SimJob { turn = t.Turn, damage = t.Damage, - hand = t.Hand, - played = t.CardsPlayed, + // Chronological event timeline: each entry is { kind: "draw"|"play", + // label, auto: true if this play came from an autoplay (Hellraiser + // strike etc.) }. Enables the UI to render cascades top-to-bottom. + events = t.Events.Select(e => new + { + kind = e.Kind == PlayCapture.EventKind.Draw ? "draw" : "play", + label = e.Label, + auto = e.Auto, + }), }), }); } diff --git a/src/Sim/ConvergenceRunner.cs b/src/Sim/ConvergenceRunner.cs index 177b405..b878de7 100644 --- a/src/Sim/ConvergenceRunner.cs +++ b/src/Sim/ConvergenceRunner.cs @@ -123,7 +123,8 @@ internal sealed class ConvergenceRunner Console.WriteLine($" Best trial breakdown (seed=0x{bestTrial.Seed:X}):"); foreach (var t in bestTrial.Turns) { - Console.WriteLine($" Turn {t.Turn}: {t.Damage} dmg via [{string.Join(", ", t.CardsPlayed)}]"); + var played = t.Events.Where(e => e.Kind == PlayCapture.EventKind.Play).Select(e => e.Label); + Console.WriteLine($" Turn {t.Turn}: {t.Damage} dmg via [{string.Join(", ", played)}]"); } } return summary; diff --git a/www/app.js b/www/app.js index 9405042..f1bc3d2 100644 --- a/www/app.js +++ b/www/app.js @@ -304,23 +304,21 @@ function handleEvent(e) { function renderBestCombat(e) { $('best-headline').textContent = `${e.totalDamage} dmg over ${e.turns.length} turns (${e.avgPerTurn.toFixed(1)}/turn) — seed 0x${e.seed.toString(16).toUpperCase()}`; const cells = e.turns.map(t => { - // Mark cards that were also played (greys out the dead ones in hand). - const played = new Set(t.played); - const handPills = t.hand.map(c => { - const wasPlayed = played.has(c); - return `${escapeHtml(c)}`; + const rows = (t.events || []).map(ev => { + if (ev.kind === 'draw') { + return `
↓${escapeHtml(ev.label)}
`; + } + const cls = ev.auto ? 'ev play auto' : 'ev play manual'; + const tag = ev.auto ? 'auto' : ''; + return `
▶${escapeHtml(ev.label)}${tag}
`; }).join(''); - const playedPills = t.played.map(c => `${escapeHtml(c)}`).join(''); return `
Turn ${t.turn} ${t.damage} dmg
-
Hand drawn
-
${handPills || 'empty'}
-
Played in order
-
${playedPills || 'none'}
+
${rows || '
no events
'}
`; }).join(''); $('best-combat').innerHTML = cells; diff --git a/www/index.html b/www/index.html index d29e1dd..af00f51 100644 --- a/www/index.html +++ b/www/index.html @@ -58,16 +58,22 @@ .verdict.error { background: #4a1c1c; color: var(--bad); } .empty { color: var(--fg-dim); font-style: italic; padding: 8px; } .best-headline { font-size: 11px; color: var(--good); font-weight: normal; margin-left: 8px; letter-spacing: 0; text-transform: none; } - .best-combat { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; } + .best-combat { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; } .turn-card { background: var(--bg-2); border-left: 3px solid var(--accent-2); padding: 8px 12px; border-radius: 3px; } - .turn-card .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; } + .turn-card .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid #21455a; } .turn-card .turn-num { font-weight: bold; color: var(--accent-2); } .turn-card .turn-dmg { font-variant-numeric: tabular-nums; } - .turn-card .label { font-size: 10px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.5px; margin: 4px 0 2px; } - .turn-card .pill-list { display: flex; flex-wrap: wrap; gap: 3px; } - .pill { background: rgba(33,69,90,0.7); padding: 2px 6px; border-radius: 10px; font-size: 11px; white-space: nowrap; } - .pill.played { background: var(--accent); color: var(--fg); } - .pill.dim { opacity: 0.5; } + .event-list { display: flex; flex-direction: column; gap: 1px; } + .ev { display: flex; align-items: center; gap: 6px; padding: 2px 4px; font-size: 12px; border-radius: 2px; } + .ev .icon { width: 12px; text-align: center; font-weight: bold; flex-shrink: 0; } + .ev .ev-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ev.draw { color: var(--fg-dim); } + .ev.draw .icon { color: #6da4c4; } + .ev.play.manual { color: var(--fg); background: rgba(139,25,19,0.35); } + .ev.play.manual .icon { color: var(--accent-2); } + .ev.play.auto { color: var(--fg); background: rgba(212,161,66,0.18); } + .ev.play.auto .icon { color: var(--accent-2); } + .auto-tag { font-size: 9px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.5px; }