diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cd42ee3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+bin/
+obj/
diff --git a/README.md b/README.md
index 379203b..21125d0 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ feel what happens to them, flinch, writhe, and sometimes crawl on toward
whatever they wanted, because they are driven by
[Lodur](https://eureka.gamecult.org/forge/GameCult/Lodur).
-**Status: design only. No code exists yet.** The design is Lodur's target,
+**Status: first slice only (the pain box). The 3D sandbox does not exist yet.** The design is Lodur's target,
[`docs/lodur-target.md`](https://eureka.gamecult.org/forge/GameCult/Lodur/src/branch/main/docs/lodur-target.md)
in the Lodur repo; this game is its first consumer and its proving ground.
@@ -19,3 +19,72 @@ impairment) in one place where a player can poke at all of it.
Canonical: `https://eureka.gamecult.org/forge/GameCult/BadDaySimulator`.
GitHub `GameCult/BadDaySimulator` is a push mirror.
+
+## What runs today: the pain box
+
+A headless, deterministic simulation of one virtual human with a hand in a
+pain box. Question it answers: **how long until the will breaks, or the box is
+disabled first?** Entry points:
+
+- `src/BadDay.Core/PainBoxSimulation.cs`: the model and the tick loop.
+- `src/BadDay.Core/PainBoxParameters.cs`: every tunable, with defaults.
+- `src/BadDay.Core/Sweep.cs`: consecutive seeds, break-time min/median/max.
+- `src/BadDay.Cli`: `baddaysim`.
+- `tests/BadDay.Tests`: determinism, break-time and monotonicity tests.
+
+```
+dotnet run --project src/BadDay.Cli -- run --seed 1
+dotnet run --project src/BadDay.Cli -- sweep --seed 1 --count 50 --pull 0.3
+dotnet run --project src/BadDay.Cli -- run --seed 1 --disable-at 5
+dotnet run --project tests/BadDay.Tests # exits non-zero on failure
+```
+
+`run` prints a timeline (samples every second, plus flinch, break and
+withdrawal events) and a one-line summary. Options: `--intensity`, `--pull`,
+`--disable-at`, `--max`, `--every`.
+
+### Modeled
+
+Fixed 10 ms tick, seeded SplitMix64 (the only randomness; integer arithmetic,
+so a seed means the same stream anywhere). Following the layers in Lodur's
+target, flattened to one loop:
+
+- **Perception / nerve:** the box emits an intensity (ramped in, with seeded
+ jitter). A nociceptor applies a threshold and sensitises under sustained
+ stimulus; the signal reaches the centre after a conduction delay.
+- **Reflex:** the arriving signal drives a withdrawal reflex. Will suppresses
+ it in proportion to resolve; what leaks through is a flinch, and holding it
+ down accumulates strain (suppression is never free, per
+ `reflex-gain-from-drive`).
+- **Drive:** pain rises with the signal and decays; resolve drains with pain
+ and strain, scaled by `(1 - ObjectivePull)`, and recovers only when pain is low.
+ Two seeded traits (nociceptor gain, resolve drain) make each seed a different person.
+- **Goals:** `HoldHand` (the objective, with its pull) and `Withdraw`. When
+ resolve reaches zero the goal flips and the hand is out after the withdrawal
+ latency (100 ms).
+- **Measure:** `TimeToBreakMs` (resolve hits zero) and the outcome:
+ `WillBroke`, `BoxDisabled` (disabled while the will held), or `Endured`.
+
+### Not modeled
+
+No body, physics, balance, joints or ragdoll; no shock, adrenaline or blood
+loss; no impairment or crawling; no Unity binding; no puppets or objects other
+than the box; no learning. The reflex never removes the hand by itself: only a
+broken will does. All parameter values are **placeholders**, not physiology;
+the figures in Lodur's target are marked unverified and so are these. Break
+times in the output are properties of this toy model, not predictions about people.
+Bit-exact output across CPU architectures is not claimed; same-machine runs
+are byte-identical (the loop uses only `+ - * /` and comparisons, no
+transcendental functions).
+
+Persistent content (puppet definitions, drive parameters, saved levels) will
+be CultCache per Lodur's `content-is-cultcache`; this slice has none, so it
+has no cache layer and no JSON.
+
+### Build note
+
+The core has no engine reference (`core-engine-free`). The slice has no
+package dependencies, so the tests are a plain console program rather than
+xunit. On the Yggdrasil mind host `dotnet restore` currently fails on a
+NuGet named mutex (read-only `/dev/shm`); the slice was compiled there with
+Roslyn's `csc` directly, which is why the `.csproj` files are the unverified part.
diff --git a/docs/pain-sweep.md b/docs/pain-sweep.md
new file mode 100644
index 0000000..d8d6038
--- /dev/null
+++ b/docs/pain-sweep.md
@@ -0,0 +1,54 @@
+# Pain sweep: time to break over intensity and suppression
+
+`tools/PainSweep/Program.cs` runs the pain box puppet over a grid of box
+intensity (rows) and withdrawal-reflex suppression strength (columns,
+`PainBoxParameters.MaxSuppression`: the largest fraction of the reflex the will
+can hold down). It is engine-free and deterministic; the same build prints the
+same table.
+
+## Reading the table
+
+Each cell is the median time-to-break in seconds over 9 consecutive seeds (1..9;
+seeds vary nociceptor gain, resolve drain and stimulus jitter). Everything else
+is at its default, including objective pull 0.5 and a 120 s limit.
+
+- `never`: no seed's will broke within 120 s. This is a result, not an error.
+- `95.4(8/9)`: only 8 of 9 seeds broke; the median is over those 8, so it
+ understates how long the ninth lasted.
+- Higher suppression breaks the will sooner here. Holding the reflex down costs
+ strain, strain drains resolve, and at suppression 0 there is no strain, so
+ pain alone drains resolve too slowly to break it. That is how this model is
+ built, not a finding about people.
+
+## What it does not capture
+
+- Every default is a placeholder. None of the numbers is measured physiology, so
+ read the shape of the table, not the seconds.
+- One hand, one stimulus channel, no body, no physics, no balance, no adaptation
+ or habituation beyond the nerve's sensitisation, no fear, expectation or
+ distraction.
+- "Will" is one scalar draining toward zero. Real people trade off, bargain, and
+ break in ways that are not a threshold crossing.
+- Suppression is a fixed ceiling scaled by remaining resolve; nothing trains,
+ tires or recovers it except through resolve.
+- Nine seeds is a smoke grid, not a distribution. The table shows medians only.
+
+## Question for the designer
+
+Is time-to-break the right measure? It collapses a whole run to one number and
+treats "never" as an outcome of equal weight to "late". Candidates: time to
+first flinch, flinch count, area under the pain curve before the hand leaves,
+peak strain, or the fraction of seeds that break by a deadline. Which one says
+what a player should feel?
+
+## Build and run (no `dotnet restore`)
+
+```
+D=/opt/commons/opt/dotnet; REF=$D/packs/Microsoft.NETCore.App.Ref/10.0.14/ref/net10.0
+dotnet $D/sdk/10.0.403/Roslyn/bincore/csc.dll -nologo -nullable:enable -target:exe \
+ -out:$TMPDIR/painsweep/PainSweep.dll -noconfig -nostdlib+ $(for f in $REF/*.dll; do echo -n "-r:$f "; done) \
+ src/BadDay.Core/*.cs tools/PainSweep/Program.cs
+printf '{"runtimeOptions":{"tfm":"net10.0","framework":{"name":"Microsoft.NETCore.App","version":"10.0.14"}}}' \
+ > $TMPDIR/painsweep/PainSweep.runtimeconfig.json
+dotnet $TMPDIR/painsweep/PainSweep.dll
+```
diff --git a/src/BadDay.Cli/BadDay.Cli.csproj b/src/BadDay.Cli/BadDay.Cli.csproj
new file mode 100644
index 0000000..26132d4
--- /dev/null
+++ b/src/BadDay.Cli/BadDay.Cli.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net10.0
+ enable
+ disable
+ baddaysim
+
+
+
+
+
diff --git a/src/BadDay.Cli/Program.cs b/src/BadDay.Cli/Program.cs
new file mode 100644
index 0000000..ec383ba
--- /dev/null
+++ b/src/BadDay.Cli/Program.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using BadDay.Core;
+
+namespace BadDay.Cli;
+
+public static class Program
+{
+ private const string Usage = """
+ baddaysim run --seed N [options] one run, full timeline
+ baddaysim sweep --seed N --count M [options] seeds N..N+M-1, break-time summary
+
+ options (all numbers, seconds unless noted):
+ --intensity X box intensity 0..1 (default 0.8)
+ --pull X objective pull 0..1 (default 0.5)
+ --disable-at S box switches off at S (default never)
+ --max S give up after S (default 120)
+ --every S timeline sample interval (default 1)
+ """;
+
+ public static int Main(string[] args)
+ {
+ try { return Execute(args, Console.Out); }
+ catch (ArgumentException e)
+ {
+ Console.Error.WriteLine(e.Message);
+ Console.Error.WriteLine(Usage);
+ return 2;
+ }
+ }
+
+ public static int Execute(string[] args, System.IO.TextWriter output)
+ {
+ if (args.Length == 0 || (args[0] != "run" && args[0] != "sweep")) throw new ArgumentException("expected 'run' or 'sweep'");
+ var opts = ParseOptions(args);
+ ulong seed = opts.TryGetValue("seed", out var s) ? ulong.Parse(s, CultureInfo.InvariantCulture)
+ : throw new ArgumentException("--seed is required");
+ var p = new PainBoxParameters
+ {
+ BoxIntensity = Num(opts, "intensity", 0.8),
+ ObjectivePull = Num(opts, "pull", 0.5),
+ BoxDisableAtSeconds = opts.ContainsKey("disable-at") ? Num(opts, "disable-at", 0) : null,
+ MaxSeconds = Num(opts, "max", 120),
+ SampleEverySeconds = Num(opts, "every", 1),
+ };
+
+ if (args[0] == "run")
+ {
+ var r = PainBoxSimulation.Run(p, seed);
+ output.Write(r.Render());
+ output.WriteLine(r.Summary());
+ return 0;
+ }
+
+ int count = (int)Num(opts, "count", 20);
+ var sweep = SweepSummary.Run(p, seed, count);
+ foreach (var r in sweep.Results) output.WriteLine(r.Summary());
+ output.WriteLine($"runs {sweep.Runs}: broke {sweep.Broke}, box disabled {sweep.BoxDisabled}, endured {sweep.Endured}; " +
+ $"time to break min/median/max ms: {Fmt(sweep.MinBreakMs)}/{Fmt(sweep.MedianBreakMs)}/{Fmt(sweep.MaxBreakMs)}");
+ return 0;
+ }
+
+ private static string Fmt(long? v) => v?.ToString(CultureInfo.InvariantCulture) ?? "-";
+
+ private static double Num(Dictionary o, string key, double fallback) =>
+ o.TryGetValue(key, out var v) ? double.Parse(v, CultureInfo.InvariantCulture) : fallback;
+
+ private static Dictionary ParseOptions(string[] args)
+ {
+ var o = new Dictionary();
+ for (int i = 1; i < args.Length; i += 2)
+ {
+ if (!args[i].StartsWith("--") || i + 1 >= args.Length) throw new ArgumentException($"bad option near '{args[i]}'");
+ o[args[i][2..]] = args[i + 1];
+ }
+ return o;
+ }
+}
diff --git a/src/BadDay.Core/BadDay.Core.csproj b/src/BadDay.Core/BadDay.Core.csproj
new file mode 100644
index 0000000..9488531
--- /dev/null
+++ b/src/BadDay.Core/BadDay.Core.csproj
@@ -0,0 +1,8 @@
+
+
+ net10.0
+ enable
+ disable
+ BadDay.Core
+
+
diff --git a/src/BadDay.Core/PainBoxParameters.cs b/src/BadDay.Core/PainBoxParameters.cs
new file mode 100644
index 0000000..7e3f3d8
--- /dev/null
+++ b/src/BadDay.Core/PainBoxParameters.cs
@@ -0,0 +1,52 @@
+namespace BadDay.Core;
+
+///
+/// Every tunable of the pain box slice. Defaults are placeholders chosen so the
+/// default run breaks in a readable time, not measured physiology: the figures
+/// Lodur's target marks [M] (withdrawal 60-150 ms, voluntary ~200 ms) are
+/// unverified, and so are these.
+///
+public sealed record PainBoxParameters
+{
+ // Clock
+ public double TickSeconds { get; init; } = 0.01;
+ public double MaxSeconds { get; init; } = 120.0;
+
+ // The box: noxious intensity on the hand, 0..1, ramped in, with seeded jitter.
+ public double BoxIntensity { get; init; } = 0.8;
+ public double BoxRampSeconds { get; init; } = 2.0;
+ public double BoxNoise { get; init; } = 0.1;
+ /// When set, the box switches off at this time (the game disables it).
+ public double? BoxDisableAtSeconds { get; init; }
+
+ // Nerve: threshold, sensitisation under sustained stimulus, conduction delay.
+ public double NociceptorThreshold { get; init; } = 0.1;
+ public double SensitisationGrowth { get; init; } = 0.04;
+ public double SensitisationRecovery { get; init; } = 0.02;
+ public double SensitisationCap { get; init; } = 2.0;
+ public double ConductionDelaySeconds { get; init; } = 0.06;
+
+ // Drive: pain, strain from holding a reflex down, resolve.
+ public double PainRise { get; init; } = 1.0;
+ public double PainDecay { get; init; } = 0.05;
+ public double StrainRecover { get; init; } = 0.1;
+ public double ResolveDrainPain { get; init; } = 0.012;
+ public double ResolveDrainStrain { get; init; } = 0.05;
+ public double ResolveRecovery { get; init; } = 0.02;
+ public double ResolveRecoversBelowPain { get; init; } = 0.1;
+
+ // Reflex: how much of it will can hold down, and the latencies.
+ public double MaxSuppression { get; init; } = 0.95;
+ public double FlinchLeakThreshold { get; init; } = 0.3;
+ public double FlinchRefractorySeconds { get; init; } = 0.5;
+ public double WithdrawalLatencySeconds { get; init; } = 0.1;
+
+ // Goals: how hard the objective ("keep the hand in the box") pulls, 0..1.
+ // It scales resolve drain by (1 - pull); it is not a second resolve bar.
+ public double ObjectivePull { get; init; } = 0.5;
+
+ // Per-seed trait spread (fraction): nociceptor gain and resolve drain.
+ public double TraitJitter { get; init; } = 0.2;
+
+ public double SampleEverySeconds { get; init; } = 1.0;
+}
diff --git a/src/BadDay.Core/PainBoxResult.cs b/src/BadDay.Core/PainBoxResult.cs
new file mode 100644
index 0000000..87cf6a2
--- /dev/null
+++ b/src/BadDay.Core/PainBoxResult.cs
@@ -0,0 +1,61 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+
+namespace BadDay.Core;
+
+public enum Goal { HoldHand, Withdraw }
+
+public enum Outcome
+{
+ /// Resolve reached zero before the box went off.
+ WillBroke,
+ /// The box was disabled while the will still held.
+ BoxDisabled,
+ /// MaxSeconds passed with the box on and the will intact.
+ Endured,
+}
+
+public enum EntryKind { Sample, Flinch, WillBroke, Withdrew, BoxDisabled, Endured }
+
+public readonly record struct TimelineEntry(
+ long TimeMs, EntryKind Kind, double Stimulus, double Pain, double Strain, double Resolve, Goal Goal);
+
+public sealed record PainBoxResult(
+ ulong Seed,
+ Outcome Outcome,
+ /// Time the run ended, ms.
+ long EndMs,
+ /// Time resolve hit zero, ms; null unless is WillBroke.
+ long? TimeToBreakMs,
+ int Flinches,
+ IReadOnlyList Timeline)
+{
+ ///
+ /// Deterministic text form (invariant culture, fixed precision). Two runs
+ /// with the same seed and parameters render byte-identical.
+ ///
+ public string Render()
+ {
+ var sb = new StringBuilder();
+ sb.Append("time_ms kind stimulus pain strain resolve goal\n");
+ foreach (var e in Timeline)
+ {
+ sb.Append(e.TimeMs.ToString(CultureInfo.InvariantCulture)).Append(' ')
+ .Append(e.Kind).Append(' ')
+ .Append(e.Stimulus.ToString("F3", CultureInfo.InvariantCulture)).Append(' ')
+ .Append(e.Pain.ToString("F3", CultureInfo.InvariantCulture)).Append(' ')
+ .Append(e.Strain.ToString("F3", CultureInfo.InvariantCulture)).Append(' ')
+ .Append(e.Resolve.ToString("F3", CultureInfo.InvariantCulture)).Append(' ')
+ .Append(e.Goal).Append('\n');
+ }
+ return sb.ToString();
+ }
+
+ public string Summary() => Outcome switch
+ {
+ Outcome.WillBroke => $"seed {Seed}: will broke at {TimeToBreakMs} ms, hand out at {EndMs} ms, {Flinches} flinches",
+ Outcome.BoxDisabled => $"seed {Seed}: box disabled at {EndMs} ms, will held, {Flinches} flinches",
+ _ => $"seed {Seed}: endured to {EndMs} ms, will held, {Flinches} flinches",
+ };
+}
diff --git a/src/BadDay.Core/PainBoxSimulation.cs b/src/BadDay.Core/PainBoxSimulation.cs
new file mode 100644
index 0000000..3392f40
--- /dev/null
+++ b/src/BadDay.Core/PainBoxSimulation.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+
+namespace BadDay.Core;
+
+///
+/// One virtual human with a hand in a pain box. Layers follow Lodur's target
+/// (perception, reflex, drive, behaviour), flattened to one fixed-step loop:
+/// the box emits a stimulus, a nociceptor with sensitisation turns it into an
+/// afferent signal delayed by conduction, the delayed signal feeds pain and the
+/// withdrawal reflex, will (resolve) suppresses the reflex at a strain cost,
+/// and strain plus pain drain resolve. When resolve reaches zero the goal
+/// switches from HoldHand to Withdraw and the hand leaves after the reflex
+/// latency. There is no body, no physics and no balance here.
+///
+public static class PainBoxSimulation
+{
+ public static PainBoxResult Run(PainBoxParameters p, ulong seed)
+ {
+ var rng = new SplitMix64(seed);
+ // Per-seed traits, drawn first and in fixed order so the stream never shifts.
+ double gainTrait = 1.0 + p.TraitJitter * rng.NextSigned();
+ double drainTrait = 1.0 + p.TraitJitter * rng.NextSigned();
+
+ long maxTick = Ticks(p.MaxSeconds, p.TickSeconds);
+ long disableTick = p.BoxDisableAtSeconds is { } d ? Ticks(d, p.TickSeconds) : long.MaxValue;
+ long sampleEvery = Math.Max(1, Ticks(p.SampleEverySeconds, p.TickSeconds));
+ long refractory = Ticks(p.FlinchRefractorySeconds, p.TickSeconds);
+ long withdrawLatency = Math.Max(1, Ticks(p.WithdrawalLatencySeconds, p.TickSeconds));
+ int delayTicks = (int)Ticks(p.ConductionDelaySeconds, p.TickSeconds);
+
+ var delay = new double[Math.Max(1, delayTicks)]; // afferent signal in flight along the nerve
+ double dt = p.TickSeconds;
+ double sens = 1.0, pain = 0.0, strain = 0.0, resolve = 1.0, stimulus = 0.0;
+ var goal = Goal.HoldHand;
+ long lastFlinch = long.MinValue / 2;
+ long breakTick = -1, withdrawTick = -1;
+ int flinches = 0;
+ var timeline = new List();
+ var outcome = Outcome.Endured;
+ long endTick = maxTick;
+
+ void Log(long tick, EntryKind kind) => timeline.Add(new TimelineEntry(
+ Ms(tick, p.TickSeconds), kind, stimulus, pain, strain, resolve, goal));
+
+ for (long tick = 0; tick <= maxTick; tick++)
+ {
+ bool boxOn = tick < disableTick;
+ if (tick == disableTick && goal == Goal.HoldHand)
+ {
+ stimulus = 0.0;
+ outcome = Outcome.BoxDisabled;
+ endTick = tick;
+ Log(tick, EntryKind.BoxDisabled);
+ break;
+ }
+
+ // Box -> stimulus (always draw jitter so the stream is independent of state).
+ double jitter = rng.NextSigned();
+ double ramp = Math.Min(1.0, tick * dt / p.BoxRampSeconds);
+ stimulus = boxOn ? Math.Max(0.0, p.BoxIntensity * ramp * (1.0 + p.BoxNoise * jitter)) : 0.0;
+
+ // Nociceptor: threshold, sensitisation, then conduction delay.
+ double above = Math.Max(0.0, stimulus - p.NociceptorThreshold);
+ sens += dt * (p.SensitisationGrowth * above - p.SensitisationRecovery * (sens - 1.0));
+ sens = Math.Clamp(sens, 1.0, p.SensitisationCap);
+ int slot = (int)(tick % delay.Length);
+ double arrived = delay[slot]; // written delayTicks ticks ago
+ delay[slot] = above * sens * gainTrait;
+
+ // Pain: afferent drives it up toward 1, it decays on its own.
+ pain += dt * (p.PainRise * arrived * (1.0 - pain) - p.PainDecay * pain);
+ pain = Math.Clamp(pain, 0.0, 1.0);
+
+ // Reflex: will holds down a fraction of it; the leak is a flinch,
+ // the effort of holding is strain. Not free, per reflex-gain-from-drive.
+ double suppression = goal == Goal.HoldHand ? p.MaxSuppression * resolve : 0.0;
+ double leak = arrived * (1.0 - suppression);
+ if (goal == Goal.HoldHand && leak > p.FlinchLeakThreshold && tick - lastFlinch >= refractory)
+ {
+ lastFlinch = tick;
+ flinches++;
+ Log(tick, EntryKind.Flinch);
+ }
+ strain += dt * (arrived * suppression - p.StrainRecover * strain);
+ strain = Math.Max(0.0, strain);
+
+ // Will decays under load, scaled down by how hard the objective pulls.
+ if (goal == Goal.HoldHand)
+ {
+ double drain = (p.ResolveDrainPain * pain + p.ResolveDrainStrain * strain)
+ * (1.0 - p.ObjectivePull) * drainTrait;
+ double recovery = pain < p.ResolveRecoversBelowPain ? p.ResolveRecovery * (1.0 - resolve) : 0.0;
+ resolve = Math.Clamp(resolve + dt * (recovery - drain), 0.0, 1.0);
+ if (resolve <= 0.0)
+ {
+ goal = Goal.Withdraw;
+ breakTick = tick;
+ withdrawTick = tick + withdrawLatency;
+ Log(tick, EntryKind.WillBroke);
+ }
+ }
+
+ if (tick % sampleEvery == 0) Log(tick, EntryKind.Sample);
+
+ if (tick == withdrawTick)
+ {
+ outcome = Outcome.WillBroke;
+ endTick = tick;
+ Log(tick, EntryKind.Withdrew);
+ break;
+ }
+ if (tick == maxTick) Log(tick, EntryKind.Endured);
+ }
+
+ return new PainBoxResult(
+ seed, outcome, Ms(endTick, p.TickSeconds),
+ breakTick >= 0 ? Ms(breakTick, p.TickSeconds) : null,
+ flinches, timeline);
+ }
+
+ private static long Ticks(double seconds, double tickSeconds) => (long)Math.Round(seconds / tickSeconds);
+ private static long Ms(long tick, double tickSeconds) => (long)Math.Round(tick * tickSeconds * 1000.0);
+}
diff --git a/src/BadDay.Core/SplitMix64.cs b/src/BadDay.Core/SplitMix64.cs
new file mode 100644
index 0000000..1572a8e
--- /dev/null
+++ b/src/BadDay.Core/SplitMix64.cs
@@ -0,0 +1,26 @@
+namespace BadDay.Core;
+
+///
+/// SplitMix64: the only source of randomness in a run. Pure integer arithmetic,
+/// so a seed gives the same stream on every platform and runtime version.
+///
+public sealed class SplitMix64
+{
+ private ulong _state;
+
+ public SplitMix64(ulong seed) => _state = seed;
+
+ public ulong NextUInt64()
+ {
+ ulong z = _state += 0x9E3779B97F4A7C15UL;
+ z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
+ z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
+ return z ^ (z >> 31);
+ }
+
+ /// Uniform in [0, 1) with 53 bits.
+ public double NextDouble() => (NextUInt64() >> 11) * (1.0 / 9007199254740992.0);
+
+ /// Uniform in [-1, 1).
+ public double NextSigned() => NextDouble() * 2.0 - 1.0;
+}
diff --git a/src/BadDay.Core/Sweep.cs b/src/BadDay.Core/Sweep.cs
new file mode 100644
index 0000000..07fbc80
--- /dev/null
+++ b/src/BadDay.Core/Sweep.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace BadDay.Core;
+
+/// Runs consecutive seeds and summarises time-to-break. The break-time measure.
+public sealed record SweepSummary(
+ int Runs, int Broke, int BoxDisabled, int Endured,
+ long? MinBreakMs, long? MedianBreakMs, long? MaxBreakMs,
+ IReadOnlyList Results)
+{
+ public static SweepSummary Run(PainBoxParameters p, ulong firstSeed, int count)
+ {
+ var results = new List(count);
+ for (int i = 0; i < count; i++) results.Add(PainBoxSimulation.Run(p, firstSeed + (ulong)i));
+ var breaks = results.Where(r => r.TimeToBreakMs.HasValue).Select(r => r.TimeToBreakMs!.Value).OrderBy(x => x).ToList();
+ return new SweepSummary(
+ count,
+ results.Count(r => r.Outcome == Outcome.WillBroke),
+ results.Count(r => r.Outcome == Outcome.BoxDisabled),
+ results.Count(r => r.Outcome == Outcome.Endured),
+ breaks.Count > 0 ? breaks[0] : null,
+ breaks.Count > 0 ? breaks[breaks.Count / 2] : null,
+ breaks.Count > 0 ? breaks[^1] : null,
+ results);
+ }
+}
diff --git a/tests/BadDay.Tests/BadDay.Tests.csproj b/tests/BadDay.Tests/BadDay.Tests.csproj
new file mode 100644
index 0000000..5bb6e2b
--- /dev/null
+++ b/tests/BadDay.Tests/BadDay.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ disable
+
+
+
+
+
+
diff --git a/tests/BadDay.Tests/Program.cs b/tests/BadDay.Tests/Program.cs
new file mode 100644
index 0000000..3bf7dba
--- /dev/null
+++ b/tests/BadDay.Tests/Program.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using BadDay.Core;
+
+namespace BadDay.Tests;
+
+public static class Program
+{
+ private static readonly PainBoxParameters Default = new();
+
+ private static readonly (string Name, Action Body)[] Tests =
+ {
+ ("same seed and parameters render byte-identical timelines", () =>
+ {
+ var a = PainBoxSimulation.Run(Default, 42);
+ var b = PainBoxSimulation.Run(Default, 42);
+ Check(a.Render() == b.Render(), "rendered timelines differ");
+ Check(a.TimeToBreakMs == b.TimeToBreakMs && a.Flinches == b.Flinches, "results differ");
+ }),
+
+ ("the same seed gives the same result no matter what ran before it", () =>
+ {
+ var alone = PainBoxSimulation.Run(Default, 7).Render();
+ for (ulong s = 100; s < 105; s++) PainBoxSimulation.Run(Default, s);
+ Check(PainBoxSimulation.Run(Default, 7).Render() == alone, "state leaked between runs");
+ }),
+
+ ("different seeds give different characters: break times spread", () =>
+ {
+ var sweep = SweepSummary.Run(Default, 1, 30);
+ var distinct = sweep.Results.Select(r => r.TimeToBreakMs).Distinct().Count();
+ Check(distinct > 10, $"only {distinct} distinct break times in 30 seeds");
+ Check(sweep.MinBreakMs < sweep.MaxBreakMs, "no spread");
+ }),
+
+ ("golden: default parameters, seed 1 breaks at 14730 ms and the hand is out 100 ms later", () =>
+ {
+ // Pins the placeholder model; a deliberate retune changes this number and this line together.
+ var r = PainBoxSimulation.Run(Default, 1);
+ Check(r.Outcome == Outcome.WillBroke, $"outcome {r.Outcome}");
+ Check(r.TimeToBreakMs == 14730, $"break at {r.TimeToBreakMs}");
+ Check(r.EndMs == 14830, $"end at {r.EndMs}");
+ }),
+
+ ("break time is the WillBroke entry and the hand leaves after the withdrawal latency", () =>
+ {
+ var r = PainBoxSimulation.Run(Default, 3);
+ var broke = r.Timeline.Single(e => e.Kind == EntryKind.WillBroke);
+ var out_ = r.Timeline.Single(e => e.Kind == EntryKind.Withdrew);
+ Check(broke.TimeMs == r.TimeToBreakMs, "TimeToBreakMs disagrees with the timeline");
+ Check(broke.Resolve == 0.0 && broke.Goal == Goal.Withdraw, "break entry should show resolve 0, goal Withdraw");
+ Check(out_.TimeMs - broke.TimeMs == 100, $"latency {out_.TimeMs - broke.TimeMs} ms");
+ Check(r.Timeline.Where(e => e.TimeMs < broke.TimeMs).All(e => e.Resolve > 0.0 && e.Goal == Goal.HoldHand),
+ "resolve hit zero or goal changed before the recorded break");
+ }),
+
+ ("a stronger box breaks the will sooner", () =>
+ {
+ long Break(double intensity) =>
+ PainBoxSimulation.Run(Default with { BoxIntensity = intensity }, 5).TimeToBreakMs ?? long.MaxValue;
+ Check(Break(1.0) < Break(0.8) && Break(0.8) < Break(0.6), "break times not ordered by intensity");
+ }),
+
+ ("a stronger objective pull holds the will longer", () =>
+ {
+ long Break(double pull) =>
+ PainBoxSimulation.Run(Default with { ObjectivePull = pull }, 5).TimeToBreakMs ?? long.MaxValue;
+ Check(Break(0.0) < Break(0.5) && Break(0.5) < Break(0.8), "break times not ordered by pull");
+ }),
+
+ ("disabling the box before the break ends the run with the will intact", () =>
+ {
+ var r = PainBoxSimulation.Run(Default with { BoxDisableAtSeconds = 5.0 }, 1);
+ Check(r.Outcome == Outcome.BoxDisabled, $"outcome {r.Outcome}");
+ Check(r.TimeToBreakMs is null && r.EndMs == 5000, $"break {r.TimeToBreakMs}, end {r.EndMs}");
+ Check(r.Timeline.Last().Resolve > 0.0, "resolve should be above zero");
+ }),
+
+ ("disabling the box after the break changes nothing", () =>
+ {
+ var plain = PainBoxSimulation.Run(Default, 1);
+ var late = PainBoxSimulation.Run(Default with { BoxDisableAtSeconds = 60.0 }, 1);
+ Check(plain.Render() == late.Render(), "a box disabled after the break altered the run");
+ }),
+
+ ("no stimulus: nothing flinches and the run endures", () =>
+ {
+ var r = PainBoxSimulation.Run(Default with { BoxIntensity = 0.0, MaxSeconds = 10.0 }, 1);
+ Check(r.Outcome == Outcome.Endured && r.Flinches == 0 && r.TimeToBreakMs is null, r.Summary());
+ Check(r.Timeline.All(e => e.Pain == 0.0 && e.Strain == 0.0 && e.Resolve == 1.0), "state moved without a stimulus");
+ }),
+
+ ("holding a reflex down costs strain before resolve gives out", () =>
+ {
+ var r = PainBoxSimulation.Run(Default, 1);
+ Check(r.Timeline.Any(e => e.Goal == Goal.HoldHand && e.Strain > 0.5), "no visible strain while holding");
+ Check(r.Flinches > 0, "no reflex leaked through before the break");
+ }),
+
+ ("sweep summary adds up and orders min <= median <= max", () =>
+ {
+ var s = SweepSummary.Run(Default, 1, 25);
+ Check(s.Broke + s.BoxDisabled + s.Endured == s.Runs, "outcome counts do not sum");
+ Check(s.MinBreakMs <= s.MedianBreakMs && s.MedianBreakMs <= s.MaxBreakMs, "order");
+ Check(s.Results[0].Seed == 1 && s.Results[24].Seed == 25, "seeds not consecutive");
+ }),
+
+ ("CLI run is deterministic and sweep reports a break-time line", () =>
+ {
+ string Run(params string[] a) { var w = new StringWriter(); BadDay.Cli.Program.Execute(a, w); return w.ToString(); }
+ Check(Run("run", "--seed", "9") == Run("run", "--seed", "9"), "CLI output differs between runs");
+ Check(Run("run", "--seed", "1").Contains("will broke at 14730 ms"), "CLI summary missing break time");
+ Check(Run("sweep", "--seed", "1", "--count", "5").Contains("time to break min/median/max ms:"), "no sweep summary");
+ bool threw = false;
+ try { Run("run"); } catch (ArgumentException) { threw = true; }
+ Check(threw, "missing --seed should be rejected");
+ }),
+ };
+
+ public static int Main()
+ {
+ int failed = 0;
+ foreach (var (name, body) in Tests)
+ {
+ try { body(); Console.WriteLine($"PASS {name}"); }
+ catch (Exception e) { failed++; Console.WriteLine($"FAIL {name}: {e.Message}"); }
+ }
+ Console.WriteLine($"{Tests.Length - failed}/{Tests.Length} passed");
+ return failed == 0 ? 0 : 1;
+ }
+
+ private static void Check(bool ok, string message)
+ {
+ if (!ok) throw new InvalidOperationException(message);
+ }
+}
diff --git a/tools/PainSweep/Program.cs b/tools/PainSweep/Program.cs
new file mode 100644
index 0000000..e61b400
--- /dev/null
+++ b/tools/PainSweep/Program.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using BadDay.Core;
+
+// Grid sweep: stimulus intensity (rows) x withdrawal-reflex suppression strength
+// (columns, PainBoxParameters.MaxSuppression). Each cell runs Seeds consecutive
+// seeds and prints the median time-to-break in seconds over the runs that broke,
+// "never" if none broke within MaxSeconds, and "(k/N)" when only k of N broke.
+// Engine-free and deterministic: same build, same table, byte for byte.
+const int Seeds = 9;
+const ulong FirstSeed = 1;
+double[] intensities = { 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0 };
+double[] suppressions = { 0.0, 0.25, 0.5, 0.75, 0.95, 1.0 };
+
+var inv = CultureInfo.InvariantCulture;
+var sb = new StringBuilder();
+sb.Append($"time to break, s (median over {Seeds} seeds from {FirstSeed}; max {new PainBoxParameters().MaxSeconds:F0} s; pull {new PainBoxParameters().ObjectivePull:F2})\n");
+sb.Append("intensity\\suppr ");
+foreach (var s in suppressions) sb.Append(s.ToString("F2", inv).PadLeft(10));
+sb.Append('\n');
+foreach (var i in intensities)
+{
+ sb.Append(i.ToString("F2", inv).PadRight(16));
+ foreach (var s in suppressions)
+ {
+ var p = new PainBoxParameters { BoxIntensity = i, MaxSuppression = s };
+ var sweep = SweepSummary.Run(p, FirstSeed, Seeds);
+ string cell = sweep.MedianBreakMs is { } ms
+ ? (ms / 1000.0).ToString("F1", inv) + (sweep.Broke < Seeds ? $"({sweep.Broke}/{Seeds})" : "")
+ : "never";
+ sb.Append(cell.PadLeft(10));
+ }
+ sb.Append('\n');
+}
+Console.Write(sb.ToString());