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
28 changes: 28 additions & 0 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,16 @@ export interface GameObject {
abilities: SerializedAbility[];
color: ManaColor[];
printed_ref?: PrintedRef | null;
/**
* Engine-owned discriminant for what this stored half actually IS. The
* `back_face` slot is shared by several printed layouts, so its presence
* alone does NOT mean the object is double-faced: CR 710 Kamigawa flip
* cards park their alternative (bottom) half here, and Adventure/Omen
* cards park their alternative spell here. Only `"Transform"`, `"Modal"`,
* and `"Meld"` are real second faces (CR 712). Absent when the engine has
* no layout to report.
*/
layout_kind?: LayoutKind | null;
} | null;
/**
* CR 702.143c-d: Whether this card in exile is foretold. Its owner may look
Expand All @@ -1109,6 +1119,22 @@ export interface PrintedRef {
face_name: string;
}

/**
* Mirror of the engine's `types::card::LayoutKind` (serialized as its plain
* variant name). Describes the printed layout that produced an object's stored
* `back_face`.
*/
export type LayoutKind =
| "Single"
| "Split"
| "Flip"
| "Transform"
| "Meld"
| "Adventure"
| "Modal"
| "Omen"
| "Prepare";

export interface ObjectIncarnationRef {
object_id: ObjectId;
incarnation: number;
Expand Down Expand Up @@ -2374,6 +2400,8 @@ export type GameEvent =
| { type: "BecomesTarget"; data: { target: TargetRef; source_id: ObjectId } }
| { type: "ReplacementApplied"; data: { source_id: ObjectId; event_type: string } }
| { type: "Transformed"; data: { object_id: ObjectId } }
// CR 710.4: a Kamigawa flip permanent flipped to its alternative face.
| { type: "Flipped"; data: { object_id: ObjectId } }
| { type: "DayNightChanged"; data: { new_state: string } }
| { type: "TurnedFaceUp"; data: { object_id: ObjectId } }
| { type: "TurnedFaceDown"; data: { object_id: ObjectId } }
Expand Down
7 changes: 5 additions & 2 deletions client/src/components/card/ArtCropCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImage
import { CARD_BACK_URL } from "../../services/scryfall.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
import { COUNTER_COLORS, computePTDisplay, toRoman } from "../../viewmodel/cardProps.ts";
import { COUNTER_COLORS, computePTDisplay, hasOtherPrintedFace, toRoman } from "../../viewmodel/cardProps.ts";
import { CounterTooltip } from "../ui/CounterTooltip.tsx";
import { LoyaltyBadge } from "../ui/LoyaltyBadge.tsx";
import { CardArtFallback } from "./CardArtFallback.tsx";
Expand Down Expand Up @@ -83,7 +83,10 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr

const src = obj.face_down ? CARD_BACK_URL : cardSrc;
const isLoading = obj.face_down ? false : cardLoading;
const hasDfc = !obj.face_down && obj.back_face != null;
// CR 712 vs CR 710: `back_face != null` is NOT "has a second face" — a
// Kamigawa flip card stores its alternative half in the same slot and has no
// face 1 to inspect. Use the engine-provided layout discriminant.
const hasDfc = !obj.face_down && hasOtherPrintedFace(obj);
// Filter out loyalty counters — shown separately as the loyalty badge
const counters = Object.entries(obj.counters).filter((entry): entry is [string, number] => entry[1] != null && entry[0] !== "loyalty");
const devotionValue = obj.devotion ?? null;
Expand Down
18 changes: 18 additions & 0 deletions client/src/viewmodel/cardProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,24 @@ export function formatTypeLine(cardTypes: CardType, keywords?: Keyword[]): strin
return main;
}

/**
* True when `obj`'s stored `back_face` is a SEPARATELY PRINTED face the UI can
* present on its own — a CR 712 transform/modal/meld back face, or an
* Adventure/Omen/Prepare alternative spell.
*
* False for CR 710 Kamigawa flip cards. Their alternative half is printed
* upside down on the SAME physical face, so there is no second face to inspect
* (CardPreview renders it as a 180° rotation instead). The `back_face` slot is
* shared by all of these layouts, so `back_face != null` is NOT the predicate:
* using it gives all 21 flip cards a bogus DFC badge and an "inspect face 1"
* button pointing at a face that doesn't exist. The engine owns the
* discriminant — it ships `layout_kind` on the serialized back face (the same
* value `engine::game::transform::is_double_faced_permanent` keys on).
*/
export function hasOtherPrintedFace(obj: Pick<GameObject, "back_face">): boolean {
return obj.back_face != null && obj.back_face.layout_kind !== "Flip";
}

