CB-WP-0042 T01-T04: H2's scoped stress, with the named defects caught
Some checks failed
ci / check (push) Failing after 4s
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:
parent
05d1a9aada
commit
04e26b3077
9 changed files with 900 additions and 7 deletions
21
editions/experiments/h2-scoped-problem-stress/Problems.csv
Normal file
21
editions/experiments/h2-scoped-problem-stress/Problems.csv
Normal 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
|
||||
|
23
editions/experiments/h2-scoped-problem-stress/Rules_Text.csv
Normal file
23
editions/experiments/h2-scoped-problem-stress/Rules_Text.csv
Normal 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 2–6 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 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."
|
||||
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."
|
||||
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 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."
|
||||
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)."
|
||||
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 3–4 players, and 9 for 5–6 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."
|
||||
|
53
editions/experiments/h2-scoped-problem-stress/VARIANT.md
Normal file
53
editions/experiments/h2-scoped-problem-stress/VARIANT.md
Normal 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 owner’s **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`.
|
||||
40
editions/experiments/h2-scoped-problem-stress/metadata.json
Normal file
40
editions/experiments/h2-scoped-problem-stress/metadata.json
Normal 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": "2–6",
|
||||
"duration": "5–15 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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; 3–4p introduces one bond; 5–6p 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 0–5
|
||||
# 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue