Pain box first slice: deterministic virtual human, will-break timeline #1

Merged
nibu merged 3 commits from nibu/pain-box-first-slice into main 2026-11-10 17:01:26 +00:00
14 changed files with 705 additions and 1 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
bin/
obj/

View file

@ -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.

54
docs/pain-sweep.md Normal file
View file

@ -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
```

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>baddaysim</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BadDay.Core\BadDay.Core.csproj" />
</ItemGroup>
</Project>

79
src/BadDay.Cli/Program.cs Normal file
View file

@ -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<string, string> o, string key, double fallback) =>
o.TryGetValue(key, out var v) ? double.Parse(v, CultureInfo.InvariantCulture) : fallback;
private static Dictionary<string, string> ParseOptions(string[] args)
{
var o = new Dictionary<string, string>();
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;
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<RootNamespace>BadDay.Core</RootNamespace>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,52 @@
namespace BadDay.Core;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>When set, the box switches off at this time (the game disables it).</summary>
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;
}

View file

@ -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
{
/// <summary>Resolve reached zero before the box went off.</summary>
WillBroke,
/// <summary>The box was disabled while the will still held.</summary>
BoxDisabled,
/// <summary>MaxSeconds passed with the box on and the will intact.</summary>
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,
/// <summary>Time the run ended, ms.</summary>
long EndMs,
/// <summary>Time resolve hit zero, ms; null unless <see cref="Outcome"/> is WillBroke.</summary>
long? TimeToBreakMs,
int Flinches,
IReadOnlyList<TimelineEntry> Timeline)
{
/// <summary>
/// Deterministic text form (invariant culture, fixed precision). Two runs
/// with the same seed and parameters render byte-identical.
/// </summary>
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",
};
}

View file

@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
namespace BadDay.Core;
/// <summary>
/// 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.
/// </summary>
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<TimelineEntry>();
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);
}

View file

@ -0,0 +1,26 @@
namespace BadDay.Core;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Uniform in [0, 1) with 53 bits.</summary>
public double NextDouble() => (NextUInt64() >> 11) * (1.0 / 9007199254740992.0);
/// <summary>Uniform in [-1, 1).</summary>
public double NextSigned() => NextDouble() * 2.0 - 1.0;
}

27
src/BadDay.Core/Sweep.cs Normal file
View file

@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.Linq;
namespace BadDay.Core;
/// <summary>Runs consecutive seeds and summarises time-to-break. The break-time measure.</summary>
public sealed record SweepSummary(
int Runs, int Broke, int BoxDisabled, int Endured,
long? MinBreakMs, long? MedianBreakMs, long? MaxBreakMs,
IReadOnlyList<PainBoxResult> Results)
{
public static SweepSummary Run(PainBoxParameters p, ulong firstSeed, int count)
{
var results = new List<PainBoxResult>(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);
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- No test framework package: the slice has no dependencies. Running this project (dotnet run, project path
tests/BadDay.Tests) runs every test and exits non-zero on any failure. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\BadDay.Core\BadDay.Core.csproj" />
<ProjectReference Include="..\..\src\BadDay.Cli\BadDay.Cli.csproj" />
</ItemGroup>
</Project>

View file

@ -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);
}
}

View file

@ -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());