From f86fc743a8b0d4196c371954aae222f6d3076122 Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 6 Nov 2026 07:12:02 +0000 Subject: [PATCH 1/3] Pain box first slice: deterministic nerve/drive/will simulation, CLI, tests Engine-free C# core (nociceptor with sensitisation and conduction delay, suppressible withdrawal reflex with strain, pain/resolve drive, HoldHand and Withdraw goals), a seeded run/sweep CLI that prints a timeline and time to break, and tests for determinism and the break-time measure. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 2 + README.md | 71 ++++++++++++- src/BadDay.Cli/BadDay.Cli.csproj | 12 +++ src/BadDay.Cli/Program.cs | 79 ++++++++++++++ src/BadDay.Core/BadDay.Core.csproj | 8 ++ src/BadDay.Core/PainBoxParameters.cs | 52 ++++++++++ src/BadDay.Core/PainBoxResult.cs | 61 +++++++++++ src/BadDay.Core/PainBoxSimulation.cs | 124 ++++++++++++++++++++++ src/BadDay.Core/SplitMix64.cs | 26 +++++ src/BadDay.Core/Sweep.cs | 27 +++++ tests/BadDay.Tests/BadDay.Tests.csproj | 14 +++ tests/BadDay.Tests/Program.cs | 138 +++++++++++++++++++++++++ 12 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 src/BadDay.Cli/BadDay.Cli.csproj create mode 100644 src/BadDay.Cli/Program.cs create mode 100644 src/BadDay.Core/BadDay.Core.csproj create mode 100644 src/BadDay.Core/PainBoxParameters.cs create mode 100644 src/BadDay.Core/PainBoxResult.cs create mode 100644 src/BadDay.Core/PainBoxSimulation.cs create mode 100644 src/BadDay.Core/SplitMix64.cs create mode 100644 src/BadDay.Core/Sweep.cs create mode 100644 tests/BadDay.Tests/BadDay.Tests.csproj create mode 100644 tests/BadDay.Tests/Program.cs 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/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..c83c755 --- /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); + } +} From 4b54f207a79b835d2bfc2f53f4be351bd86516ec Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 6 Nov 2026 12:16:13 +0000 Subject: [PATCH 2/3] Pain sweep: intensity x suppression time-to-break table, csc-built Co-Authored-By: Claude Sonnet 5.5 --- docs/pain-sweep.md | 54 ++++++++++++++++++++++++++++++++++++++ tools/PainSweep/Program.cs | 38 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 docs/pain-sweep.md create mode 100644 tools/PainSweep/Program.cs 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/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()); From 2bade7b30439b81ea2259c917a17d2639daeec1d Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 10 Nov 2026 17:00:23 +0000 Subject: [PATCH 3/3] Test project: drop the double hyphen from the csproj comment MSBuild refuses an XML comment containing "--", so dotnet run on the test project failed to load it. The README command now runs. Co-Authored-By: Claude Sonnet 5.5 --- tests/BadDay.Tests/BadDay.Tests.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/BadDay.Tests/BadDay.Tests.csproj b/tests/BadDay.Tests/BadDay.Tests.csproj index c83c755..5bb6e2b 100644 --- a/tests/BadDay.Tests/BadDay.Tests.csproj +++ b/tests/BadDay.Tests/BadDay.Tests.csproj @@ -1,6 +1,6 @@ - + Exe net10.0