Pain box first slice: deterministic virtual human, will-break timeline #1
14 changed files with 705 additions and 1 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
bin/
|
||||
obj/
|
||||
71
README.md
71
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.
|
||||
|
|
|
|||
54
docs/pain-sweep.md
Normal file
54
docs/pain-sweep.md
Normal 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
|
||||
```
|
||||
12
src/BadDay.Cli/BadDay.Cli.csproj
Normal file
12
src/BadDay.Cli/BadDay.Cli.csproj
Normal 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
79
src/BadDay.Cli/Program.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
8
src/BadDay.Core/BadDay.Core.csproj
Normal file
8
src/BadDay.Core/BadDay.Core.csproj
Normal 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>
|
||||
52
src/BadDay.Core/PainBoxParameters.cs
Normal file
52
src/BadDay.Core/PainBoxParameters.cs
Normal 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;
|
||||
}
|
||||
61
src/BadDay.Core/PainBoxResult.cs
Normal file
61
src/BadDay.Core/PainBoxResult.cs
Normal 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",
|
||||
};
|
||||
}
|
||||
124
src/BadDay.Core/PainBoxSimulation.cs
Normal file
124
src/BadDay.Core/PainBoxSimulation.cs
Normal 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);
|
||||
}
|
||||
26
src/BadDay.Core/SplitMix64.cs
Normal file
26
src/BadDay.Core/SplitMix64.cs
Normal 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
27
src/BadDay.Core/Sweep.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
14
tests/BadDay.Tests/BadDay.Tests.csproj
Normal file
14
tests/BadDay.Tests/BadDay.Tests.csproj
Normal 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>
|
||||
138
tests/BadDay.Tests/Program.cs
Normal file
138
tests/BadDay.Tests/Program.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
38
tools/PainSweep/Program.cs
Normal file
38
tools/PainSweep/Program.cs
Normal 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());
|
||||
Loading…
Reference in a new issue