Skip to content

Commit 32a2d4d

Browse files
claudehyperpolymath
authored andcommitted
wip(C7 drafts): preserve UNVERIFIED player-cluster .affine drafts
The C7 (player) migration agent produced 8 .affine kernel drafts but timed out before writing any parity oracle (config.mjs), boundary proof, or evidence. These are UNVERIFIED — no gate has been run on them. They are committed only to preserve the re-decomposition head-start (the container is ephemeral); they are NOT claimed as 4-gate-green and the PR stays draft until they are. Status recorded as IN_PROGRESS / drafted_unverified in proposals/idaptik/migration-map.json (cluster C7). Next: write independent oracles, run compile/parity/boundary/assail, fix as needed, then mark C7 DONE. Kernels (unverified): CriticalRoll, PlayerAttributes, QCertifications, SkillRank, SkillAbilities, QPrograms, JessicaLoadout, JessicaBackground. https://claude.ai/code/session_01WoKhFQePiRsAj7aqnxbG8s
1 parent f17bf3a commit 32a2d4d

8 files changed

Lines changed: 920 additions & 0 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
//
3+
// CriticalRoll -- the pure probability/threshold kernel re-decomposed from
4+
// src/app/player/CriticalRoll.res. The ReScript original entangles three things:
5+
// a non-deterministic draw (Math.random()), two attribute-driven threshold
6+
// formulae for Jessica, and a three-way classification of the draw against a
7+
// success and a failure threshold shared by Jessica and Q. Per the DESIGN-VISION
8+
// ("AffineScript is the brain, JS/Pixi the senses; only primitives cross the wasm
9+
// boundary"), neither the draw nor the variant labels are the unit of migration.
10+
// What crosses is the deterministic arithmetic: the two thresholds and the band
11+
// the comparison falls into. The host owns the entropy (Math.random), the rate
12+
// tables QCertifications already serves, the rollResult record, and the outcome
13+
// labels and colours. The kernel is stateless and total: every Float the boundary
14+
// admits yields a defined Int verdict or Float threshold, never a trap.
15+
//
16+
//## Why this split, not a port of jessicaRoll/qRoll
17+
// jessicaRoll and qRoll each draw a random number, look thresholds up (Jessica by
18+
// formula, Q by host rate table), then classify. The draw is impure and the Q
19+
// lookups are discrete table reads with no arithmetic, so both stay host senses.
20+
// The arithmetic that remains -- the two Jessica formulae and the one shared
21+
// classification -- is what the co-processor owns. Both call sites feed the same
22+
// classify_outcome with their respective thresholds, so the brain has one decision
23+
// rule and the host supplies the two numbers however it sourced them.
24+
//
25+
//## Outcome encoding (the header contract for the JS host)
26+
// The rollOutcome variant collapses to an ordinal the host maps back to the
27+
// variant and thence to a label/colour:
28+
// 0 CriticalSuccess 1 Normal 2 CriticalFailure
29+
// classify_outcome returns exactly one of these three. The boundaries match the
30+
// ReScript verbatim: roll < critSuccess -> 0; else roll > 1 - critFail -> 2; else
31+
// 1. The success test wins ties at the lower edge (strict <), the failure test at
32+
// the upper edge (strict >), so a roll sitting exactly on a threshold is Normal,
33+
// identical to the ReScript if/else chain.
34+
//
35+
//## Float contract
36+
// roll, crit_success and crit_fail cross as f64 (proven to marshal both ways;
37+
// cf. migration/bindings/Maths.wasm and PlayerHP.wasm). roll is the host's draw in
38+
// [0, 1); crit_success and crit_fail are probabilities in [0, 1]. The float
39+
// comparisons drive an Int verdict, which the wasm backend compares as i32, the
40+
// working subset for float-fed branches. jessica_crit_success and
41+
// jessica_crit_fail return f64 thresholds the host either feeds straight back into
42+
// classify_outcome or displays in the rollResult.
43+
44+
//## Jessica's critical-success threshold
45+
// min(0.25, 0.05 + (primaryStat - 100)/1000), the jessicaRoll line verbatim in
46+
// f64. Higher primary stat raises the chance; the cap at 0.25 is the design
47+
// ceiling. primaryStat arrives as a Float, so the division is pure f64 and no
48+
// int->float is implicated. Branchless via min_float rather than a Float if/else.
49+
pub fn jessica_crit_success(primary_stat: Float) -> Float {
50+
min_float(0.25, 0.05 + (primary_stat - 100.0) / 1000.0)
51+
}
52+
53+
//## Jessica's critical-failure threshold
54+
// max(0.01, 0.10 + (100 - wil)/1000), the jessicaRoll line verbatim in f64.
55+
// Higher willpower lowers the chance; the floor at 0.01 is the design minimum.
56+
// Branchless via max_float. wil is a Float host-side, so the subtract and divide
57+
// are pure f64.
58+
pub fn jessica_crit_fail(wil: Float) -> Float {
59+
max_float(0.01, 0.10 + (100.0 - wil) / 1000.0)
60+
}
61+
62+
//## Shared three-way classification
63+
// The outcome if/else chain shared by jessicaRoll and qRoll, in isolation:
64+
// if roll < crit_success -> CriticalSuccess (0)
65+
// else if roll > 1.0 - crit_fail -> CriticalFailure (2)
66+
// else -> Normal (1)
67+
// Flat guarded returns rather than nested if/else; the parser dislikes deep
68+
// nesting and the order of the guards preserves the ReScript precedence (success
69+
// tested first, then failure, Normal as the fall-through). The strict comparisons
70+
// mean a roll exactly on a boundary is Normal, matching the ReScript.
71+
pub fn classify_outcome(roll: Float, crit_success: Float, crit_fail: Float) -> Int {
72+
if roll < crit_success { return 0; }
73+
if roll > 1.0 - crit_fail { return 2; }
74+
1
75+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// JessicaBackground -- the operative-background numeric core extracted from
5+
// src/app/player/JessicaBackground.res. The rank/XP progression tables
6+
// (rankValue, rankXpThreshold, canPromote) already live in the SkillRank
7+
// co-processor and are NOT duplicated here; this binding carries the remaining
8+
// numeric tables that switch on the `background` variant: the per-background
9+
// attribute-bonus matrix that getBonus folds, and the starting-skill grant that
10+
// makeSkillSet applies (which skill category a background seeds at Trained).
11+
//
12+
// Per the DESIGN-VISION ("AffineScript is the brain, the JS/Pixi host the
13+
// senses; only primitives cross the wasm boundary"), the host keeps every
14+
// string (display names, descriptions), the attributeBonus and skill records,
15+
// the skillSet construction, and the variant tags. AffineScript owns only the
16+
// scalar tables. The `background` variant collapses to its JessicaBackground.all
17+
// index (0 Assault .. 5 Logistics), exactly the dense tag order the .res.mjs
18+
// emits; each attribute collapses to a column ordinal; each skill category to
19+
// its JessicaBackground skillCategory index.
20+
//
21+
// getBonus returns a six-field float record per background. A record cannot
22+
// cross the boundary, so it re-decomposes into a single bonus(background, attr)
23+
// kernel selected by an attribute-column ordinal; the host reassembles the six
24+
// columns into its attributeBonus record. The values are the baseline shifts
25+
// added to the base 100.0 — ALL of the bonus shifts are whole numbers (e.g.
26+
// Assault: str+20, dex+10, con+15, int_-5, wil+5, cha-5), so they cross as Int
27+
// without loss. The host receives an integer shift and applies it to the float
28+
// base (100.0 + shift). The signed integer suffices and avoids Float literals
29+
// in the wasm codegen, which the v0.1.1 backend does not lower cleanly to f64
30+
// at the ABI level.
31+
//
32+
//## Background encoding (the header contract for the JS host)
33+
// Backgrounds index the JessicaBackground.all array order:
34+
// 0 Assault 1 Recon 2 Engineer 3 Signals 4 Medic 5 Logistics
35+
// An off-domain background index yields 0 from bonus (a neutral shift) and -1
36+
// from start_skill (a sentinel the host reads as "no grant").
37+
//
38+
//## Attribute-column encoding (the second contract, for the bonus selector)
39+
// Columns index the attributeBonus record field order:
40+
// 0 str 1 dex 2 con 3 int_ 4 wil 5 cha
41+
// An off-domain column yields 0.
42+
//
43+
//## Skill-category encoding (the third contract, for the starting grant)
44+
// Categories index the JessicaBackground skillCategory variant order:
45+
// 0 Infiltration 1 CombatSkill 2 Athletics 3 Observation
46+
// 4 TechLiteracy 5 Fieldcraft 6 Composure
47+
// start_skill returns the category index a background seeds at Trained, or -1
48+
// when the background is off-domain.
49+
//
50+
// PURE: integers only, no floats, no strings, no arrays, no effects, no I/O.
51+
// Each entry point is a flat sequence of guarded returns rather than nested
52+
// if/else, which keeps the wasm backend's parser within its nesting tolerance.
53+
54+
//## Attribute bonus for the STR column
55+
// All shifts are whole numbers; crossing as Int preserves the value exactly.
56+
fn bonus_str(background: Int) -> Int {
57+
if background == 0 { return 20; }
58+
if background == 1 { return -5; }
59+
if background == 2 { return 5; }
60+
if background == 3 { return -5; }
61+
if background == 4 { return 0; }
62+
if background == 5 { return 10; }
63+
0
64+
}
65+
66+
//## Attribute bonus for the DEX column
67+
fn bonus_dex(background: Int) -> Int {
68+
if background == 0 { return 10; }
69+
if background == 1 { return 15; }
70+
if background == 2 { return 5; }
71+
if background == 3 { return 5; }
72+
if background == 4 { return 5; }
73+
if background == 5 { return 0; }
74+
0
75+
}
76+
77+
//## Attribute bonus for the CON column
78+
fn bonus_con(background: Int) -> Int {
79+
if background == 0 { return 15; }
80+
if background == 1 { return 0; }
81+
if background == 2 { return 5; }
82+
if background == 3 { return 0; }
83+
if background == 4 { return 20; }
84+
if background == 5 { return 10; }
85+
0
86+
}
87+
88+
//## Attribute bonus for the INT column
89+
fn bonus_int(background: Int) -> Int {
90+
if background == 0 { return -5; }
91+
if background == 1 { return 10; }
92+
if background == 2 { return 15; }
93+
if background == 3 { return 10; }
94+
if background == 4 { return 10; }
95+
if background == 5 { return 5; }
96+
0
97+
}
98+
99+
//## Attribute bonus for the WIL column
100+
fn bonus_wil(background: Int) -> Int {
101+
if background == 0 { return 5; }
102+
if background == 1 { return 10; }
103+
if background == 2 { return 5; }
104+
if background == 3 { return 10; }
105+
if background == 4 { return 10; }
106+
if background == 5 { return 0; }
107+
0
108+
}
109+
110+
//## Attribute bonus for the CHA column
111+
fn bonus_cha(background: Int) -> Int {
112+
if background == 0 { return -5; }
113+
if background == 1 { return 0; }
114+
if background == 2 { return -5; }
115+
if background == 3 { return 10; }
116+
if background == 4 { return 0; }
117+
if background == 5 { return 10; }
118+
0
119+
}
120+
121+
//## Attribute bonus selector
122+
// bonus(background, attr): getBonus re-decomposed to a single signed integer
123+
// shift per (background, attribute-column) pairing. The host calls this six
124+
// times, once per column ordinal, and applies each shift to the float base
125+
// (100.0 + shift). Off-domain column yields 0; off-domain background yields 0
126+
// from each column kernel.
127+
pub fn bonus(background: Int, attr: Int) -> Int {
128+
if attr == 0 { return bonus_str(background); }
129+
if attr == 1 { return bonus_dex(background); }
130+
if attr == 2 { return bonus_con(background); }
131+
if attr == 3 { return bonus_int(background); }
132+
if attr == 4 { return bonus_wil(background); }
133+
if attr == 5 { return bonus_cha(background); }
134+
0
135+
}
136+
137+
//## Starting-skill grant
138+
// makeSkillSet re-decomposed. Each background seeds exactly one skill category
139+
// at Trained rank; this returns that category's index. Mirrors the switch:
140+
// Assault -> CombatSkill(1) Recon -> Observation(3)
141+
// Engineer -> TechLiteracy(4) Signals -> TechLiteracy(4)
142+
// Medic -> Composure(6) Logistics -> Fieldcraft(5)
143+
// Off-domain background yields -1 (no grant), distinct from any valid 0..6 index.
144+
pub fn start_skill(background: Int) -> Int {
145+
if background == 0 { return 1; }
146+
if background == 1 { return 3; }
147+
if background == 2 { return 4; }
148+
if background == 3 { return 4; }
149+
if background == 4 { return 6; }
150+
if background == 5 { return 5; }
151+
-1
152+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
//
3+
// JessicaLoadout -- the loadout selection/validation co-processor, the pure
4+
// integer core extracted from src/app/player/JessicaLoadout.res. Per the
5+
// DESIGN-VISION ("AffineScript is the brain, JS/Pixi the senses; they pass
6+
// primitives across the wasm boundary"), the JS host keeps every string (item
7+
// display names, descriptions), the metadata records, and the array filters that
8+
// build the available-item lists; AffineScript owns only the access arithmetic
9+
// and the consumable charge bookkeeping.
10+
//
11+
// The ReScript original folds each weapon/tool variant through getWeaponInfo /
12+
// getToolInfo to a tier plus an allowedBackgrounds array, then canUseWeapon /
13+
// canUseTool grant access when the tier is T1, or when the allowed set is empty,
14+
// or when it contains the operative's background. We re-decompose those getInfo
15+
// switches (whose strings and arrays cannot cross the boundary) into the integer
16+
// bands the switch already produces: each item is its index, each tier its
17+
// ordinal, each background its index, and the allowed-set membership becomes a
18+
// flat guarded comparison per (item, background) pairing. No strings, no arrays,
19+
// no effects cross; the encoding IS the access rule.
20+
//
21+
//## Item encoding (the header contract for the JS host)
22+
// Weapons index the allWeapons array order:
23+
// 0 StunPistol 1 TacticalBaton 2 TranqRifle
24+
// 3 BreachingShotgun 4 Railgun 5 EMPPistol
25+
// Tools index the allTools array order:
26+
// 0 LockpickSet 1 GrapplingHook 2 MotionSensor 3 SignalJammer
27+
// 4 TraumaKit 5 C4Charge 6 GhillieWrap 7 FieldSurgeryKit
28+
// Consumables index the allConsumables array order:
29+
// 0 Flashbang 1 SmokeGrenade 2 ZipTies
30+
// 3 AdrenalineShot 4 DecoyPhone 5 FibreTap
31+
// Any index outside its band is off-domain: the tier functions return 0 (a
32+
// sentinel the host reads as "no such item"), can_use_weapon / can_use_tool
33+
// return 0 (denied), and consumable_uses returns 0.
34+
//
35+
//## Background encoding (the second contract, for the access predicate)
36+
// Backgrounds index the JessicaBackground.all array order, exactly the dense
37+
// tag order the .res.mjs emits:
38+
// 0 Assault 1 Recon 2 Engineer 3 Signals 4 Medic 5 Logistics
39+
// An off-domain background index matches no allowed set, so a T2/T3 item with a
40+
// non-empty set denies it, mirroring the ReScript Array.some returning false.
41+
//
42+
//## Tier encoding
43+
// The implicit tier ordering the access switch walks:
44+
// 1 T1 (universal) 2 T2 (background OR Trained) 3 T3 (background AND Veteran+)
45+
// can_use only distinguishes T1 (always granted) from T2/T3 (set-gated); the
46+
// rank arithmetic the T2/T3 distinction feeds lives in SkillRank, not here.
47+
48+
//## Weapon tier
49+
// weapon_tier(): the tier ordinal each getWeaponInfo arm assigns. Off-domain
50+
// weapon indices yield 0. Flat guarded returns avoid the deep if/else nesting the
51+
// parser dislikes.
52+
pub fn weapon_tier(weapon: Int) -> Int {
53+
if weapon == 0 { return 1; }
54+
if weapon == 1 { return 1; }
55+
if weapon == 2 { return 2; }
56+
if weapon == 3 { return 2; }
57+
if weapon == 4 { return 3; }
58+
if weapon == 5 { return 3; }
59+
0
60+
}
61+
62+
//## Tool tier
63+
// tool_tier(): the tier ordinal each getToolInfo arm assigns. Off-domain tool
64+
// indices yield 0.
65+
pub fn tool_tier(tool: Int) -> Int {
66+
if tool == 0 { return 1; }
67+
if tool == 1 { return 1; }
68+
if tool == 2 { return 2; }
69+
if tool == 3 { return 2; }
70+
if tool == 4 { return 2; }
71+
if tool == 5 { return 2; }
72+
if tool == 6 { return 3; }
73+
if tool == 7 { return 3; }
74+
0
75+
}
76+
77+
//## Consumable charges
78+
// consumable_uses(): the initial charge count each getConsumableInfo arm assigns,
79+
// the value makeLoadout seeds consumableUsesLeft with. Off-domain indices yield 0.
80+
pub fn consumable_uses(consumable: Int) -> Int {
81+
if consumable == 0 { return 2; }
82+
if consumable == 1 { return 2; }
83+
if consumable == 2 { return 3; }
84+
if consumable == 3 { return 1; }
85+
if consumable == 4 { return 2; }
86+
if consumable == 5 { return 1; }
87+
0
88+
}
89+
90+
//## Weapon access predicate
91+
// canUseWeapon re-decomposed. T1 weapons are universal (return 1 regardless of
92+
// background). The two T1 weapons (0, 1) short-circuit first. The four gated
93+
// weapons each carry a non-empty allowed set, so the empty-set arm of the
94+
// ReScript switch is unreachable for them and need not be modelled; access is
95+
// granted iff the background index is a member, tested as a flat guarded compare
96+
// per (weapon, background) pairing. Any unmatched pairing falls through to 0
97+
// (denied), which also covers every off-domain weapon and background.
98+
// 2 TranqRifle -> Assault(0), Recon(1)
99+
// 3 BreachingShotgun -> Assault(0), Engineer(2)
100+
// 4 Railgun -> Assault(0)
101+
// 5 EMPPistol -> Engineer(2), Signals(3)
102+
pub fn can_use_weapon(weapon: Int, background: Int) -> Int {
103+
if weapon == 0 { return 1; }
104+
if weapon == 1 { return 1; }
105+
if weapon == 2 { if background == 0 { return 1; } if background == 1 { return 1; } return 0; }
106+
if weapon == 3 { if background == 0 { return 1; } if background == 2 { return 1; } return 0; }
107+
if weapon == 4 { if background == 0 { return 1; } return 0; }
108+
if weapon == 5 { if background == 2 { return 1; } if background == 3 { return 1; } return 0; }
109+
0
110+
}
111+
112+
//## Tool access predicate
113+
// canUseTool re-decomposed, same shape. The two T1 tools (0, 1) are universal.
114+
// The six gated tools each carry a single-background allowed set.
115+
// 2 MotionSensor -> Recon(1)
116+
// 3 SignalJammer -> Signals(3)
117+
// 4 TraumaKit -> Medic(4)
118+
// 5 C4Charge -> Engineer(2)
119+
// 6 GhillieWrap -> Recon(1)
120+
// 7 FieldSurgeryKit -> Medic(4)
121+
pub fn can_use_tool(tool: Int, background: Int) -> Int {
122+
if tool == 0 { return 1; }
123+
if tool == 1 { return 1; }
124+
if tool == 2 { if background == 1 { return 1; } return 0; }
125+
if tool == 3 { if background == 3 { return 1; } return 0; }
126+
if tool == 4 { if background == 4 { return 1; } return 0; }
127+
if tool == 5 { if background == 2 { return 1; } return 0; }
128+
if tool == 6 { if background == 1 { return 1; } return 0; }
129+
if tool == 7 { if background == 4 { return 1; } return 0; }
130+
0
131+
}
132+
133+
//## Consumable charge decrement
134+
// useConsumable re-decomposed. The ReScript guard is `if usesLeft > 0 then
135+
// usesLeft - 1`. This returns the charges remaining AFTER one use: one fewer when
136+
// any remain, else a floored 0 so a malformed negative or zero count can never
137+
// drive the counter below empty. The host reads "decremented < before" as the
138+
// boolean success the ReScript returns.
139+
pub fn use_consumable(uses_left: Int) -> Int {
140+
if uses_left > 0 { return uses_left - 1; }
141+
0
142+
}

0 commit comments

Comments
 (0)