CB-WP-0042 T01-T04: H2's scoped stress, with the named defects caught
Some checks failed
ci / check (push) Failing after 4s

H2 is ground-game's answer to our H1 reading — that a flat +1 to every
seat is a solve-rate tax scaling with the number of Problems. Unclaimed
Problems now tick only the seats in scope: global (all), personal (the
owner), bond (the owner's Bond network over Bond edges only, degree 0
falling back to personal), assigned by hidden priority so 2p never has the
bond card in play.

T01: the package is vendored with digests, and H2's Problems.csv is r0's
with one column added and NOTHING else changed — checked, not assumed,
because the delta claims deal_and_thresholds unchanged and a silent
difference would make every H2-vs-baseline comparison a comparison of two
boards as well as two rule sets. Scopes are read from the column, not
derived from the priority in Rust: F25 exists because we hardcoded numbers
the edition already carried.

T02: owner and scope are new ProblemState fields, both Option and both
skipped when None, so a baseline state serialises without them and every
recorded scenario's hash is untouched — asserted on the JSON, not assumed.
with_variant() replaces the bare field write, because state.variant = v
would leave owners unassigned: a silently wrong game rather than a failing
one.

T03: every named defect is mutation-proven — traversing Rivalry edges,
applying stacking once, a degree-0 owner ticking everyone, personal
hitting everyone. The degree-0 mutation MISSED first: the fallback lives
inside bond_network and the mutation broke the None-owner arm instead, a
different branch. It stayed green until aimed at the path the test
exercises. A mutation that misses is not evidence the test works.

T04: ownership is not a permission. Filtering SOLVE to the owner turns it
red, which is the regression this task exists for — the engine had no
owner concept before T02 added one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 16:06:21 +02:00
parent 05d1a9aada
commit 04e26b3077
9 changed files with 900 additions and 7 deletions

View file

