using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StS2Sim;
///
/// One end-to-end run of with progress events
/// shaped for the web UI. Splits sim orchestration out of the HTTP layer so
/// the server only deals with routing/transport, not the algorithm.
///
/// The wire shapes (event "type" strings, field names) here are part of the
/// frontend contract — see www/app.js. Don't rename without coordinating.
///
internal sealed class SimJob
{
public required IReadOnlyList Deck { get; init; }
public IReadOnlyList Relics { get; init; } = Array.Empty();
public required string CharacterId { get; init; }
public required Func BroadcastEvent { get; init; }
public int Seeds { get; init; } = 200;
public int K { get; init; } = 30;
public int Turns { get; init; } = 5;
public double Epsilon { get; init; } = 0.30;
public async Task Run(CancellationToken ct)
{
await BroadcastEvent(new
{
type = "started",
deckSize = Deck.Count,
character = CharacterId,
seeds = Seeds,
k = K,
turns = Turns,
epsilon = Epsilon,
});
try
{
var characterType = Harness.ResolveCharacterType(CharacterId)
?? typeof(MegaCrit.Sts2.Core.Models.Characters.Ironclad);
var policy = new EpsilonGreedyPolicy(new HighestDamagePolicy(), Epsilon);
var runner = new BestOfKRunner
{
DeckName = CharacterId,
Deck = Deck,
Relics = Relics,
CharacterType = characterType,
Policy = policy,
Seeds = Seeds,
InnerSamples = K,
Turns = Turns,
Quiet = true,
Cancellation = ct,
OnSeedDone = OnSeedDone,
OnNewBest = OnNewBest,
};
var summary = await runner.Run();
await BroadcastEvent(new
{
type = "done",
avgOfBest = summary.AvgOfBest,
avgPerTurn = summary.AvgOfBest / summary.Seeds == 0 ? 0 : summary.AvgOfBest / Turns,
ci95 = summary.Ci95HalfWidth,
bestOfBest = summary.BestOfBest,
worstSeedBest = summary.WorstSeedBest,
totalRuns = summary.TotalRuns,
elapsedSec = summary.Elapsed.TotalSeconds,
medianConvergenceK = summary.MedianConvergenceK,
maxConvergenceK = summary.MaxConvergenceK,
});
}
catch (OperationCanceledException)
{
await BroadcastEvent(new { type = "cancelled" });
}
catch (Exception ex)
{
Console.Error.WriteLine("[SimJob] sim run threw:\n" + ex);
await BroadcastEvent(new { type = "error", message = ex.Message, stack = ex.ToString() });
}
}
private void OnSeedDone(BestOfKRunner.SeedProgress p) => _ = BroadcastEvent(new
{
type = "seed",
index = p.SeedIndex,
total = p.TotalSeeds,
bestForSeed = p.BestForSeed,
runningAvg = p.RunningAvg,
runningStdErr = p.RunningStdErr,
ci95 = 1.96 * p.RunningStdErr,
totalRuns = p.TotalRuns,
elapsedMs = (long)p.Elapsed.TotalMilliseconds,
});
private void OnNewBest(DamagePerTurnSim.TrialResult trial) => _ = BroadcastEvent(new
{
type = "newBest",
seed = trial.Seed,
totalDamage = trial.TotalDamage,
avgPerTurn = trial.AvgPerTurn,
turns = trial.Turns.Select(t => new
{
turn = t.Turn,
damage = t.Damage,
// 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,
subject = e.SubjectLabel,
}),
}),
});
}