Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/src/components/modal/OptionalCostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ function giftKindLabel(
return t("optionalCost.gift.kind.food");
case "TappedFish":
return t("optionalCost.gift.kind.tappedFish");
// CR 702.174g: the chosen player takes an extra turn after this one.
case "ExtraTurn":
return t("optionalCost.gift.kind.extraTurn");
default:
return t("optionalCost.gift.kind.card");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
3 changes: 2 additions & 1 deletion client/src/i18n/locales/de/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion client/src/i18n/locales/en/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion client/src/i18n/locales/es/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion client/src/i18n/locales/fr/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion client/src/i18n/locales/it/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion client/src/i18n/locales/pl/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1853,7 +1853,8 @@
"card": "kartę",
"treasure": "Skarb",
"food": "Pożywienie",
"tappedFish": "zakręconą Rybę"
"tappedFish": "zakręconą Rybę",
"extraTurn": "dodatkową turę"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion client/src/i18n/locales/pt/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
50 changes: 50 additions & 0 deletions crates/engine/src/game/effects/gift_delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<_>>(),
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);
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
61 changes: 59 additions & 2 deletions crates/engine/src/parser/oracle_keyword.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ 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::sequence::{preceded, terminated};
use nom::Parser;

use super::oracle_cost::parse_oracle_cost;
Expand Down Expand Up @@ -1739,6 +1739,30 @@ 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 let Ok((remainder, _)) = terminated(
tag::<_, _, OracleError<'_>>("extra turn"),
not(alphanumeric1),
)
.parse(rest)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
return Some((Keyword::Gift(GiftKind::ExtraTurn), remainder));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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;
Expand Down Expand Up @@ -3636,6 +3660,39 @@ 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));
}

#[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.
#[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"));
Expand Down
9 changes: 9 additions & 0 deletions crates/engine/src/types/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions crates/phase-ai/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ 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`.
#[serde(default = "default_gift_extra_turn_penalty")]
pub gift_extra_turn_penalty: f64,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Minimum creature value (from evaluate_creature) to justify gift removal.
pub worthy_target_threshold: f64,

Expand Down Expand Up @@ -560,6 +564,7 @@ impl Default for PolicyPenalties {
gift_treasure_penalty: -1.5,
gift_food_penalty: -1.0,
gift_fish_penalty: -0.5,
gift_extra_turn_penalty: default_gift_extra_turn_penalty(),
worthy_target_threshold: 3.0,
overkill_base_penalty: -2.0,
removal_quality_mismatch: -1.5,
Expand Down Expand Up @@ -642,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).
Expand Down Expand Up @@ -904,6 +917,13 @@ 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 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",
"CR 700.5 per-pip devotion progress weight — awaiting a paired-seed ai-gate calibration.",
Expand Down Expand Up @@ -1766,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
Expand Down
Loading
Loading