From 6bca2b194da4642270ac9f579413a2d27cc654c6 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:47:17 +0200 Subject: [PATCH 1/5] fix(engine,parser): gift an extra turn, not a card (#7286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 702.174g: "Gift an extra turn" means "The chosen player takes an extra turn after this one." `GiftKind` had no variant for it, so Perch Protection promised a card draw. The article is the whole story. The keyword scan matched `"gift a "`, and this is one of the two printed forms that take "an". Nothing matched, the outer keyword scan fell back to the bare `Gift` form, and that defaults to `Card` — so parser and deserializer agreed on the wrong value and the export shipped it. A separate `"gift an "` scan rather than an `alt` over both articles: an unknown "gift an [something]" must keep falling THROUGH to the outer scan exactly as it does today. CR 702.174i's Octopus is the live case (Octomancer, #5975) and has no `GiftKind` yet; folding it into the "a" block would turn its silent-`Card` parse into no keyword at all — a different wrong answer, in a card this change has no business touching. A row pins that non-change. `GiftKind::ExtraTurn` deliberately does NOT join the token family. Treasure / Food / tapped Fish (and the missing Octopus) differ only in which token is created and want a parameterization, not a fifth sibling; an extra turn is not an object and is orthogonal to all four. Within CR 702.174 throughout, so the categorical boundary holds. Delivery routes through `turns::enqueue_extra_turn`, the CR 500.7 authority `Effect::ExtraTurn` uses, with the same CR 805.8 shared-turn normalization. "After this one" is the anchor — the turn during which the gift resolved, not the recipient's next. The client's `giftKindLabel` falls back to "a card" for any kind it does not name, so an unlabelled kind does not look unlabelled — it looks like a DIFFERENT promise. Named in all six locales. `phase-ai`'s gift penalty is an exhaustive match, so the variant had to be classified there too. `gift_extra_turn_penalty` is seeded at -30.0 and listed as UNTUNED with a reason: an extra turn is the largest downside in the family by a wide margin, but a tuned value needs a paired-seed `ai-gate` report, which this rules fix is not. ## Class Measured over `client/public/card-data.json`: 25 cards print a gift promise. **One changes** — Perch Protection, `Gift(Card)` → `Gift(ExtraTurn)`, confirmed by parsing its printed Oracle text through `parse_oracle_text`. Octomancer's "gift an Octopus" is unchanged, pinned by its own row. ## Counter-probe Dropping the `case "ExtraTurn"` from the client label turns `Gift an extra turn is named, not folded into the card fallback` red on `Unable to find an accessible element … /promise an extra turn/i`. ## Not covered CR 702.174i's Octopus (#5975). It belongs to the token family's parameterization, not to a fifth sibling. Co-Authored-By: Claude Opus 5 --- .../components/modal/OptionalCostModal.tsx | 4 ++ .../__tests__/OptionalCostModal.test.tsx | 27 ++++++++++ client/src/i18n/locales/de/game.json | 3 +- client/src/i18n/locales/en/game.json | 3 +- client/src/i18n/locales/es/game.json | 3 +- client/src/i18n/locales/fr/game.json | 3 +- client/src/i18n/locales/it/game.json | 3 +- client/src/i18n/locales/pt/game.json | 3 +- .../engine/src/game/effects/gift_delivery.rs | 50 +++++++++++++++++++ crates/engine/src/game/keywords.rs | 1 + crates/engine/src/parser/oracle_keyword.rs | 38 ++++++++++++++ crates/engine/src/types/keywords.rs | 9 ++++ crates/phase-ai/src/config.rs | 15 ++++++ .../src/policies/downside_awareness.rs | 4 ++ 14 files changed, 160 insertions(+), 6 deletions(-) diff --git a/client/src/components/modal/OptionalCostModal.tsx b/client/src/components/modal/OptionalCostModal.tsx index 815bca6764..a74e92722b 100644 --- a/client/src/components/modal/OptionalCostModal.tsx +++ b/client/src/components/modal/OptionalCostModal.tsx @@ -23,6 +23,10 @@ function giftKindLabel( return t("optionalCost.gift.kind.food"); case "TappedFish": return t("optionalCost.gift.kind.tappedFish"); + // CR 702.174g: the one promised gift that is not an object. + case "ExtraTurn": + return t("optionalCost.gift.kind.extraTurn"); + // CR 702.174g: the one promised gift that is not an object. default: return t("optionalCost.gift.kind.card"); } diff --git a/client/src/components/modal/__tests__/OptionalCostModal.test.tsx b/client/src/components/modal/__tests__/OptionalCostModal.test.tsx index 3eda99ac9e..88ee99b00e 100644 --- a/client/src/components/modal/__tests__/OptionalCostModal.test.tsx +++ b/client/src/components/modal/__tests__/OptionalCostModal.test.tsx @@ -119,4 +119,31 @@ describe("OptionalCostModalContent (issue #454)", () => { data: { pay: false }, }); }); + + // CR 702.174g: the one promised gift that is not an object. The label switch + // falls back to "a card" for anything it does not name, so an unlabelled kind + // does not look unlabelled — it looks like a DIFFERENT promise (#7286). + it("Gift an extra turn is named, not folded into the card fallback", () => { + const waitingFor: OptionalCostWaitingFor = { + type: "OptionalCostChoice", + data: { + player: 0, + cost: { + type: "Optional", + data: { + cost: { type: "Mana", cost: { type: "Cost", shards: [], generic: 0 } }, + repeatable: false, + }, + }, + times_kicked: 0, + origin: "Gift", + gift_kind: { type: "ExtraTurn" }, + pending_cast: {} as OptionalCostWaitingFor["data"]["pending_cast"], + }, + }; + renderModal(waitingFor); + + expect(screen.getByRole("button", { name: /promise an extra turn/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /promise a card/i })).toBeNull(); + }); }); diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index ad51f34b0f..85339726c6 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1853,7 +1853,8 @@ "card": "eine Karte", "treasure": "einen Schatz", "food": "eine Nahrung", - "tappedFish": "einen getappten Fisch" + "tappedFish": "einen getappten Fisch", + "extraTurn": "einen zusätzlichen Zug" } } }, diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 7e57452b0a..3fda3dc879 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1897,7 +1897,8 @@ "card": "a card", "treasure": "a Treasure", "food": "a Food", - "tappedFish": "a tapped Fish" + "tappedFish": "a tapped Fish", + "extraTurn": "an extra turn" } } }, diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index da54a18715..872d0d785e 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1853,7 +1853,8 @@ "card": "una carta", "treasure": "un Tesoro", "food": "un Alimento", - "tappedFish": "un Pez girado" + "tappedFish": "un Pez girado", + "extraTurn": "un turno adicional" } } }, diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index f6af513c1c..66dd34cd1b 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1853,7 +1853,8 @@ "card": "une carte", "treasure": "un Trésor", "food": "un Aliment", - "tappedFish": "un Poisson engagé" + "tappedFish": "un Poisson engagé", + "extraTurn": "un tour supplémentaire" } } }, diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 58dad4225c..a82237ca8f 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1853,7 +1853,8 @@ "card": "una carta", "treasure": "un Tesoro", "food": "un Cibo", - "tappedFish": "un Pesce TAPpato" + "tappedFish": "un Pesce TAPpato", + "extraTurn": "un turno extra" } } }, diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 9d8ad0022e..f7bfadcab8 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1853,7 +1853,8 @@ "card": "um card", "treasure": "um Tesouro", "food": "um Alimento", - "tappedFish": "um Peixe virado" + "tappedFish": "um Peixe virado", + "extraTurn": "um turno extra" } } }, diff --git a/crates/engine/src/game/effects/gift_delivery.rs b/crates/engine/src/game/effects/gift_delivery.rs index 33a95867dc..83b90defa8 100644 --- a/crates/engine/src/game/effects/gift_delivery.rs +++ b/crates/engine/src/game/effects/gift_delivery.rs @@ -85,6 +85,21 @@ pub fn resolve( obj.tapped = true; } } + // CR 702.174g: "Gift an extra turn" means "The chosen player takes an + // extra turn after this one." CR 500.7 owns the queue, so this routes + // through the same authority `Effect::ExtraTurn` uses rather than + // touching `extra_turns` directly. + // + // "After this one" is the ANCHOR: the extra turn follows the turn during + // which the gift resolved, which is `state.active_player`'s — not the + // recipient's next turn. `enqueue_extra_turn` takes that anchor as its + // third argument, exactly as the effect resolver passes it. + GiftKind::ExtraTurn => { + // CR 805.8: with shared team turns the extra turn is taken by the + // recipient's team; the same normalization the effect resolver does. + let recipient = crate::game::topology::normalize_shared_turn_recipient(state, opponent); + crate::game::turns::enqueue_extra_turn(state, recipient, state.active_player); + } } events.push(GameEvent::EffectResolved { @@ -219,6 +234,41 @@ mod tests { )); } + /// CR 702.174g + CR 500.7: the promised extra turn is queued for the chosen + /// player, anchored after the turn during which the gift resolved. + #[test] + fn gift_extra_turn_queues_a_turn_for_the_recipient() { + let mut state = GameState::new_two_player(42); + let mut events = Vec::new(); + + let ability = make_gift_ability(GiftKind::ExtraTurn, true); + resolve(&mut state, &ability, &mut events).unwrap(); + + assert_eq!( + state + .extra_turns + .iter() + .map(|turn| (turn.player, turn.anchor)) + .collect::>(), + vec![(PlayerId(1), state.active_player)], + "CR 702.174g: the CHOSEN player takes the extra turn, after this one" + ); + } + + /// The negative that keeps the row above honest: an unpromised gift queues + /// nothing, so the assertion is about the promise and not about the queue + /// being writable. + #[test] + fn gift_extra_turn_queues_nothing_when_not_promised() { + let mut state = GameState::new_two_player(42); + let mut events = Vec::new(); + + let ability = make_gift_ability(GiftKind::ExtraTurn, false); + resolve(&mut state, &ability, &mut events).unwrap(); + + assert!(state.extra_turns.is_empty()); + } + #[test] fn gift_card_uses_source_object_recipient_when_context_is_absent() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 2c749fe4e2..a3824fe11d 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -2715,6 +2715,7 @@ mod tests { GiftKind::Treasure, GiftKind::Food, GiftKind::TappedFish, + GiftKind::ExtraTurn, ] { let mut state = GameState::new_two_player(1); let id = create_object( diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index d39055a215..ecd7abddd8 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -1739,6 +1739,25 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { } } + // CR 702.174g: "Gift an extra turn". The article is part of the printed form + // and this kind takes "an", so the "gift a " scan below never saw it: the + // outer keyword scan then fell back to the bare `Gift` form, which defaults + // to `Card`, and Perch Protection promised a card draw instead of a turn + // (#7286). + // + // A separate scan rather than an `alt` over both articles, because an + // unknown "gift an [something]" must keep falling THROUGH to the outer scan + // exactly as it does today. CR 702.174i's Octopus is the live case + // (Octomancer, #5975) and has no `GiftKind` yet; folding it into the block + // below would turn its silent-`Card` parse into no keyword at all, which is + // a different wrong answer, in a card this change has no business touching. + if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("gift an ").parse(text) { + use crate::types::keywords::GiftKind; + if rest.trim() == "extra turn" { + return Some((Keyword::Gift(GiftKind::ExtraTurn), "")); + } + } + // Gift keyword: "gift a card", "gift a treasure", "gift a food", "gift a tapped fish" if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("gift a ").parse(text) { use crate::types::keywords::GiftKind; @@ -3636,6 +3655,25 @@ mod tests { assert_eq!(kw, Keyword::Gift(GiftKind::TappedFish)); } + /// CR 702.174g: the article is part of the printed form and this is the one + /// kind that takes "an". Matching only "gift a " dropped it, and the outer + /// scan fell back to the bare `Gift` form, which defaults to `Card` — Perch + /// Protection promised a card draw instead of a turn (#7286). + #[test] + fn parse_granted_keyword_fragment_gift_an_extra_turn() { + use crate::types::keywords::GiftKind; + let kw = parse_granted_keyword_fragment("gift an extra turn").unwrap(); + assert_eq!(kw, Keyword::Gift(GiftKind::ExtraTurn)); + } + + /// The other "an" form, CR 702.174i's Octopus, has no `GiftKind` yet + /// (Octomancer, #5975). It must keep falling THROUGH the new scan to the + /// same answer it gave before, so this change touches exactly one card. + #[test] + fn parse_granted_keyword_fragment_gift_an_octopus_is_unchanged() { + assert_eq!(parse_granted_keyword_fragment("gift an octopus"), None); + } + #[test] fn gift_is_keyword_cost_line() { assert!(is_keyword_cost_line("gift a card")); diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index c6f1496a67..f57302f78b 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -501,6 +501,15 @@ pub enum GiftKind { Food, /// Opponent creates a tapped 1/1 blue Fish creature token. TappedFish, + /// CR 702.174g: "Gift an extra turn" means "The chosen player takes an extra + /// turn after this one." The only promised gift that is not an object, which + /// is why it sits outside the token family rather than inside it. + /// + /// Perch Protection is the only shipped card in this class. CR 702.174i's + /// Octopus is still missing (#5975); it belongs to the token family + /// (Treasure / Food / tapped Fish), which is a parameterization those three + /// already want and this variant deliberately does not join. + ExtraTurn, } /// CR 702.11d: What a hexproof-from keyword protects against. diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index bf859827cf..86d2b75365 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -242,6 +242,9 @@ pub struct PolicyPenalties { pub gift_food_penalty: f64, /// Penalty for gifting opponent a tapped 1/1 Fish token. pub gift_fish_penalty: f64, + /// CR 702.174g: penalty for gifting an opponent an extra turn. Untuned — see + /// `UNTUNED_POLICY_PENALTY_FIELDS`. + pub gift_extra_turn_penalty: f64, /// Minimum creature value (from evaluate_creature) to justify gift removal. pub worthy_target_threshold: f64, @@ -560,6 +563,12 @@ impl Default for PolicyPenalties { gift_treasure_penalty: -1.5, gift_food_penalty: -1.0, gift_fish_penalty: -0.5, + // Placed an order of magnitude below the card gift rather than + // calibrated: an extra turn is the largest downside in the family by + // a wide margin (a whole untapping, draw and attack step for the + // opponent), and a single shipped card promises it. A tuned value + // needs a paired-seed `ai-gate` report, which this rules fix is not. + gift_extra_turn_penalty: -30.0, worthy_target_threshold: 3.0, overkill_base_penalty: -2.0, removal_quality_mismatch: -1.5, @@ -904,6 +913,12 @@ pub const ACTIVE_POLICY_PENALTY_FIELDS: &[&str] = &[ /// Policy penalties intentionally not present in an active CMA-ES parameter /// vector yet. pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ + ( + "gift_extra_turn_penalty", + "CR 702.174g extra-turn gift downside — one shipped card (Perch Protection); \ + seeded an order of magnitude below the card gift and awaiting a paired-seed \ + ai-gate calibration.", + ), ( "devotion_pip_progress", "CR 700.5 per-pip devotion progress weight — awaiting a paired-seed ai-gate calibration.", diff --git a/crates/phase-ai/src/policies/downside_awareness.rs b/crates/phase-ai/src/policies/downside_awareness.rs index 20e08661ff..ca66dfcf02 100644 --- a/crates/phase-ai/src/policies/downside_awareness.rs +++ b/crates/phase-ai/src/policies/downside_awareness.rs @@ -34,6 +34,10 @@ impl DownsideAwarenessPolicy { GiftKind::Treasure => ctx.penalties().gift_treasure_penalty, GiftKind::Food => ctx.penalties().gift_food_penalty, GiftKind::TappedFish => ctx.penalties().gift_fish_penalty, + // CR 702.174g: "The chosen player takes an extra turn after + // this one." The only promised gift that hands the opponent + // a whole turn rather than an object. + GiftKind::ExtraTurn => ctx.penalties().gift_extra_turn_penalty, }; } } From d93849b79b7c1e97b92d0c7b66db69ba972ef742 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:32:29 +0200 Subject: [PATCH 2/5] fix(PR-7543): bound the extra-turn gift inside the policy's own band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -30.0 saturated. `DownsideAwarenessPolicy` doubles the penalty on the pure-downside branch and `PolicyVerdict::score` clamps at `CRITICAL_MAX` (15.0), so both -30 and -60 arrived as -15 — flattening the new seed AND erasing the existing no-worthy-target distinction the doubling exists to draw. Seeded at -7.0 instead: the largest value the band admits with the doubling intact (-7.0 / -14.0), still above the card gift (-3.0 / -6.0) and under the ceiling. No rescale, so every existing gift value keeps its current behaviour; a rescale would have compressed the tuned card-doubled path from -6.0 to about -5.2. The stale range comment on `verdict` said [-6, 0] from #5473 and is now re-derived to [-14, 0], with the reason the bound is load-bearing rather than decorative. Two verdict-level rows, which is where the clamp actually sits — the existing gift rows all assert the RAW score and cannot see it: * an extra-turn gift still outweighs a card gift after the clamp; * the pure-downside branch stays distinguishable AND off the clamp, so the next seed bump cannot go unnoticed. Counter-probe: with the seed back at -30.0 the second row fails on `no_target=-15, with_target=-15` — the reported defect, reproduced. Co-Authored-By: Claude Opus 5 --- crates/phase-ai/src/config.rs | 23 +++-- .../src/policies/downside_awareness.rs | 91 ++++++++++++++++++- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 86d2b75365..1ec2e2111f 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -563,12 +563,18 @@ impl Default for PolicyPenalties { gift_treasure_penalty: -1.5, gift_food_penalty: -1.0, gift_fish_penalty: -0.5, - // Placed an order of magnitude below the card gift rather than - // calibrated: an extra turn is the largest downside in the family by - // a wide margin (a whole untapping, draw and attack step for the - // opponent), and a single shipped card promises it. A tuned value - // needs a paired-seed `ai-gate` report, which this rules fix is not. - gift_extra_turn_penalty: -30.0, + // The worst gift in the family — a whole untapping, draw and + // attack step for the opponent — but bounded by the POLICY'S OWN + // band rather than by that judgement. `DownsideAwarenessPolicy` + // doubles the penalty on the pure-downside branch and + // `PolicyVerdict::score` clamps at `CRITICAL_MAX` (15.0), so any seed + // past 7.5 saturates the doubled value and the two branches score + // identically — erasing the distinction the doubling exists to draw. + // -7.0 keeps both apart (-7.0 / -14.0), above the card gift + // (-3.0 / -6.0) and under the ceiling. The true weight awaits a + // paired-seed `ai-gate` calibration; see + // `UNTUNED_POLICY_PENALTY_FIELDS`. + gift_extra_turn_penalty: -7.0, worthy_target_threshold: 3.0, overkill_base_penalty: -2.0, removal_quality_mismatch: -1.5, @@ -916,8 +922,9 @@ pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ ( "gift_extra_turn_penalty", "CR 702.174g extra-turn gift downside — one shipped card (Perch Protection); \ - seeded an order of magnitude below the card gift and awaiting a paired-seed \ - ai-gate calibration.", + seeded at the largest value the downside policy's band admits without its \ + pure-downside doubling saturating, and awaiting a paired-seed ai-gate \ + calibration.", ), ( "devotion_pip_progress", diff --git a/crates/phase-ai/src/policies/downside_awareness.rs b/crates/phase-ai/src/policies/downside_awareness.rs index ca66dfcf02..f201d55905 100644 --- a/crates/phase-ai/src/policies/downside_awareness.rs +++ b/crates/phase-ai/src/policies/downside_awareness.rs @@ -78,11 +78,18 @@ impl TacticalPolicy for DownsideAwarenessPolicy { } fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { - // Range check (issue #5473): the largest gift penalty is `gift_card` - // (-3.0), doubled to -6.0 for pure-downside removal — so `score()` never - // leaves [-6.0, 0.0], comfortably inside the critical band. No rescale is - // needed; PolicyVerdict::score is identity here and simply upholds the - // band contract uniformly (no raw Score literal). + // Range check (issue #5473, re-derived for #7286): the largest gift + // penalty is `gift_extra_turn` (-7.0), doubled to -14.0 for pure-downside + // removal — so `score()` never leaves [-14.0, 0.0], still inside the + // critical band (`CRITICAL_MAX` = 15.0). No rescale is needed; + // `PolicyVerdict::score` is identity here and simply upholds the band + // contract uniformly (no raw Score literal). + // + // The bound is load-bearing, not decorative: a seed past 7.5 makes the + // DOUBLED value saturate at the clamp, and the pure-downside branch then + // scores identically to the ordinary one — erasing the distinction the + // doubling exists to draw. A future gift penalty either respects it or + // this policy starts rescaling (`registry::rescale_into_critical_band`). PolicyVerdict::score( self.score(ctx), PolicyReason::new("downside_awareness_score"), @@ -308,6 +315,80 @@ mod tests { ); } + /// The verdict-level assertions the raw-score rows above cannot make: the + /// clamp in `PolicyVerdict::score` sits between `score()` and what the + /// registry actually uses, and a penalty seeded past the band saturates + /// there. These two rows are what keep `gift_extra_turn_penalty` inside it. + fn verdict_delta( + state: &GameState, + decision: &AiDecisionContext, + candidate: &CandidateAction, + ) -> f64 { + let config = AiConfig::default(); + let ctx = PolicyContext { + state, + decision, + candidate, + ai_player: PlayerId(1), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + match DownsideAwarenessPolicy.verdict(&ctx) { + crate::policies::registry::PolicyVerdict::Score { delta, .. } => delta, + other => panic!("downside awareness must return a score, got {other:?}"), + } + } + + fn gift_verdict(kind: GiftKind, worthy_target: bool) -> f64 { + let mut state = make_state(); + if worthy_target { + add_creature(&mut state, PlayerId(0), 5, 5); + } + let (decision, candidate) = make_cast_ctx( + &mut state, + Effect::Bounce { + target: TargetFilter::Any, + destination: None, + selection: BounceSelection::Targeted, + }, + Some(Effect::GiftDelivery { kind }), + ); + verdict_delta(&state, &decision, &candidate) + } + + /// CR 702.174g: an extra turn is the worst promise in the family, and it has + /// to still READ as worse after the band clamp. + #[test] + fn gift_extra_turn_outweighs_a_card_gift_after_the_band_clamp() { + let extra_turn = gift_verdict(GiftKind::ExtraTurn, true); + let card = gift_verdict(GiftKind::Card, true); + assert!( + extra_turn < card, + "extra turn must outweigh a card gift: extra_turn={extra_turn}, card={card}" + ); + } + + /// The pure-downside doubling must survive the clamp too. A seed past 7.5 + /// saturates both branches at `-CRITICAL_MAX` and this row goes flat — which + /// is exactly the failure a larger, better-sounding penalty would cause. + #[test] + fn the_extra_turn_pure_downside_branch_stays_distinguishable() { + let no_target = gift_verdict(GiftKind::ExtraTurn, false); + let with_target = gift_verdict(GiftKind::ExtraTurn, true); + assert!( + no_target < with_target, + "the no-worthy-target branch must stay worse, not saturate: \ + no_target={no_target}, with_target={with_target}" + ); + assert!( + no_target > -crate::policies::registry::CRITICAL_MAX, + "and it must not sit ON the clamp, or the next seed bump goes unnoticed: \ + no_target={no_target}" + ); + } + #[test] fn gift_not_applied_during_targeting() { let state = make_state(); From 12369efac75c82736d757176e5da8176a91215b3 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 17:05:54 -0700 Subject: [PATCH 3/5] fix(PR-7543): preserve gift compatibility Restore Polish locale parity, default the new persisted AI penalty for older tuning artifacts, and correct the UI CR annotation.\n\nCo-authored-by: cuinhellcat --- .../components/modal/OptionalCostModal.tsx | 3 +- client/src/i18n/locales/pl/game.json | 3 +- crates/phase-ai/src/config.rs | 46 ++++++++++++++----- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/client/src/components/modal/OptionalCostModal.tsx b/client/src/components/modal/OptionalCostModal.tsx index a74e92722b..ed3f4f06dd 100644 --- a/client/src/components/modal/OptionalCostModal.tsx +++ b/client/src/components/modal/OptionalCostModal.tsx @@ -23,10 +23,9 @@ function giftKindLabel( return t("optionalCost.gift.kind.food"); case "TappedFish": return t("optionalCost.gift.kind.tappedFish"); - // CR 702.174g: the one promised gift that is not an object. + // CR 702.174g: the chosen player takes an extra turn after this one. case "ExtraTurn": return t("optionalCost.gift.kind.extraTurn"); - // CR 702.174g: the one promised gift that is not an object. default: return t("optionalCost.gift.kind.card"); } diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 24d96ae94c..e6611bc315 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1853,7 +1853,8 @@ "card": "kartę", "treasure": "Skarb", "food": "Pożywienie", - "tappedFish": "zakręconą Rybę" + "tappedFish": "zakręconą Rybę", + "extraTurn": "dodatkową turę" } } }, diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 1ec2e2111f..cb6aa483de 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -244,6 +244,7 @@ pub struct PolicyPenalties { pub gift_fish_penalty: f64, /// CR 702.174g: penalty for gifting an opponent an extra turn. Untuned — see /// `UNTUNED_POLICY_PENALTY_FIELDS`. + #[serde(default = "default_gift_extra_turn_penalty")] pub gift_extra_turn_penalty: f64, /// Minimum creature value (from evaluate_creature) to justify gift removal. pub worthy_target_threshold: f64, @@ -563,18 +564,7 @@ impl Default for PolicyPenalties { gift_treasure_penalty: -1.5, gift_food_penalty: -1.0, gift_fish_penalty: -0.5, - // The worst gift in the family — a whole untapping, draw and - // attack step for the opponent — but bounded by the POLICY'S OWN - // band rather than by that judgement. `DownsideAwarenessPolicy` - // doubles the penalty on the pure-downside branch and - // `PolicyVerdict::score` clamps at `CRITICAL_MAX` (15.0), so any seed - // past 7.5 saturates the doubled value and the two branches score - // identically — erasing the distinction the doubling exists to draw. - // -7.0 keeps both apart (-7.0 / -14.0), above the card gift - // (-3.0 / -6.0) and under the ceiling. The true weight awaits a - // paired-seed `ai-gate` calibration; see - // `UNTUNED_POLICY_PENALTY_FIELDS`. - gift_extra_turn_penalty: -7.0, + gift_extra_turn_penalty: default_gift_extra_turn_penalty(), worthy_target_threshold: 3.0, overkill_base_penalty: -2.0, removal_quality_mismatch: -1.5, @@ -657,6 +647,14 @@ fn default_graveyard_types_progress() -> f64 { fn default_wasted_cast_penalty() -> f64 { -8.0 } +/// The worst gift in the family — a whole untapping, draw and attack step for +/// the opponent — but bounded by the policy's own score band. The pure-downside +/// branch doubles this to -14.0; a seed past 7.5 would saturate its -15.0 clamp +/// and erase that distinction. Shared by `Default` and `#[serde(default)]` so +/// older `ai_tune` artifacts keep loading. +fn default_gift_extra_turn_penalty() -> f64 { + -7.0 +} /// CR 104.3d. Shared by `Default` and `#[serde(default)]` so a tuning artifact /// written before this field existed still deserializes (`ai_tune` reads the /// `policy_penalties` section directly into this struct). @@ -1788,6 +1786,30 @@ mod tests { ); } + #[test] + fn policy_penalties_load_pre_gift_extra_turn_artifact() { + let mut artifact = serde_json::to_value(PolicyPenalties::default()).unwrap(); + let object = artifact.as_object_mut().expect("serializes as object"); + object + .remove("gift_extra_turn_penalty") + .expect("field must be present before removal"); + object.insert("wasted_cast_penalty".into(), serde_json::json!(-3.5)); + + let loaded: PolicyPenalties = serde_json::from_value(artifact) + .expect("a pre-gift-extra-turn artifact must still deserialize"); + assert_eq!(loaded.wasted_cast_penalty, -3.5, "tuned value preserved"); + assert_eq!( + loaded.gift_extra_turn_penalty, + default_gift_extra_turn_penalty(), + "absent field must fall back to the shared default" + ); + assert_eq!( + PolicyPenalties::default().gift_extra_turn_penalty, + default_gift_extra_turn_penalty(), + "Default and serde must share one source of truth" + ); + } + /// Artifact compatibility: `ai_tune` deserializes a persisted /// `policy_penalties` section straight into `PolicyPenalties` /// (`bin/ai_tune.rs`, `TuneGroup::Penalties`), so an artifact written From 985893953e2e8a34180805f94370a11795f340e7 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 18:07:09 -0700 Subject: [PATCH 4/5] fix(PR-7543): preserve gift extra-turn keyword remainder Keep the keyword core remainder-preserving so strict routing permits terminal punctuation while rejecting semantic prose. Co-authored-by: cuinhellcat --- crates/engine/src/parser/oracle_keyword.rs | 25 +++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index ecd7abddd8..5650388f90 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use crate::parser::oracle_nom::error::{OracleError, OracleResult}; use nom::branch::alt; use nom::bytes::complete::{tag, take_until}; -use nom::character::complete::{alpha1, space0, space1}; +use nom::character::complete::{alpha1, alphanumeric1, space0, space1}; use nom::combinator::{all_consuming, eof, not, opt, peek, value}; use nom::sequence::preceded; use nom::Parser; @@ -1753,8 +1753,13 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { // a different wrong answer, in a card this change has no business touching. if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("gift an ").parse(text) { use crate::types::keywords::GiftKind; - if rest.trim() == "extra turn" { - return Some((Keyword::Gift(GiftKind::ExtraTurn), "")); + if let Ok((remainder, _)) = terminated( + tag::<_, _, OracleError<'_>>("extra turn"), + not(alphanumeric1), + ) + .parse(rest) + { + return Some((Keyword::Gift(GiftKind::ExtraTurn), remainder)); } } @@ -3666,6 +3671,20 @@ mod tests { assert_eq!(kw, Keyword::Gift(GiftKind::ExtraTurn)); } + #[test] + fn router_gift_an_extra_turn_preserves_the_tail() { + use crate::types::keywords::GiftKind; + + assert!(matches!( + parse_router_keyword_line("Gift an extra turn.").and_then(|routed| routed.keyword), + Some(Keyword::Gift(GiftKind::ExtraTurn)) + )); + assert!( + parse_router_keyword_line("Gift an extra turn if you control a Bird").is_none(), + "a semantic suffix must remain unconsumed so the strict router declines the line" + ); + } + /// The other "an" form, CR 702.174i's Octopus, has no `GiftKind` yet /// (Octomancer, #5975). It must keep falling THROUGH the new scan to the /// same answer it gave before, so this change touches exactly one card. From ed177fda481b79c74d1363534a854e9d7fb1ea49 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 19:04:49 -0700 Subject: [PATCH 5/5] fix(PR-7543): import extra-turn parser combinator --- crates/engine/src/parser/oracle_keyword.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index 5650388f90..465b31c7de 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -5,7 +5,7 @@ use nom::branch::alt; use nom::bytes::complete::{tag, take_until}; use nom::character::complete::{alpha1, alphanumeric1, space0, space1}; use nom::combinator::{all_consuming, eof, not, opt, peek, value}; -use nom::sequence::preceded; +use nom::sequence::{preceded, terminated}; use nom::Parser; use super::oracle_cost::parse_oracle_cost;