@ -0,0 +1,21 @@
problem_id,scenario_id,visibility,stress_scope,hidden_priority,title,problem_text,required_solution,symbol_id,point_value,front_rules,reveal_effect,unresolved_effect,back_design_id
PRB_01_S,SCN_01,Surface,global,0,Deadline Missed,A promised result was not delivered when expected.,Repair,SYM_REPAIR,2,Resolve with Repair. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=global (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM_SURFACE
PRB_01_1,SCN_01,Hidden,personal,1,Unclear Ownership,Responsibility for the commitment and the work was never made explicit.,Clarify,SYM_CLARIFY,2,Resolve with Clarify. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_01_2,SCN_01,Hidden,personal,2,Unspoken Overload,The work required more capacity than someone could safely or fairly provide.,Boundary,SYM_BOUNDARY,2,Resolve with Boundary. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_01_3,SCN_01,Hidden,bond,3,Bad News Was Delayed,A warning was withheld until the remaining options became worse.,Repair,SYM_REPAIR,3,Resolve with Repair. Value: 3.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=bond (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_01_4,SCN_01,Hidden,personal,4,No Checkpoint Process,The group had no reliable moment for testing progress and changing course.,Change,SYM_CHANGE,3,Resolve with Change. Value: 3.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_02_S,SCN_02,Surface,global,0,Shared Task Left Undone,"A recurring responsibility was not completed, and others absorbed the impact.",Repair,SYM_REPAIR,2,Resolve with Repair. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=global (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM_SURFACE
PRB_02_1,SCN_02,Hidden,personal,1,Different Standards,"Players were using different definitions of complete, timely, or fair.",Clarify,SYM_CLARIFY,2,Resolve with Clarify. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_02_2,SCN_02,Hidden,personal,2,Invisible Workload,Some contributions and constraints were not visible to the group.,Boundary,SYM_BOUNDARY,2,Resolve with Boundary. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_02_3,SCN_02,Hidden,bond,3,Resentment Never Raised,Frustration accumulated without a direct request or acknowledgement.,Repair,SYM_REPAIR,3,Resolve with Repair. Value: 3.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=bond (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_02_4,SCN_02,Hidden,personal,4,No Ownership Routine,The group relied on goodwill instead of a dependable allocation method.,Change,SYM_CHANGE,3,Resolve with Change. Value: 3.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_03_S,SCN_03,Surface,global,0,Decision Announced as Settled,A group-affecting choice was presented as final before meaningful agreement.,Boundary,SYM_BOUNDARY,2,Resolve with Boundary. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=global (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM_SURFACE
PRB_03_1,SCN_03,Hidden,personal,1,Mandate Was Ambiguous,"It was unclear who could decide, advise, consent, or veto.",Clarify,SYM_CLARIFY,2,Resolve with Clarify. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_03_2,SCN_03,Hidden,personal,2,Contributions Were Dismissed,Relevant input was ignored or treated as less legitimate.,Repair,SYM_REPAIR,2,Resolve with Repair. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_03_3,SCN_03,Hidden,bond,3,One Voice Spoke for Others,A player claimed authority to represent people who had not agreed.,Boundary,SYM_BOUNDARY,3,Resolve with Boundary. Value: 3.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=bond (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_03_4,SCN_03,Hidden,personal,4,No Decision Rule,The group had no shared method for turning discussion into a legitimate choice.,Change,SYM_CHANGE,3,Resolve with Change. Value: 3.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_04_S,SCN_04,Surface,global,0,Private Information Spread,Information moved beyond the circle in which it was originally shared.,Repair,SYM_REPAIR,2,Resolve with Repair. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=global (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM_SURFACE
PRB_04_1,SCN_04,Hidden,personal,1,Confidentiality Was Assumed,The players never made the scope of confidentiality explicit.,Clarify,SYM_CLARIFY,2,Resolve with Clarify. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_04_2,SCN_04,Hidden,personal,2,Exposure Caused Harm,"The sharing changed another player's safety, reputation, or freedom to choose.",Repair,SYM_REPAIR,2,Resolve with Repair. Value: 2.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_04_3,SCN_04,Hidden,bond,3,Consent Boundary Was Ignored,A clear or reasonably expected limit on sharing was crossed.,Boundary,SYM_BOUNDARY,3,Resolve with Boundary. Value: 3.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=bond (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
PRB_04_4,SCN_04,Hidden,personal,4,No Sharing Protocol,"The group lacked a repeatable rule for consent, need-to-know, and escalation.",Change,SYM_CHANGE,3,Resolve with Change. Value: 3.,None in the core set.,"H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all).",BACK_PROBLEM
1 problem_id scenario_id visibility stress_scope hidden_priority title problem_text required_solution symbol_id point_value front_rules reveal_effect unresolved_effect back_design_id
2 PRB_01_S SCN_01 Surface global 0 Deadline Missed A promised result was not delivered when expected. Repair SYM_REPAIR 2 Resolve with Repair. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=global (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM_SURFACE
3 PRB_01_1 SCN_01 Hidden personal 1 Unclear Ownership Responsibility for the commitment and the work was never made explicit. Clarify SYM_CLARIFY 2 Resolve with Clarify. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
4 PRB_01_2 SCN_01 Hidden personal 2 Unspoken Overload The work required more capacity than someone could safely or fairly provide. Boundary SYM_BOUNDARY 2 Resolve with Boundary. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
5 PRB_01_3 SCN_01 Hidden bond 3 Bad News Was Delayed A warning was withheld until the remaining options became worse. Repair SYM_REPAIR 3 Resolve with Repair. Value: 3. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=bond (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
6 PRB_01_4 SCN_01 Hidden personal 4 No Checkpoint Process The group had no reliable moment for testing progress and changing course. Change SYM_CHANGE 3 Resolve with Change. Value: 3. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
7 PRB_02_S SCN_02 Surface global 0 Shared Task Left Undone A recurring responsibility was not completed, and others absorbed the impact. Repair SYM_REPAIR 2 Resolve with Repair. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=global (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM_SURFACE
8 PRB_02_1 SCN_02 Hidden personal 1 Different Standards Players were using different definitions of complete, timely, or fair. Clarify SYM_CLARIFY 2 Resolve with Clarify. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
9 PRB_02_2 SCN_02 Hidden personal 2 Invisible Workload Some contributions and constraints were not visible to the group. Boundary SYM_BOUNDARY 2 Resolve with Boundary. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
10 PRB_02_3 SCN_02 Hidden bond 3 Resentment Never Raised Frustration accumulated without a direct request or acknowledgement. Repair SYM_REPAIR 3 Resolve with Repair. Value: 3. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=bond (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
11 PRB_02_4 SCN_02 Hidden personal 4 No Ownership Routine The group relied on goodwill instead of a dependable allocation method. Change SYM_CHANGE 3 Resolve with Change. Value: 3. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
12 PRB_03_S SCN_03 Surface global 0 Decision Announced as Settled A group-affecting choice was presented as final before meaningful agreement. Boundary SYM_BOUNDARY 2 Resolve with Boundary. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=global (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM_SURFACE
13 PRB_03_1 SCN_03 Hidden personal 1 Mandate Was Ambiguous It was unclear who could decide, advise, consent, or veto. Clarify SYM_CLARIFY 2 Resolve with Clarify. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
14 PRB_03_2 SCN_03 Hidden personal 2 Contributions Were Dismissed Relevant input was ignored or treated as less legitimate. Repair SYM_REPAIR 2 Resolve with Repair. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
15 PRB_03_3 SCN_03 Hidden bond 3 One Voice Spoke for Others A player claimed authority to represent people who had not agreed. Boundary SYM_BOUNDARY 3 Resolve with Boundary. Value: 3. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=bond (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
16 PRB_03_4 SCN_03 Hidden personal 4 No Decision Rule The group had no shared method for turning discussion into a legitimate choice. Change SYM_CHANGE 3 Resolve with Change. Value: 3. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
17 PRB_04_S SCN_04 Surface global 0 Private Information Spread Information moved beyond the circle in which it was originally shared. Repair SYM_REPAIR 2 Resolve with Repair. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=global (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM_SURFACE
18 PRB_04_1 SCN_04 Hidden personal 1 Confidentiality Was Assumed The players never made the scope of confidentiality explicit. Clarify SYM_CLARIFY 2 Resolve with Clarify. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
19 PRB_04_2 SCN_04 Hidden personal 2 Exposure Caused Harm The sharing changed another player's safety, reputation, or freedom to choose. Repair SYM_REPAIR 2 Resolve with Repair. Value: 2. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
20 PRB_04_3 SCN_04 Hidden bond 3 Consent Boundary Was Ignored A clear or reasonably expected limit on sharing was crossed. Boundary SYM_BOUNDARY 3 Resolve with Boundary. Value: 3. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=bond (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM
21 PRB_04_4 SCN_04 Hidden personal 4 No Sharing Protocol The group lacked a repeatable rule for consent, need-to-know, and escalation. Change SYM_CHANGE 3 Resolve with Change. Value: 3. None in the core set. H2: while unclaimed, +1 Stress at Round End to stress_scope=personal (personal=owner; bond=owner's Bond network; global=all). BACK_PROBLEM

View file

@ -0,0 +1,23 @@
order,section,heading,body
1,Object,What players are trying to do,"Uncover hidden Problems and solve them with matching Solutions before the end of Round 5. Support, Attack, Bonds, Rivalries, Stress, DARVO, and GROUND determine how much freedom each player retains while doing so. The game is for 26 players in every Mode, including SHARED GROUND."
2,Setup,Choose the game,"Choose one Scenario card and one Mode card. Always use the Scenario's Surface Problem. Then add hidden Problems by player count: priorities 12 for 2 players, 13 for 34 players, and 14 for 56 players. Surface is never optional and is never counted as one of those hidden priorities."
3,Setup,Problems in play and available value,"Problems in play = Surface + the selected hidden priorities. With the core problem values (2+2+2+3+3), available points are 6 at 2 players, 9 at 34 players, and 12 at 56 players. Every published setup must keep the Scenario threshold at or below that available total so the group can win in principle."
4,Setup,Prepare each player,"Each player takes one mat, the five Action cards with their player symbol, one Stress marker at 2, one ready Freedom token, one DARVO marker at OFF, one Focus/Blame token, and two relation-link tokens. Deal two Solution cards to each player."
5,Setup,Prepare the table,"Place the Surface Problem face up and the selected hidden Problems face down. Put the remaining Solution deck within reach. Give the Lead marker to a random player and place the Round marker on 1. H2 stress scope: each Problem has a printed stress_scope (global / personal / bond). Assign owners for every personal and bond Problem in play: in ascending hidden_priority order, assign the next seat clockwise starting from Lead (each assignment uses the next seat). Place that seat's owner marker on the card (or note it). Global Problems have no owner."
6,Round,1. Select,"Every player chooses one Action face down and places it beside its target where needed. At Stress 4 or 5, a player must choose ATTACK or GROUND unless they spend a ready Freedom token before reveal."
7,Round,2. Reveal,Reveal all selected Actions simultaneously. Players using GROUND choose their mode after seeing the revealed Actions.
8,Round,3. Resolve,"Resolve in this order: GROUND, Support, active DARVO stages, Investigate, Attack, Solve. Within the same step, start with the Lead player and continue clockwise."
9,Round,4. End,"H2 scoped problem pressure: for each still-unclaimed Problem in play, add +1 Stress to every seat in that Problem's stress_scope (see Problems). Then cap Stress at 05. Any player at Stress 5 who is not already in DARVO places their marker on DENY for the next round. Rotate Lead clockwise and advance the Round marker."
10,Stress,Stress and Freedom,"Stress 03 allows any Action. At Stress 45, only ATTACK or GROUND is freely available. Spending a ready Freedom token allows any one Action; turn it to SPENT. GROUND—GR and Support through an existing Bond can ready it. H2: inbound Stress also comes from unclaimed Problems by scope — personal (owner only), bond (owner's Bond network), global (all seats)."
11,DARVO,A binding sequence,"Once triggered, DARVO continues from DENY to ATTACK to REVERSE over consecutive rounds even if Stress later falls. Existing Bond Support cancels the current stage and ends the sequence. GROUND—GR lets the current stage resolve, then ends the remaining sequence."
12,DARVO,DENY,Turn one visible unsolved Problem face down and mark it Denied. It cannot be solved or revealed by ordinary Investigate. GROUND—OU can restore it.
13,DARVO,ATTACK,Make one extra Attack against another player and place your Focus token beside that target. This extra Attack is in addition to your chosen Action.
14,DARVO,REVERSE,"Target the player holding your Focus. Unless rejected by GROUND—ND, flip Focus to Blame in front of them, give them +1 Stress, and gain Protection. Reduce your own Stress by 2 and end the sequence."
15,Relations,Creating and limiting relations,"Every player has two relation slots. A relation uses one link token from each player. If either player has no free slot, the immediate Support or Attack still works but no new relation is formed. Only one relation may exist between the same two players."
16,Relations,Bond,A no-relation Support may create a Bond if both players have a free slot and the target accepts. Support through an existing Bond is strong enough to regulate DARVO. Attack through a Bond causes +2 Stress and flips it to Rivalry.
17,Relations,Rivalry,A no-relation Attack creates Rivalry automatically when both slots are available. Attack through Rivalry causes +2 Stress and breaks it. Support through Rivalry causes 1 Stress; the target chooses to flip it to Bond or break it.
18,Problems,Hidden and Denied Problems,"Only face-up, non-Denied Problems can be solved. Investigate reveals one hidden Problem and draws one Solution. Deny can turn a previously revealed Problem face down again."
19,Problems,Solving,"Play SOLVE beside a face-up Problem and discard one matching Solution when Solve resolves. Claim that Problem and count its printed value. If another player claims it first in Lead order, keep your Solution. H2: any seat with a matching Solution may SOLVE any Problem — claim rights are not limited by stress_scope or owner. Clearing a bond-scoped Problem relieves the whole Bond network."
20,Problems,Stress scope (H2 experiment),"Each Problem has stress_scope. global: every seat. personal: the assigned owner only. bond: the owner and every seat connected to the owner through one or more Bonds (the Bond network). If the owner has no Bonds, bond scope behaves as personal. Rivalries do not expand bond scope. Multiple unclaimed Problems stack (+1 each) for seats in their scopes."
21,End,End of game,"After Round 5, total solved Problem values and apply the selected Mode card. The Scenario's Standard thresholds are 5 for 2 players, 7 for 34 players, and 9 for 56 players — always at or below available points for that seat band (6 / 9 / 12)."
22,Safety,Learning frame,"DARVO is a response pattern, not proof of an underlying accusation and not a diagnosis. GROUND does not require reconciliation. Breaking a relation, bringing in process, or preserving personal freedom can be a successful result."
1 order section heading body
2 1 Object What players are trying to do Uncover hidden Problems and solve them with matching Solutions before the end of Round 5. Support, Attack, Bonds, Rivalries, Stress, DARVO, and GROUND determine how much freedom each player retains while doing so. The game is for 2–6 players in every Mode, including SHARED GROUND.
3 2 Setup Choose the game Choose one Scenario card and one Mode card. Always use the Scenario's Surface Problem. Then add hidden Problems by player count: priorities 1–2 for 2 players, 1–3 for 3–4 players, and 1–4 for 5–6 players. Surface is never optional and is never counted as one of those hidden priorities.
4 3 Setup Problems in play and available value Problems in play = Surface + the selected hidden priorities. With the core problem values (2+2+2+3+3), available points are 6 at 2 players, 9 at 3–4 players, and 12 at 5–6 players. Every published setup must keep the Scenario threshold at or below that available total so the group can win in principle.
5 4 Setup Prepare each player Each player takes one mat, the five Action cards with their player symbol, one Stress marker at 2, one ready Freedom token, one DARVO marker at OFF, one Focus/Blame token, and two relation-link tokens. Deal two Solution cards to each player.
6 5 Setup Prepare the table Place the Surface Problem face up and the selected hidden Problems face down. Put the remaining Solution deck within reach. Give the Lead marker to a random player and place the Round marker on 1. H2 stress scope: each Problem has a printed stress_scope (global / personal / bond). Assign owners for every personal and bond Problem in play: in ascending hidden_priority order, assign the next seat clockwise starting from Lead (each assignment uses the next seat). Place that seat's owner marker on the card (or note it). Global Problems have no owner.
7 6 Round 1. Select Every player chooses one Action face down and places it beside its target where needed. At Stress 4 or 5, a player must choose ATTACK or GROUND unless they spend a ready Freedom token before reveal.
8 7 Round 2. Reveal Reveal all selected Actions simultaneously. Players using GROUND choose their mode after seeing the revealed Actions.
9 8 Round 3. Resolve Resolve in this order: GROUND, Support, active DARVO stages, Investigate, Attack, Solve. Within the same step, start with the Lead player and continue clockwise.
10 9 Round 4. End H2 scoped problem pressure: for each still-unclaimed Problem in play, add +1 Stress to every seat in that Problem's stress_scope (see Problems). Then cap Stress at 0–5. Any player at Stress 5 who is not already in DARVO places their marker on DENY for the next round. Rotate Lead clockwise and advance the Round marker.
11 10 Stress Stress and Freedom Stress 0–3 allows any Action. At Stress 4–5, only ATTACK or GROUND is freely available. Spending a ready Freedom token allows any one Action; turn it to SPENT. GROUND—GR and Support through an existing Bond can ready it. H2: inbound Stress also comes from unclaimed Problems by scope — personal (owner only), bond (owner's Bond network), global (all seats).
12 11 DARVO A binding sequence Once triggered, DARVO continues from DENY to ATTACK to REVERSE over consecutive rounds even if Stress later falls. Existing Bond Support cancels the current stage and ends the sequence. GROUND—GR lets the current stage resolve, then ends the remaining sequence.
13 12 DARVO DENY Turn one visible unsolved Problem face down and mark it Denied. It cannot be solved or revealed by ordinary Investigate. GROUND—OU can restore it.
14 13 DARVO ATTACK Make one extra Attack against another player and place your Focus token beside that target. This extra Attack is in addition to your chosen Action.
15 14 DARVO REVERSE Target the player holding your Focus. Unless rejected by GROUND—ND, flip Focus to Blame in front of them, give them +1 Stress, and gain Protection. Reduce your own Stress by 2 and end the sequence.
16 15 Relations Creating and limiting relations Every player has two relation slots. A relation uses one link token from each player. If either player has no free slot, the immediate Support or Attack still works but no new relation is formed. Only one relation may exist between the same two players.
17 16 Relations Bond A no-relation Support may create a Bond if both players have a free slot and the target accepts. Support through an existing Bond is strong enough to regulate DARVO. Attack through a Bond causes +2 Stress and flips it to Rivalry.
18 17 Relations Rivalry A no-relation Attack creates Rivalry automatically when both slots are available. Attack through Rivalry causes +2 Stress and breaks it. Support through Rivalry causes −1 Stress; the target chooses to flip it to Bond or break it.
19 18 Problems Hidden and Denied Problems Only face-up, non-Denied Problems can be solved. Investigate reveals one hidden Problem and draws one Solution. Deny can turn a previously revealed Problem face down again.
20 19 Problems Solving Play SOLVE beside a face-up Problem and discard one matching Solution when Solve resolves. Claim that Problem and count its printed value. If another player claims it first in Lead order, keep your Solution. H2: any seat with a matching Solution may SOLVE any Problem — claim rights are not limited by stress_scope or owner. Clearing a bond-scoped Problem relieves the whole Bond network.
21 20 Problems Stress scope (H2 experiment) Each Problem has stress_scope. global: every seat. personal: the assigned owner only. bond: the owner and every seat connected to the owner through one or more Bonds (the Bond network). If the owner has no Bonds, bond scope behaves as personal. Rivalries do not expand bond scope. Multiple unclaimed Problems stack (+1 each) for seats in their scopes.
22 21 End End of game After Round 5, total solved Problem values and apply the selected Mode card. The Scenario's Standard thresholds are 5 for 2 players, 7 for 3–4 players, and 9 for 5–6 players — always at or below available points for that seat band (6 / 9 / 12).
23 22 Safety Learning frame DARVO is a response pattern, not proof of an underlying accusation and not a diagnosis. GROUND does not require reconciliation. Breaking a relation, bringing in process, or preserving personal freedom can be a successful result.

View file

@ -0,0 +1,53 @@
# Experiment H2 — scoped problem stress (personal / bond / global)
| | |
|---|---|
| **variant_id** | `h2-scoped-problem-stress` |
| **base** | `ground-darvo-r0` (not stacked on H1) |
| **status** | experimental |
| **catalog** | [`../../catalog.yaml`](../../catalog.yaml) |
| **design note** | [`../../../history/260808-h2-scoped-problem-stress.md`](../../../history/260808-h2-scoped-problem-stress.md) |
| **workplan** | [`../../../workplans/GROUND-WP-0007-h2-scoped-problem-stress.md`](../../../workplans/GROUND-WP-0007-h2-scoped-problem-stress.md) |
| **control** | `h1-problem-stress` (reject-as-baseline) |
## Hypothesis
If unclaimed Problems apply End stress only to their **stress_scope**, with mostly personal, one bond at 3p+, and rare global (Surface), then some seats can climb toward DARVO **without** taxing the whole table into zero group wins — and **bond-scoped** cards give the Bond network a shared reason to SOLVE.
## Scopes
| stress_scope | End +1 Stress while unclaimed goes to… |
|--------------|----------------------------------------|
| **global** | every seat |
| **personal** | assigned **owner** only |
| **bond** | owner + every seat in the owners **Bond network** (Bond edges only). No Bonds → behaves as personal |
**SOLVE:** any seat with matching suit may claim any Problem (owner need not solve). Clearing a bond card is a network public good — that is intentional.
## Assignment in this package
By `hidden_priority` in `Problems.csv`:
| priority | scope | in play at |
|---------:|-------|------------|
| 0 Surface | global | all seats |
| 1 | personal | 2p+ |
| 2 | personal | 2p+ |
| 3 | **bond** | 3p+ only |
| 4 | personal | 5p+ only |
Owners: non-global in-play Problems, ascending priority, round-robin clockwise from **Lead**.
## Bond incentive (design intent)
Bond is still the regulation tool (2 Stress, cancel DARVO). Bond-scope pressure **spreads** cost across the network so partners are incentivised to clear *that* card together rather than leave one seat to melt alone. Measure whether networks SOLVE bond cards faster or avoid Bonding.
## Not in H2
- H1 flat global +1 for “any open problem”
- H1-B ATTACK self-soothe
- Owner-only SOLVE, shadow goals, competence track
## Simulation
Select `variant_id: h2-scoped-problem-stress`. Implement `rules_delta.yaml` (H2-SCOPE, H2-OWN, H2-A, H2-SOLVE). Compare to `ground-darvo-r0` and optionally `h1-problem-stress`.

View file

@ -0,0 +1,40 @@
{
"dataset_id": "GROUND-DARVO-EXP-H2-0.1",
"variant_id": "h2-scoped-problem-stress",
"base_variant_id": "ground-darvo-r0",
"base_path": "editions/ground-darvo-r0",
"game_title": "GROUND",
"subtitle": "A Game of Bonds and Rivalry",
"edition": "DARVO Edition — experiment H2",
"version": "0.1-h2",
"status": "Experimental rules variant (not baseline)",
"player_count": "26",
"duration": "515 minutes",
"rounds": 5,
"relationship_slots_per_player": 2,
"description": "Experiment H2: unclaimed Problems apply End Stress only to stress_scope (personal / bond / global). Bond scope taxes the Bond network so bonded seats share incentive to SOLVE. Not stacked on H1. Design: history/260808-h2-scoped-problem-stress.md",
"inherits_unchanged_from_base": [
"Solutions.csv",
"Modes.csv",
"Scenarios.csv",
"Tokens.csv",
"DARVO.csv",
"Relations.csv",
"Player_Mats.csv",
"Glossary.csv",
"Extensions.csv",
"BOM.csv",
"Print_Manifest.csv",
"Symbols.csv",
"Back_Designs.csv",
"Design_Tokens.csv",
"Actions.csv"
],
"changed_files": [
"Problems.csv",
"Rules_Text.csv",
"metadata.json",
"rules_delta.yaml",
"VARIANT.md"
]
}

View file

@ -0,0 +1,93 @@
# Machine-oriented rules deltas for clay-borg / simulators.
# Human prose: VARIANT.md and history/260808-h2-scoped-problem-stress.md
# Apply on top of base variant ground-darvo-r0 (not on H1).
variant_id: h2-scoped-problem-stress
base: ground-darvo-r0
schema_version: 1
replaces_experiments:
# H2 is a new package, not a silent edit of H1. Do not stack H1-A/H1-B.
- h1-problem-stress
deltas:
- id: H2-SCOPE
name: problem_stress_scope_field
phase: data
effect:
problems_csv_column: stress_scope
allowed_values: [global, personal, bond]
edition_assignment_by_hidden_priority:
0: global # Surface
1: personal
2: personal
3: bond
4: personal
notes: >
Seat-band dial without a difficulty card: 2p deal (S+1..2) has no bond
card in play; 34p introduces one bond; 56p still one bond + more personal.
- id: H2-OWN
name: assign_owners_at_setup
phase: setup_after_deal
when:
problem_stress_scope_in: [personal, bond]
effect:
assign_owner:
order: ascending_hidden_priority_among_in_play_non_global
start: lead
step: clockwise_next_seat
# Each such Problem gets exactly one owner seat.
notes: >
Deterministic for sims. Table: place owner marker on the card.
Global Problems have owner = none.
- id: H2-A
name: scoped_problem_pressure_end_of_round
phase: round_end
# Order:
# 1) for each unclaimed Problem in play, +1 Stress to each seat in scope
# 2) clamp 05
# 3) arm DARVO at 5 as baseline
# 4) rotate Lead; advance Round
when:
problem_unclaimed: true
effect:
stress_delta_per_matching_problem: +1
recipients: stress_scope_of_that_problem
scope_resolution:
global: all_seats
personal: [owner]
bond: bond_network_of_owner
bond_network: >
owner plus every seat reachable from owner by traversing Bond
edges only (not Rivalry). If owner has degree 0, treat as personal.
stacking: true # two open bond problems both hit the network
notes: >
Bond incentive: every bonded partner shares the tick, so the network
is jointly motivated to SOLVE that card (anyone may still SOLVE).
- id: H2-SOLVE
name: claim_not_restricted_by_scope
phase: resolve_solve
effect:
any_seat_with_matching_suit_may_solve: true
owner_need_not_be_solver: true
notes: >
Altruistic clear is legal and intended — especially on bond-scope cards.
unchanged:
- deal_and_thresholds
- solve_legality_suit_faceup_unclaimed_race
- support
- ground_modes
- darvo_stage_table
- attack_resolution # no H1-B self-soothe in H2
- start_stress: 2
- relation_slots: 2
not_in_this_variant:
- H1-A_flat_global_plus_one
- H1-B_attack_self_soothe
- owner_only_solve
- shadow_goals
- competence_track

View file

@ -59,6 +59,11 @@ reported it unverified and it was a real gap.
```
sha256 c469f984c3147861815e8fbd67cf1254de73368fad1654f4177f740c0c280499 ../catalog.yaml
sha256 4c81bae21d2ecb70c7424fa17445246a9b551b10e258d448634816c564e28f09 ../experiments/h2-scoped-problem-stress/Problems.csv
sha256 8ed8deb7ad142de1bda70dc11add339f742e8c12ac1f02d5f7c7ec8f17f77b1d ../experiments/h2-scoped-problem-stress/Rules_Text.csv
sha256 b2db91edc6798efca1ccf0b048bb0d7cb324e808e01d636ebca813527bcd0db2 ../experiments/h2-scoped-problem-stress/VARIANT.md
sha256 abf994f585fdfa9b2822614a7961e98141cded6a91c446916ff7fd48642de0a7 ../experiments/h2-scoped-problem-stress/metadata.json
sha256 8dc569b2ae62f88f7f64e282bc6bba3dbcc3ffcad276591baafbdd24cb6c16b7 ../experiments/h2-scoped-problem-stress/rules_delta.yaml
sha256 f58e81f84ea2b0d16e39932261eb3f3d9890345cdf37ad6f0b3abc00636840be ../experiments/h1-problem-stress/rules_delta.yaml
sha256 7b1cc0149122b855e827bc930576ed165bf7dd8d62707e845a9e514ce3521f8e ../experiments/h1-problem-stress/Actions.csv
sha256 62785f5e7e245c60171624d15de2f40187a44fec54f93c7d9705cf52584b1078 ../experiments/h1-problem-stress/Rules_Text.csv
@ -66,6 +71,13 @@ sha256 b2714210c142f1d6d5bed8f9a795019e51829c627f9599ac0ebfa04ef60a37a5 ../exp
sha256 443199db94601cc889557e5e86823f374dbfdf875962fc84f95c01865605101c ../experiments/h1-problem-stress/metadata.json
```
**H2 vendored 2026-08-08** (CB-WP-0042). Its `Problems.csv` is r0's with
**one column added** — `stress_scope` — and **nothing else changed**: same
rows, same `point_value`, `required_solution`, `visibility` and
`hidden_priority`. That is checked, not assumed, because the delta's
`unchanged:` list claims `deal_and_thresholds` and a silent change there
would move every baseline hash.
**`rules_delta.yaml` is the load-bearing one**: it is the executable
statement of H1, and the kernel implements it. A silent change there is a
silent change to what we measured.

View file

@ -11,6 +11,8 @@
//! is wrong and `csv` is the answer (ADR-0011 D1).
use crate::{SolutionCard, Suit};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// One Problem as the edition prints it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -77,6 +79,9 @@ const RELATIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Rela
const SCENARIOS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Scenarios.csv");
const PLAYER_MATS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Player_Mats.csv");
const GLOSSARY_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Glossary.csv");
/// H2's Problems table (CB-WP-0042). r0's with one column added.
const H2_PROBLEMS_CSV: &str =
include_str!("../../../editions/experiments/h2-scoped-problem-stress/Problems.csv");
/// A vendored CSV, parsed into rows addressable by column name.
///
@ -504,6 +509,59 @@ pub fn scenarios() -> Result<Vec<ScenarioText>, String> {
Ok(out)
}
/// Who an unclaimed Problem's End-of-Round Stress falls on (H2-SCOPE).
///
/// **Read from the edition, never derived from the priority.** The delta
/// states a priority→scope mapping and `Problems.csv` carries the
/// column; F25 exists because we hardcoded numbers the edition already
/// held, and this is the same shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StressScope {
/// All seats.
Global,
/// The Problem's owner alone.
Personal,
/// The owner's Bond network — Bond edges only, never Rivalry.
Bond,
}
impl std::str::FromStr for StressScope {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s.trim() {
"global" => Ok(StressScope::Global),
"personal" => Ok(StressScope::Personal),
"bond" => Ok(StressScope::Bond),
other => Err(format!(
"unknown stress_scope {other:?} (global, personal, bond)"
)),
}
}
}
/// H2's scope per hidden priority, for one scenario.
///
/// Keyed by `hidden_priority` because that is what survives the deal —
/// `EditionProblem` carries the priority and not the `problem_id`.
pub fn stress_scopes(scenario_id: &str) -> Result<BTreeMap<u8, StressScope>, String> {
let t = Table::parse(H2_PROBLEMS_CSV, "h2/Problems.csv")?;
let mut out = BTreeMap::new();
for row in &t.rows {
if t.get(row, "scenario_id")? != scenario_id {
continue;
}
let priority: u8 = t
.get(row, "hidden_priority")?
.parse()
.map_err(|_| "hidden_priority is not a number".to_string())?;
out.insert(priority, t.get(row, "stress_scope")?.parse()?);
}
if out.is_empty() {
return Err(format!("h2/Problems.csv has no rows for {scenario_id}"));
}
Ok(out)
}
/// The stress gate, printed on every player mat (CB-WP-0037 T03).
///
/// **A mat is mostly ornamentation with one rule on it.** Symbol, colour
@ -750,6 +808,80 @@ mod card_text_tests {
println!("{report}");
}
/// **H2 changes only the column it says it changes** (CB-WP-0042 T01).
///
/// `rules_delta.yaml`'s `unchanged:` list claims
/// `deal_and_thresholds`, and H2 ships its own `Problems.csv`. If a
/// value, suit, visibility or priority moved in it, **every baseline
/// comparison in every H2 measurement would be against a different
/// board** — and the variant would be testing two things at once.
#[test]
fn h2_adds_a_column_and_alters_nothing_else() {
let base = Table::parse(CSV, "Problems.csv").expect("r0");
let h2 = Table::parse(H2_PROBLEMS_CSV, "h2/Problems.csv").expect("h2");
let key = |t: &Table, r: &Vec<String>| {
(
t.get(r, "problem_id").expect("id").to_string(),
t.get(r, "scenario_id").expect("scn").to_string(),
t.get(r, "visibility").expect("vis").to_string(),
t.get(r, "hidden_priority").expect("pri").to_string(),
t.get(r, "point_value").expect("val").to_string(),
t.get(r, "required_solution").expect("sol").to_string(),
)
};
let a: Vec<_> = base.rows.iter().map(|r| key(&base, r)).collect();
let b: Vec<_> = h2.rows.iter().map(|r| key(&h2, r)).collect();
assert_eq!(
a, b,
"H2's Problems.csv differs from r0 beyond `stress_scope` — the deal \
moved, so H2 would be testing a rules change and a board change at once"
);
// And it really does add the column, or there is nothing to read.
assert!(
h2.cols.iter().any(|c| c == "stress_scope"),
"H2's Problems.csv has no stress_scope column"
);
assert!(
!base.cols.iter().any(|c| c == "stress_scope"),
"r0 already carries stress_scope — H2 is not the variant that adds it"
);
}
/// **The scopes come from the edition, not from the priority**
/// (CB-WP-0042 T01).
///
/// The delta states the mapping — 0 global, 1 personal, 2 personal,
/// 3 bond, 4 personal — and the file carries it. **Deriving it from
/// the priority in Rust would be F25 again**: a number hardcoded that
/// the edition already holds.
#[test]
fn the_stress_scopes_are_read_from_the_edition() {
let scopes = stress_scopes("SCN_01").expect("SCN_01 scopes");
assert_eq!(
scopes.get(&0),
Some(&StressScope::Global),
"Surface is global"
);
assert_eq!(scopes.get(&1), Some(&StressScope::Personal));
assert_eq!(scopes.get(&2), Some(&StressScope::Personal));
assert_eq!(
scopes.get(&3),
Some(&StressScope::Bond),
"priority 3 is the bond card — the seat-band dial, absent at 2p"
);
assert_eq!(scopes.get(&4), Some(&StressScope::Personal));
// Every scenario the edition ships, not just the one we deal.
for id in ["SCN_01", "SCN_02", "SCN_03", "SCN_04"] {
assert!(
stress_scopes(id).is_ok(),
"{id} has no scopes, so a later pass that deals it would have none"
);
}
}
/// **The stress gate is printed on the mats** (CB-WP-0037 T03).
///
/// `Player_Mats.csv` looked like pure ornamentation — a symbol, a

View file

@ -75,6 +75,23 @@ pub struct ProblemState {
pub claimed_by: Option<PlayerId>,
/// GR-A11: protected from Deny this round by GROUND—OU.
pub protected_this_round: bool,
/// H2-OWN: which seat this Problem's Stress falls on, for a
/// `personal` or `bond` scope (CB-WP-0042).
///
/// **`None` under the baseline, and skipped when `None` so it
/// serialises exactly as it always did** — every recorded scenario
/// predates this field, and a state that hashed differently would
/// break all of them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<PlayerId>,
/// H2-SCOPE: who this Problem's End-of-Round Stress falls on.
///
/// Stored rather than looked up, because the lookup needs the
/// `hidden_priority` and `ProblemState` does not carry it — and
/// deriving the priority from the map key would make the deal order
/// load-bearing for a rules effect.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<crate::edition::StressScope>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@ -212,6 +229,10 @@ pub enum Variant {
/// `ground-darvo-r0` — the printed baseline, and the default.
#[default]
Baseline,
/// `h2-scoped-problem-stress` — unclaimed Problems tick only the
/// seats in their scope. **Does not stack with H1** — the package
/// says `replaces_experiments: [h1-problem-stress]`.
H2ScopedProblemStress,
/// `h1-problem-stress` — ground-game's hypothesis H1.
///
/// **Experimental.** Two deltas only: unclaimed Problems raise
@ -226,6 +247,7 @@ impl Variant {
match self {
Variant::Baseline => "ground-darvo-r0",
Variant::H1ProblemStress => "h1-problem-stress",
Variant::H2ScopedProblemStress => "h2-scoped-problem-stress",
}
}
}
@ -236,13 +258,63 @@ impl std::str::FromStr for Variant {
match s {
"ground-darvo-r0" | "baseline" | "r0" => Ok(Variant::Baseline),
"h1-problem-stress" | "h1" => Ok(Variant::H1ProblemStress),
"h2-scoped-problem-stress" | "h2" => Ok(Variant::H2ScopedProblemStress),
other => Err(format!(
"unknown variant {other:?} (ground-darvo-r0, h1-problem-stress)"
"unknown variant {other:?} (ground-darvo-r0, h1-problem-stress, \
h2-scoped-problem-stress)"
)),
}
}
}
impl GroundState {
/// Select the rules package, **and apply whatever its setup needs**
/// (CB-WP-0042 T02).
///
/// **A builder rather than a bare field write.** H2 assigns Problem
/// owners at setup, and `state.variant = v` would leave them
/// unassigned — a silently wrong game rather than a failing one.
/// Every driver path goes through here.
pub fn with_variant(mut self, variant: Variant) -> Self {
self.variant = variant;
if variant == Variant::H2ScopedProblemStress {
self.apply_h2_setup();
}
self
}
/// H2-SCOPE and H2-OWN, applied to the dealt board.
///
/// Owners go to the **non-global** Problems in **ascending hidden
/// priority**, starting at the Lead and stepping clockwise. The
/// Problems map is keyed 1..n in deal order and `edition::deal`
/// sorts by priority, so map order *is* priority order — asserted in
/// `owners_follow_ascending_priority_from_the_lead` rather than
/// assumed.
fn apply_h2_setup(&mut self) {
let Ok(scopes) = crate::edition::stress_scopes("SCN_01") else {
return;
};
let seats: Vec<PlayerId> = self.players.keys().copied().collect();
let start = seats.iter().position(|s| *s == self.lead).unwrap_or(0);
let mut next = 0usize;
// Keys ascend with priority; priority 0 is the Surface.
for (i, (_, problem)) in self.problems.iter_mut().enumerate() {
let priority = i as u8;
let scope = scopes.get(&priority).copied();
problem.scope = scope;
if matches!(
scope,
Some(crate::edition::StressScope::Personal)
| Some(crate::edition::StressScope::Bond)
) {
problem.owner = Some(seats[(start + next) % seats.len()]);
next += 1;
}
}
}
}
/// GR-E01..E04: the final scoring result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Outcome {
@ -618,6 +690,27 @@ impl GroundState {
player.stress >= 4 && !player.freedom_gate_lifted
}
/// H2-A: the owner plus every seat reachable over **Bond edges
/// only** — never Rivalry. A degree-0 owner falls back to personal,
/// which the delta states and which is the branch a test forgets.
fn bond_network(&self, owner: PlayerId) -> Vec<PlayerId> {
let mut seen = vec![owner];
let mut frontier = vec![owner];
while let Some(seat) = frontier.pop() {
for (pair, rel) in &self.relations {
if *rel != Relation::Bond || !pair.contains(seat) {
continue;
}
let other = if pair.0 == seat { pair.1 } else { pair.0 };
if !seen.contains(&other) {
seen.push(other);
frontier.push(other);
}
}
}
seen
}
fn relation_between(&self, a: PlayerId, b: PlayerId) -> Option<Relation> {
self.relations.get(&Pair::new(a, b)).copied()
}
@ -1540,6 +1633,49 @@ impl GroundState {
//
// The trigger loop reads `work`, so it sees the new Stress.
let mut work = self.clone();
// H2-A (CB-WP-0042): scoped pressure. Each unclaimed Problem
// ticks only the seats in ITS scope, and `stacking: true` — two
// open bond Problems hit the network twice, so this accumulates
// per Problem rather than applying once.
if self.variant == Variant::H2ScopedProblemStress {
let mut ticks: BTreeMap<PlayerId, i16> = BTreeMap::new();
for problem in self.problems.values() {
if problem.claimed_by.is_some() {
continue;
}
let recipients: Vec<PlayerId> = match problem.scope {
Some(crate::edition::StressScope::Global) => {
self.players.keys().copied().collect()
}
Some(crate::edition::StressScope::Personal) => {
problem.owner.into_iter().collect()
}
Some(crate::edition::StressScope::Bond) => match problem.owner {
Some(owner) => self.bond_network(owner),
None => vec![],
},
None => vec![],
};
for seat in recipients {
*ticks.entry(seat).or_default() += 1;
}
}
// In seat order, so two seats taking a tick are ordered (U9).
for seat in self.seat_order() {
let Some(n) = ticks.get(&seat).copied() else {
continue;
};
let stress = work.stress_after(seat, n);
let e = GroundEvent::StressSet {
player: seat,
stress,
};
work.fold(&e);
events.push(e);
}
}
if self.variant == Variant::H1ProblemStress
&& self.problems.values().any(|p| p.claimed_by.is_none())
{
@ -1977,6 +2113,8 @@ impl ScenarioGame for GroundState {
denied: false,
claimed_by: None,
protected_this_round: false,
owner: None,
scope: None,
},
)
})
@ -2094,7 +2232,7 @@ mod tests {
seed,
)
.expect("setup");
s.variant = variant;
s = s.with_variant(variant);
s
}
@ -2150,6 +2288,346 @@ mod tests {
}
}
/// **The baseline is untouched by H2's new state** (T02).
///
/// `owner` and `scope` are new fields on `ProblemState`. Both are
/// `None` under the baseline and skipped when `None`, so a
/// baseline state must serialise and hash exactly as it did
/// before they existed — **every recorded scenario predates
/// them.**
#[test]
fn h2s_new_state_is_invisible_under_the_baseline() {
for players in [2u8, 3, 6] {
let s = GroundState::setup(
&Setup {
players,
preset: format!("standard-{players}p"),
patch: Default::default(),
},
5,
)
.expect("setup")
.with_variant(Variant::Baseline);
assert!(
s.problems
.values()
.all(|p| p.owner.is_none() && p.scope.is_none()),
"{players}p: the baseline acquired owners or scopes"
);
let json = serde_json::to_string(&s).expect("json");
assert!(
!json.contains("\"owner\"") && !json.contains("\"scope\""),
"{players}p: the baseline serialises H2's fields, so every \
recorded scenario's hash has moved"
);
}
}
/// **H2-OWN: ascending priority, from the Lead, clockwise** (T02).
#[test]
fn owners_follow_ascending_priority_from_the_lead() {
for players in [2u8, 3, 4, 5, 6] {
let s = setup(players, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let start = seats.iter().position(|x| *x == s.lead).expect("lead seats");
// The Surface is global and owns nothing; the rest are
// owned in map order, which is priority order.
let mut expected = 0usize;
for (key, p) in &s.problems {
match p.scope {
Some(crate::edition::StressScope::Global) => {
assert!(
p.owner.is_none(),
"{players}p: a global Problem has an owner"
);
assert_eq!(*key, 1, "the Surface should be the first key");
}
Some(_) => {
assert_eq!(
p.owner,
Some(seats[(start + expected) % seats.len()]),
"{players}p problem {key}: owner is not the next seat \
clockwise from the Lead"
);
expected += 1;
}
None => panic!("{players}p problem {key}: H2 left a Problem unscoped"),
}
}
assert!(expected > 0, "{players}p: nothing was owned");
// Exactly one owner each, and the walk did not skip.
let owned = s.problems.values().filter(|p| p.owner.is_some()).count();
assert_eq!(owned, expected, "{players}p: an owner was assigned twice");
}
}
/// **Deterministic, as the delta requires** — "deterministic for
/// sims". A seeded-but-unstated order would be untestable.
#[test]
fn h2_ownership_is_deterministic() {
for seed in [1u64, 7, 99] {
let a = setup(4, Variant::H2ScopedProblemStress, seed);
let b = setup(4, Variant::H2ScopedProblemStress, seed);
let owners = |s: &GroundState| -> Vec<Option<PlayerId>> {
s.problems.values().map(|p| p.owner).collect()
};
assert_eq!(
owners(&a),
owners(&b),
"seed {seed}: ownership is not stable"
);
}
}
/// **H2-A: each scope reaches exactly who it should** (T03).
///
/// Each case is built so one scope is unclaimed and the rest are
/// claimed, so the recipients are unambiguous.
#[test]
fn h2_pressure_reaches_only_the_scope() {
use crate::edition::StressScope;
let ticks = |scope: StressScope, bonds: &[(usize, usize)]| {
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
for (a, b) in bonds {
s.relations
.insert(Pair::new(seats[*a], seats[*b]), Relation::Bond);
}
// Claim everything, then re-open exactly one Problem of
// the scope under test — with a known owner, seat 0.
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
let target = *keys
.iter()
.find(|k| s.problems[k].scope == Some(scope))
.unwrap_or_else(|| panic!("no {scope:?} Problem in the deal"));
let p = s.problems.get_mut(&target).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
let mut out: Vec<(PlayerId, u8)> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, stress } => Some((player, stress)),
_ => None,
})
.collect();
out.sort();
(seats, out)
};
// global: everyone.
let (seats, out) = ticks(StressScope::Global, &[]);
assert_eq!(out.len(), seats.len(), "global did not reach every seat");
// personal: the owner alone.
let (seats, out) = ticks(StressScope::Personal, &[]);
assert_eq!(
out.len(),
1,
"personal reached more than the owner: {out:?}"
);
assert_eq!(out[0].0, seats[0]);
// bond: the owner's network, over BOND edges only.
let (seats, out) = ticks(StressScope::Bond, &[(0, 1), (1, 2)]);
let hit: Vec<PlayerId> = out.iter().map(|(p, _)| *p).collect();
assert!(
hit.contains(&seats[0]) && hit.contains(&seats[1]) && hit.contains(&seats[2]),
"bond did not traverse the network: {hit:?}"
);
assert!(
!hit.contains(&seats[3]),
"bond reached a seat outside the network: {hit:?}"
);
}
/// **Rivalry edges are not the network** (T03) — the single most
/// likely defect, named in the workplan before the code existed.
#[test]
fn h2_bond_scope_does_not_traverse_rivalry() {
use crate::edition::StressScope;
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
// 0—1 Bond, 1—2 RIVALRY. The network is {0,1}, never 2.
s.relations
.insert(Pair::new(seats[0], seats[1]), Relation::Bond);
s.relations
.insert(Pair::new(seats[1], seats[2]), Relation::Rivalry);
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
let target = *keys
.iter()
.find(|k| s.problems[k].scope == Some(StressScope::Bond))
.expect("a bond Problem");
let p = s.problems.get_mut(&target).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
let hit: Vec<PlayerId> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, .. } => Some(player),
_ => None,
})
.collect();
assert!(
!hit.contains(&seats[2]),
"the Bond network crossed a Rivalry edge: {hit:?}"
);
}
/// **A degree-0 owner falls back to personal** (T03) — the branch
/// a test forgets, named in advance.
#[test]
fn h2_a_bond_problem_with_no_bonds_is_personal() {
use crate::edition::StressScope;
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
assert!(s.relations.is_empty(), "the fixture needs no relations");
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
let target = *keys
.iter()
.find(|k| s.problems[k].scope == Some(StressScope::Bond))
.expect("a bond Problem");
let p = s.problems.get_mut(&target).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
let hit: Vec<PlayerId> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, .. } => Some(player),
_ => None,
})
.collect();
assert_eq!(hit, vec![seats[0]], "a bondless owner should tick alone");
}
/// **`stacking: true`** (T03): two open Problems hitting the same
/// seat tick it **twice**. A careless implementation applies the
/// pressure once.
#[test]
fn h2_pressure_stacks_per_problem() {
let mut s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
let p = s.problems.get_mut(k).expect("p");
p.claimed_by = Some(seats[0]);
}
// Two personal Problems, both owned by seat 0, both open.
let personal: Vec<u32> = keys
.iter()
.copied()
.filter(|k| s.problems[k].scope == Some(crate::edition::StressScope::Personal))
.take(2)
.collect();
assert_eq!(personal.len(), 2, "the deal needs two personal Problems");
for k in &personal {
let p = s.problems.get_mut(k).expect("p");
p.claimed_by = None;
p.owner = Some(seats[0]);
}
s.players.get_mut(&seats[0]).expect("s").stress = 0;
let got: Vec<(PlayerId, u8)> = s
.end_round_events()
.into_iter()
.filter_map(|e| match e {
GroundEvent::StressSet { player, stress } => Some((player, stress)),
_ => None,
})
.collect();
assert_eq!(
got,
vec![(seats[0], 2)],
"two open Problems on one seat must tick it twice, not once"
);
}
/// **The baseline and H1 do not feel H2-A** (T03).
#[test]
fn h2_pressure_does_not_reach_the_other_variants() {
for variant in [Variant::Baseline, Variant::H1ProblemStress] {
let mut s = setup(3, variant, 5);
// H1 would fire on an unclaimed Problem; claim them all so
// only H2-A could produce anything here.
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
let keys: Vec<u32> = s.problems.keys().copied().collect();
for k in &keys {
s.problems.get_mut(k).expect("p").claimed_by = Some(seats[0]);
}
assert!(
!s.end_round_events()
.iter()
.any(|e| matches!(e, GroundEvent::StressSet { .. })),
"{variant:?} gained scoped pressure"
);
}
}
/// **H2-SOLVE: ownership is not a permission** (T04).
///
/// *"Any seat with a matching suit may solve; the owner need not
/// be the solver"* — and altruistic clearing is **intended**,
/// especially on bond-scope cards.
///
/// **This is a regression test, not a feature.** The engine had
/// no owner concept, so it was already true; T02 added ownership,
/// and the risk is that ownership silently becomes a right.
#[test]
fn h2_ownership_does_not_restrict_who_may_solve() {
let s = setup(4, Variant::H2ScopedProblemStress, 5);
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
// A Problem owned by someone else, face up and unclaimed.
let (key, owner) = s
.problems
.iter()
.find_map(|(k, p)| p.owner.map(|o| (*k, o)))
.expect("H2 assigns owners");
let mut s = s;
s.problems.get_mut(&key).expect("p").face_up = true;
let suit = s.problems[&key].suit;
for seat in &seats {
// Give every seat the matching card.
s.players.get_mut(seat).expect("p").hand = vec![SolutionCard { suit }];
}
for seat in &seats {
let legal = crate::bot::legal_commands(&s, *seat);
let can_solve = legal.iter().any(|c| {
matches!(
c,
GroundCommand::SelectAction {
action: Action::Solve,
problem: Some(p),
..
} if *p == key
)
});
assert!(
can_solve,
"{seat:?} holds the matching suit but may not SOLVE problem {key} \
(owned by {owner:?}) ownership has become a permission"
);
}
}
/// **H1-A.** Unclaimed Problems raise everyone's Stress at Round
/// End — and "unclaimed" includes Denied and still-hidden, which
/// is the clause a careless reading drops.
@ -2550,6 +3028,8 @@ mod tests {
denied: false,
claimed_by,
protected_this_round: false,
owner: None,
scope: None,
};
s.problems.insert(1, mk(4, Some(seats[0])));
s.problems.insert(2, mk(4, Some(seats[1])));
@ -2611,6 +3091,8 @@ mod tests {
denied: false,
claimed_by,
protected_this_round: false,
owner: None,
scope: None,
};
// GR-E03 key 2, Stress: equal claims, lower Stress wins.

View file

@ -2,7 +2,7 @@
id: CB-WP-0042
kind: product
title: "H2 — scoped problem stress"
status: ready
status: active
state_hub_workstream_id: "8f11d55b-3452-49f3-9857-d0bf06752f68"
---
@ -57,7 +57,7 @@ do not have:
```task
id: CB-WP-0042-T01
status: todo
status: done
priority: high
state_hub_task_id: "37f9bc34-05d1-4a12-9a98-6c61ab3ec23f"
```
@ -74,11 +74,20 @@ state_hub_task_id: "37f9bc34-05d1-4a12-9a98-6c61ab3ec23f"
state and ours to read**, and F25 exists because we hardcoded numbers
the edition already carried.
**Done 2026-08-08.** Package vendored with digests; `edition-check` walks
it. `StressScope` and `stress_scopes()` read the column.
**H2's `Problems.csv` is r0's with one column added and nothing else
changed** — same rows, values, suits, visibilities and priorities. That is
**checked, not assumed**: the delta claims `deal_and_thresholds` unchanged,
and a silent difference would make every H2-vs-baseline comparison a
comparison of two boards as well as two rule sets.
## Task: ownership at setup
```task
id: CB-WP-0042-T02
status: todo
status: done
priority: high
state_hub_task_id: "65b9b2b8-8160-488f-8cd4-b7f734d03b22"
```
@ -96,11 +105,21 @@ starting at Lead, stepping clockwise. Global Problems have no owner.
because the assignment walks two sequences at once and off-by-one is
the obvious failure.
**Done 2026-08-08.** `owner` and `scope` on `ProblemState`, both
`Option`, both `skip_serializing_if = "Option::is_none"` — so a baseline
state serialises **without them** and every recorded scenario's hash is
untouched. Asserted directly, on the JSON.
**`with_variant()` replaces the bare field write.** `state.variant = v`
would leave owners unassigned — **a silently wrong game rather than a
failing one** — so the builder applies the variant's setup and every
driver path goes through it.
## Task: scoped pressure
```task
id: CB-WP-0042-T03
status: todo
status: done
priority: high
state_hub_task_id: "8426e79c-579e-4aba-a695-381020016206"
```
@ -118,11 +137,26 @@ ordering H1-A needed, and the same trap.
- **degree 0 falls back to personal**, which is the branch a test forgets;
- ordering asserted as in CB-WP-0038: pressure before the arm check.
**Done 2026-08-08.** All four named defects mutation-proven:
| mutation | caught by |
|---|---|
| traverse Rivalry edges too | `h2_bond_scope_does_not_traverse_rivalry` |
| stacking applied once | `h2_pressure_stacks_per_problem` |
| degree-0 owner ticks everyone | `h2_a_bond_problem_with_no_bonds_is_personal` |
| personal hits everyone | `h2_pressure_reaches_only_the_scope` |
**The degree-0 mutation missed first**, and that is worth recording: the
fallback lives *inside* `bond_network`, and the first mutation broke the
`None`-owner arm instead — a different branch. It stayed green until the
mutation was aimed at the path the test actually exercises. **A mutation
that misses is not evidence the test works.**
## Task: SOLVE is not restricted by scope
```task
id: CB-WP-0042-T04
status: todo
status: done
priority: medium
state_hub_task_id: "6606cb3b-733d-486c-9e46-37a2a2b0c2c9"
```
@ -135,6 +169,9 @@ true — which makes it a **regression test, not a feature**. T02 adds
ownership, and the risk is that ownership silently becomes a permission.
The test must fail if it does.
**Done 2026-08-08**, and it does: filtering SOLVE to the owner in
`legal_commands` turns it red.
## Task: measure, and report what fails
```task