export function computePTDisplay(obj: GameObject): PTDisplay | null {
if (obj.power == null || obj.toughness == null) return null;

Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,9 @@ fn effect_projection(effect: &Effect) -> Projection {
| Effect::Discard { .. }
| Effect::Shuffle { .. }
| Effect::Transform { .. }
// CR 710.4: a flip instruction carries no nested ability edge, exactly
// like `Transform`.
| Effect::FlipPermanent { .. }
| Effect::SearchOutsideGame { .. }
| Effect::RevealHand { .. }
| Effect::RevealFromHand { .. }
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2804,6 +2804,8 @@ fn legacy_effect(x: &Effect) -> bool {
| Effect::HideawayConceal { target }
| Effect::ChooseCard { target, .. }
| Effect::Transform { target }
// CR 710.4: same single-target-slot shape as `Transform`.
| Effect::FlipPermanent { target }
| Effect::Shuffle { target }
| Effect::Reveal { target }
| Effect::TargetOnly { target }
Expand Down Expand Up @@ -4891,6 +4893,10 @@ fn rw_effect(
} => obj(StateKind::ObjectPt, target),
Effect::SwitchPT { target } => obj(StateKind::ObjectPt, target),
Effect::Transform { target } => obj(StateKind::ObjectPt, target),
// CR 710.1b: flipping replaces the permanent's power and toughness
// (along with its name, type line, and text box) — the same
// `ObjectPt` write axis `Transform` records.
Effect::FlipPermanent { target } => obj(StateKind::ObjectPt, target),
Effect::BecomeCopy {
target,
recipient,
Expand Down
17 changes: 17 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,13 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes {
acc = acc.or(scan_target_filter(target, target_ctx, mode));
acc
}
// CR 710.4: identical scan shape to `Transform` — the only read is the
// effect's own target filter.
Effect::FlipPermanent { target } => {
let mut acc = Axes::NONE;
acc = acc.or(scan_target_filter(target, target_ctx, mode));
acc
}
Effect::SearchLibrary { .. } => Axes::CONSERVATIVE,
Effect::SearchOutsideGame {
filter,
Expand Down Expand Up @@ -5286,6 +5293,8 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext {
| Effect::Discard { .. }
| Effect::Shuffle { .. }
| Effect::Transform { .. }
// CR 710.4: same single-target read context as `Transform`.
| Effect::FlipPermanent { .. }
| Effect::SearchLibrary { .. }
| Effect::SearchOutsideGame { .. }
| Effect::RevealHand { .. }
Expand Down Expand Up @@ -5667,6 +5676,9 @@ fn effect_census_role(e: &Effect) -> CensusRole {
| Effect::Discard { .. }
| Effect::Shuffle { .. }
| Effect::Transform { .. }
// CR 710.4: a flip reads only its own self-referential target — not a
// board census, mirroring `Transform`.
| Effect::FlipPermanent { .. }
| Effect::TargetOnly { .. }
| Effect::Choose { .. }
| Effect::ChooseDamageSource { .. }
Expand Down Expand Up @@ -5937,6 +5949,9 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom {
| Effect::Discard { .. }
| Effect::Shuffle { .. }
| Effect::Transform { .. }
// CR 710.4: `flip_permanent` offers no resolution-time choice (it is a
// status change or a silent no-op), exactly like `Transform`.
| Effect::FlipPermanent { .. }
| Effect::SearchLibrary { .. }
| Effect::SearchOutsideGame { .. }
| Effect::RevealHand { .. }
Expand Down Expand Up @@ -6203,6 +6218,8 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool {
| Effect::Mana { .. }
| Effect::Shuffle { .. }
| Effect::Transform { .. }
// CR 710.4: flipping is deterministic — no RNG draw, mirroring `Transform`.
| Effect::FlipPermanent { .. }
| Effect::SearchLibrary { .. }
| Effect::SearchOutsideGame { .. }
| Effect::RevealFromHand { .. }
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4834,6 +4834,8 @@ fn concretize_granting_object_in_effect(effect: &mut Effect, granter: ObjectId)
| Effect::Pump { target, .. }
| Effect::Counter { target, .. }
| Effect::Transform { target, .. }
// CR 710.4: same single-target-slot shape as `Transform`.
| Effect::FlipPermanent { target, .. }
| Effect::Connive { target, .. }
| Effect::PhaseOut { target }
| Effect::PhaseIn { target }
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2352,6 +2352,8 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> {
| Effect::ForceBlock { target }
| Effect::ForceAttack { target, .. }
| Effect::Transform { target }
// CR 710.4: the flipping permanent is the effect's single reported target.
| Effect::FlipPermanent { target }
| Effect::Shuffle { target }
| Effect::Reveal { target }
| Effect::Regenerate { target }
Expand Down
Loading
Loading