Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions client/src/i18n/locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Schließen",
"closeNamed": "{{name}} schließen"
},
"quantityRef": {
"highestNumber": "die höchste Zahl",
"lowestNumber": "die niedrigste Zahl",
"chosenNumber": "die gewählte Zahl"
},
"scryOutcome": {
"title": "Spähen abgeschlossen",
"you": "Du",
Expand Down
5 changes: 5 additions & 0 deletions client/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Close",
"closeNamed": "Close {{name}}"
},
"quantityRef": {
"highestNumber": "the highest number",
"lowestNumber": "the lowest number",
"chosenNumber": "the chosen number"
},
"scryOutcome": {
"title": "Scry complete",
"you": "You",
Expand Down
5 changes: 5 additions & 0 deletions client/src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Cerrar",
"closeNamed": "Cerrar {{name}}"
},
"quantityRef": {
"highestNumber": "el número más alto",
"lowestNumber": "el número más bajo",
"chosenNumber": "el número elegido"
},
"scryOutcome": {
"title": "Adivinación completada",
"you": "Tú",
Expand Down
5 changes: 5 additions & 0 deletions client/src/i18n/locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Fermer",
"closeNamed": "Fermer {{name}}"
},
"quantityRef": {
"highestNumber": "le nombre le plus élevé",
"lowestNumber": "le nombre le plus bas",
"chosenNumber": "le nombre choisi"
},
"scryOutcome": {
"title": "Regard terminé",
"you": "Vous",
Expand Down
5 changes: 5 additions & 0 deletions client/src/i18n/locales/it/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Chiudi",
"closeNamed": "Chiudi {{name}}"
},
"quantityRef": {
"highestNumber": "il numero più alto",
"lowestNumber": "il numero più basso",
"chosenNumber": "il numero scelto"
},
"scryOutcome": {
"title": "Scry completato",
"you": "Tu",
Expand Down
5 changes: 5 additions & 0 deletions client/src/i18n/locales/pl/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Zamknij",
"closeNamed": "Zamknij {{name}}"
},
"quantityRef": {
"highestNumber": "najwyższa liczba",
"lowestNumber": "najniższa liczba",
"chosenNumber": "wybrana liczba"
},
"scryOutcome": {
"title": "Wróżenie zakończone",
"you": "Ty",
Expand Down
5 changes: 5 additions & 0 deletions client/src/i18n/locales/pt/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Fechar",
"closeNamed": "Fechar {{name}}"
},
"quantityRef": {
"highestNumber": "o número mais alto",
"lowestNumber": "o número mais baixo",
"chosenNumber": "o número escolhido"
},
"scryOutcome": {
"title": "Vidência concluída",
"you": "Você",
Expand Down
38 changes: 38 additions & 0 deletions client/src/viewmodel/__tests__/costLabel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,44 @@ describe("additionalCostChoices — repeatable additional cost", () => {
});

describe("formatAbilityCost", () => {
// CR 101.4: `QuantityRef::PlayerChosenNumber` renders the cross-player fold the
// engine supplies — "the highest number" for `Max`, "the lowest number" for
// `Min` — and falls back to the bare noun for a single-player scope, which
// carries no fold. All three go through the i18n boundary, so the assertions
// read the `en` catalog rather than frontend-authored literals.
it.each([
["Max", "Pay the highest number life"],
["Min", "Pay the lowest number life"],
])("formats a chosen-number cost for the %s fold", (aggregate, expected) => {
expect(
formatAbilityCost({
type: "PayLife",
amount: {
type: "Ref",
qty: {
type: "PlayerChosenNumber",
player: { type: "AllPlayers", aggregate },
},
},
}),
).toBe(expected);
});

it("falls back to the bare noun for a scoped chosen number", () => {
expect(
formatAbilityCost({
type: "PayLife",
amount: {
type: "Ref",
qty: {
type: "PlayerChosenNumber",
player: { type: "ScopedPlayer" },
},
},
}),
).toBe("Pay the chosen number life");
});

it("formats disjunctive activation cost branches", () => {
expect(formatAbilityCost({
type: "OneOf",
Expand Down
15 changes: 15 additions & 0 deletions client/src/viewmodel/costLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
SerializedAbility,
SerializedAbilityCost,
} from "../adapter/types.ts";
import i18n from "../i18n";
import { getCrewPower, getSaddlePower } from "./keywordProps.ts";
import { renderDescription } from "../utils/description.ts";

Expand Down Expand Up @@ -187,6 +188,20 @@ function formatQuantityRef(ref: { type: string; [key: string]: unknown }): strin
case "ExiledFromHandThisResolution": return "cards exiled from hand";
case "Speed": return "your speed";
case "ChosenNumber": return "the chosen number";
// CR 101.4: the number a player secretly chose. The engine supplies the
// player scope (and, for the cross-player scopes, the fold); this only
// renders it — "the highest number" / "the lowest number". Routed through
// the i18n boundary; the surrounding labels in this file are legacy raw
// English and are tracked separately.
case "PlayerChosenNumber": {
const aggregate =
ref.player != null && typeof ref.player === "object" && "aggregate" in ref.player
? (ref.player as { aggregate?: string }).aggregate
: undefined;
if (aggregate === "Max") return i18n.t("quantityRef.highestNumber");
if (aggregate === "Min") return i18n.t("quantityRef.lowestNumber");
return i18n.t("quantityRef.chosenNumber");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case "PreviousEffectAmount": return "the previous amount";
case "EventContextAmount": return "the amount";
case "EventContextSourcePower": return "the source's power";
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,7 @@ fn effect_projection(effect: &Effect) -> Projection {
| Effect::TargetOnly { .. }
| Effect::Choose { .. }
| Effect::SwapChosenLabels { .. }
| Effect::RevealChosenNumbers { .. }
| Effect::ChooseDamageSource { .. }
| Effect::Suspect { .. }
| Effect::Unsuspect { .. }
Expand Down
15 changes: 15 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2095,6 +2095,7 @@ fn legacy_quantity_ref(x: &QuantityRef) -> bool {
| QuantityRef::TurnsTaken
| QuantityRef::CrimesCommittedThisTurn
| QuantityRef::ChosenNumber
| QuantityRef::PlayerChosenNumber { .. }
| QuantityRef::AttackedThisTurn { .. }
| QuantityRef::DescendedThisTurn
// CR 701.65b/701.66b/701.67c: controller-scoped per-turn bend accumulator
Expand Down Expand Up @@ -3055,6 +3056,8 @@ fn legacy_effect(x: &Effect) -> bool {
first: _,
second: _,
} => false,
// CR 101.4: carries a `PlayerFilter`, not a legacy tag-bearing target.
Effect::RevealChosenNumbers { players: _ } => false,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Effect::Attach { attachment, target } | Effect::UnattachAll { attachment, target } => {
legacy_target_filter(attachment) || legacy_target_filter(target)
}
Expand Down Expand Up @@ -5608,6 +5611,12 @@ fn rw_effect(
first: _,
second: _,
} => (ext_write(StateKind::Other), None),
// CR 101.4 + CR 603.3b: publishes per-player chosen numbers. The write is
// to the same per-player chosen-attribute storage `Effect::Choose`
// produces, so it is classified with it (`StateKind::Other`, the
// unclassifiable/fail-closed kind) rather than given a narrower kind that
// no profiled read would conflict with.
Effect::RevealChosenNumbers { players: _ } => (ext_write(StateKind::Other), None),

// ---- Histogram-absent ⇒ fail-closed conservative ----
Effect::StartYourEngines { .. }
Expand Down Expand Up @@ -5903,6 +5912,12 @@ fn rw_quantity_ref(x: &QuantityRef) -> RwProfile {
| QuantityRef::TrackedSetSize
| QuantityRef::FilteredTrackedSetSize { .. }
| QuantityRef::ChosenNumber
// CR 101.4 + CR 608.2d: the player-axis chosen-number read. Its producer
// is a persisting `Effect::Choose`, whose own arm below already declares
// `reads_member_bound`; classifying the reader the same way keeps the
// CR 603.3b same-event ordering gate fail-closed for the producer/consumer
// pair, exactly as for the object-axis `ChosenNumber` sibling.
| QuantityRef::PlayerChosenNumber { .. }
| QuantityRef::CostXPaid
| QuantityRef::KickerCount
| QuantityRef::AdditionalCostPaymentCount
Expand Down
15 changes: 15 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,11 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes {
first: _,
second: _,
} => Axes::CONSERVATIVE,
// CR 101.4: publishes an already-committed per-player number. Writes only
// the visibility half of the chosen-number ledger (`Number` ->
// `RevealedNumber`), never a value, so it perturbs no scanned axis; the
// player set it names is the only thing to descend into.
Effect::RevealChosenNumbers { players } => scan_player_filter(players, mode),
Effect::EachSourceDealsDamage {
sources,
amount,
Expand Down Expand Up @@ -2369,6 +2374,13 @@ fn scan_quantity_ref(x: &QuantityRef, mode: ScanMode) -> Axes {
acc
}
QuantityRef::ChosenNumber => Axes::NONE,
// CR 101.4 + CR 608.2d: the number a player chose this resolution. Like
// its object-axis sibling `ChosenNumber` this is a bounded one-shot
// answer, not an accumulating projected resource — a re-choose REPLACES
// the stored value rather than adding to it (`bind_named_choice`), so it
// cannot grow across loop iterations. The only axis it can contribute is
// whatever its player scope carries.
QuantityRef::PlayerChosenNumber { player } => scan_player_scope(player),
QuantityRef::AttackedThisTurn { scope, filter } => {
let mut acc = Axes::NONE;
acc = acc.or(scan_count_scope(scope));
Expand Down Expand Up @@ -5371,6 +5383,7 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext {
| Effect::ApplyPostReplacementDamage { .. }
| Effect::OpponentGuess { .. }
| Effect::SwapChosenLabels { .. }
| Effect::RevealChosenNumbers { .. }
| Effect::Draw { .. }
| Effect::Pump { .. }
| Effect::PairWith { .. }
Expand Down Expand Up @@ -5781,6 +5794,7 @@ fn effect_census_role(e: &Effect) -> CensusRole {
| Effect::ApplyPostReplacementDamage { .. }
| Effect::OpponentGuess { .. }
| Effect::SwapChosenLabels { .. }
| Effect::RevealChosenNumbers { .. }
| Effect::Draw { .. }
| Effect::Pump { .. }
| Effect::PairWith { .. }
Expand Down Expand Up @@ -5999,6 +6013,7 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool {
| Effect::EachDealsDamageEqualToPower { .. }
| Effect::OpponentGuess { .. }
| Effect::SwapChosenLabels { .. }
| Effect::RevealChosenNumbers { .. }
| Effect::Draw { .. }
| Effect::Pump { .. }
| Effect::PairWith { .. }
Expand Down
10 changes: 10 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,9 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String {
}
QuantityRef::TurnsTaken => "turns taken".into(),
QuantityRef::ChosenNumber => "chosen number".into(),
QuantityRef::PlayerChosenNumber { player } => {
format!("secretly chosen number ({})", fmt_player_scope(player))
}
QuantityRef::AttackedThisTurn { .. } => "attacked this turn".into(),
QuantityRef::DescendedThisTurn => "descended this turn".into(),
QuantityRef::LoyaltyAbilitiesActivatedThisTurn { player } => {
Expand Down Expand Up @@ -2923,6 +2926,9 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> {
Effect::SwapChosenLabels { first, second } => {
d.push(("swap".into(), format!("{first} <-> {second}")));
}
Effect::RevealChosenNumbers { players } => {
d.push(("reveal chosen numbers".into(), format!("{players:?}")));
}
Effect::ChooseDamageSource { source_filter } => {
d.push(("source".into(), fmt_target(source_filter)));
}
Expand Down Expand Up @@ -6472,6 +6478,7 @@ fn visit_direct_effect_ability_payloads<'a>(
| Effect::Choose { .. }
| Effect::OpponentGuess { .. }
| Effect::SwapChosenLabels { .. }
| Effect::RevealChosenNumbers { .. }
| Effect::ChooseDamageSource { .. }
| Effect::Suspect { .. }
| Effect::Unsuspect { .. }
Expand Down Expand Up @@ -8075,6 +8082,9 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) {
// strict-failure marker anywhere, so it is genuinely handled.
QuantityRef::TurnsTaken => ("TurnsTaken", Handled),
QuantityRef::ChosenNumber => ("ChosenNumber", Unhandled),
// CR 101.4 + CR 608.2d: resolved live in `quantity::resolve_quantity`
// over `Player::chosen_attributes` (per-candidate and aggregate scopes).
QuantityRef::PlayerChosenNumber { .. } => ("PlayerChosenNumber", Handled),
QuantityRef::AttackedThisTurn { .. } => ("AttackedThisTurn", Handled),
QuantityRef::DescendedThisTurn => ("DescendedThisTurn", Unhandled),
QuantityRef::LoyaltyAbilitiesActivatedThisTurn { .. } => {
Expand Down
Loading