Skip to content
Merged
12 changes: 11 additions & 1 deletion client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1417,9 +1417,13 @@ export type KeywordAction =
export type StackEntryKind =
| { type: "Spell"; data: { card_id: CardId; ability?: ResolvedAbility; actual_mana_spent?: number } }
| { type: "ActivatedAbility"; data: { source_id: ObjectId; ability: ResolvedAbility } }
| { type: "TriggeredAbility"; data: { source_id: ObjectId; ability: ResolvedAbility; description?: string; source_name?: string } }
| { type: "TriggeredAbility"; data: { source_id: ObjectId; ability: ResolvedAbility; description?: string; source_name?: string; provenance?: SyntheticTriggerProvenance } }
| { type: "KeywordAction"; data: { action: KeywordAction } };

/** Engine-authored identity for a synthesized triggered ability. */
export type SyntheticTriggerProvenance =
| { type: "Storm"; data: { copy_count: number } };

export interface StackEntry {
id: ObjectId;
source_id: ObjectId;
Expand Down Expand Up @@ -1470,6 +1474,7 @@ export interface StackEntryDisplay {
targets?: StackTargetDisplay[];
paid?: StackPaidFactView[];
trigger_context?: TriggerContextDisplay[];
provenance?: SyntheticTriggerProvenance;
}

// ── Pending Cast (for target selection) ──────────────────────────────────
Expand Down Expand Up @@ -2798,6 +2803,11 @@ export interface DerivedViews {
* infer game logic from raw abilities.
*/
stack_entry_details?: Record<string, StackEntryDisplay>;
/**
* CR 702.40a: prospective Storm copy counts for the viewing player's own
* hand, keyed by hand object id. The engine owns qualification and counting.
*/
prospective_storm_counts?: Record<string, number>;
/**
* Engine-authored "Auras attached to player X" projection. Players have no
* `attachments` back-link on the GameObject side because they aren't
Expand Down
16 changes: 16 additions & 0 deletions client/src/components/hand/MobileHandDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { CardOrganizerToolbar } from "../modal/cardChoice/CardOrganizerToolbar.t
// Stable empty lookup so an undefined `objects` (pre-game) never busts the
// organizer's filter memo with a fresh `{}` each render.
const EMPTY_OBJECTS: Record<string, GameObject> = {};
const EMPTY_STORM_COUNTS: Record<string, number> = {};

export function MobileHandDrawer() {
const { t } = useTranslation("game");
Expand All @@ -30,6 +31,9 @@ export function MobileHandDrawer() {
const playerId = usePerspectivePlayerId();
const player = useGameStore((s) => s.gameState?.players[playerId]);
const objects = useGameStore((s) => s.gameState?.objects);
const prospectiveStormCounts = useGameStore(
(s) => s.gameState?.derived?.prospective_storm_counts ?? EMPTY_STORM_COUNTS,
);
const legalActionsByObject = useGameStore((s) => s.legalActionsByObject);
const inspectObject = useUiStore((s) => s.inspectObject);
const setPendingAbilityChoice = useUiStore((s) => s.setPendingAbilityChoice);
Expand Down Expand Up @@ -200,6 +204,7 @@ export function MobileHandDrawer() {
manaCost={obj.mana_cost}
isPlayable={isPlayable}
hasPriority={hasPriority}
stormCopyCount={prospectiveStormCounts[String(obj.id)]}
onPlay={playCard}
onDebugOpen={handleDebugOpen}
/>
Expand All @@ -219,6 +224,7 @@ interface DrawerCardProps {
manaCost: ManaCost;
isPlayable: boolean;
hasPriority: boolean;
stormCopyCount?: number;
onPlay: (objectId: number) => void;
onDebugOpen: (objectId: number, x: number, y: number) => void;
}
Expand All @@ -229,9 +235,11 @@ const DrawerCard = memo(function DrawerCard({
manaCost,
isPlayable,
hasPriority,
stormCopyCount,
onPlay,
onDebugOpen,
}: DrawerCardProps) {
const { t } = useTranslation("game");
const inspectObject = useUiStore((s) => s.inspectObject);
const setPreviewSticky = useUiStore((s) => s.setPreviewSticky);
const effectiveCost = useGameStore((s) => s.spellCosts[String(objectId)]);
Expand Down Expand Up @@ -296,6 +304,14 @@ const DrawerCard = memo(function DrawerCard({
<div className="pointer-events-none absolute inset-0 @container">
<ManaCostPips cost={displayCost} isReduced={isReduced} size="fluid" />
</div>
{stormCopyCount !== undefined && (
<span
className="pointer-events-none absolute right-1 top-1 rounded-full bg-violet-700 px-1.5 py-0.5 text-[11px] font-bold leading-none text-white shadow-md"
title={t("storm.copies", { count: stormCopyCount })}
>
{stormCopyCount}
</span>
)}
</button>
);
});
13 changes: 12 additions & 1 deletion client/src/components/hand/MobileHeldHandCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
motion,
useMotionValue,
Expand All @@ -20,6 +21,7 @@ import { ManaCostPips } from "../mana/ManaCostPips.tsx";
interface MobileHeldHandCardProps {
gesture: MobileHandGesture | null;
object: GameObject | null;
stormCopyCount?: number;
}

/**
Expand All @@ -29,7 +31,8 @@ interface MobileHeldHandCardProps {
* fixed-position child into a container-relative element. The real HandCard
* remains keyed in the fan but collapsed until the gesture ends.
*/
export function MobileHeldHandCard({ gesture, object }: MobileHeldHandCardProps) {
export function MobileHeldHandCard({ gesture, object, stormCopyCount }: MobileHeldHandCardProps) {
const { t } = useTranslation("game");
const effectiveCost = useGameStore((s) =>
object ? s.spellCosts[String(object.id)] : undefined,
);
Expand Down Expand Up @@ -130,6 +133,14 @@ export function MobileHeldHandCard({ gesture, object }: MobileHeldHandCardProps)
<div className="pointer-events-none absolute inset-0 @container">
<ManaCostPips cost={displayCost} isReduced={isReduced} size="fluid" />
</div>
{stormCopyCount !== undefined && (
<span
className="absolute right-1 top-1 rounded-full bg-violet-700 px-1.5 py-0.5 text-[11px] font-bold leading-none text-white shadow-md"
title={t("storm.copies", { count: stormCopyCount })}
>
{stormCopyCount}
</span>
)}
</motion.div>,
document.body,
);
Expand Down
21 changes: 21 additions & 0 deletions client/src/components/hand/PlayerHand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { MobileHeldHandCard } from "./MobileHeldHandCard.tsx";
// Stable empty lookup so an undefined `objects` (pre-game) never busts the
// organizer's filter memo with a fresh `{}` each render.
const EMPTY_OBJECTS: Record<string, GameObject> = {};
const EMPTY_STORM_COUNTS: Record<string, number> = {};

// The whole-row fan geometry — the overlap / tilt / arc that lays hand cards
// (plus the castable exile / graveyard "wings") out as one held hand — now
Expand Down Expand Up @@ -82,6 +83,9 @@ export function PlayerHand() {
// otherwise rebuild the drag-end callback on every update.
const hand = player?.hand;
const objects = useGameStore((s) => s.gameState?.objects);
const prospectiveStormCounts = useGameStore(
(s) => s.gameState?.derived?.prospective_storm_counts ?? EMPTY_STORM_COUNTS,
);
const mobileHandGesture = useUiStore((s) => s.mobileHandGesture);
// Use dispatchAction (animation pipeline) instead of store dispatch
const inspectObject = useUiStore((s) => s.inspectObject);
Expand Down Expand Up @@ -663,6 +667,7 @@ export function PlayerHand() {
isSelected={selectedCardId === obj.id}
hasPriority={hasPriority}
isMobile={isMobile}
stormCopyCount={prospectiveStormCounts[String(obj.id)]}
onDragEnd={handleDragEnd}
onDrag={handleDrag}
onClick={handleCardClick}
Expand Down Expand Up @@ -784,6 +789,11 @@ export function PlayerHand() {
? objects[mobileHandGesture.objectId]
: null
}
stormCopyCount={
mobileHandGesture
? prospectiveStormCounts[String(mobileHandGesture.objectId)]
: undefined
}
/>
</>
);
Expand Down Expand Up @@ -811,6 +821,7 @@ interface HandCardProps {
isDragging: boolean;
hasPriority: boolean;
isMobile: boolean;
stormCopyCount?: number;
onDragStart: (id: number) => void;
onDragStop: () => void;
onDragEnd: (objectId: number, event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => boolean;
Expand Down Expand Up @@ -843,6 +854,7 @@ const HandCard = memo(function HandCard({
isDragging,
hasPriority,
isMobile,
stormCopyCount,
onDragStart: onDragStartProp,
onDragStop,
onDragEnd,
Expand All @@ -852,6 +864,7 @@ const HandCard = memo(function HandCard({
onMouseEnter,
onMouseLeave,
}: HandCardProps) {
const { t } = useTranslation("game");
const inspectObject = useUiStore((s) => s.inspectObject);
const setDragging = useUiStore((s) => s.setDragging);
const isMobileDragged = useUiStore(
Expand Down Expand Up @@ -997,6 +1010,14 @@ const HandCard = memo(function HandCard({
unimplementedMechanics={unimplementedMechanics}
className="!w-[var(--hand-card-w)] !h-[var(--hand-card-h)]"
/>
{stormCopyCount !== undefined && (
<span
className="pointer-events-none absolute -right-1 -top-2 rounded-full bg-violet-700 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white shadow-md"
title={t("storm.copies", { count: stormCopyCount })}
>
{stormCopyCount}
</span>
)}
{/* Inner-edge drop highlights. Always rendered, normally invisible; their
opacity is driven by MotionValues so the glow toggles without a
re-render. They sit inside the displaced + rotated card, so they track
Expand Down
13 changes: 12 additions & 1 deletion client/src/components/stack/StackEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ export function StackEntry({ entry, index, isTop, isPending, cardSize, style, on
details?.paid?.filter((fact) => fact.type !== "XValue").map((fact) => formatPaidFact(fact, t)) ??
[];
const contextLabels = details?.trigger_context?.map((context) => context.label) ?? [];
const stormCopyCount = details?.provenance?.type === "Storm"
? details.provenance.data.copy_count
: undefined;
const controllerLabel = entry.controller === playerId ? t("stack.controllerYou") : t("stack.controllerOpp");
const seatColor = useSeatColor(entry.controller);
const controllerInitial =
Expand Down Expand Up @@ -333,8 +336,16 @@ export function StackEntry({ entry, index, isTop, isPending, cardSize, style, on
</section>
)}

{(targetLabels.length > 0 || paidLabels.length > 0 || contextLabels.length > 0) && (
{(stormCopyCount !== undefined || targetLabels.length > 0 || paidLabels.length > 0 || contextLabels.length > 0) && (
<div className="absolute left-1 right-1 top-5 flex flex-wrap gap-1">
{stormCopyCount !== undefined && (
<span
className="max-w-full rounded bg-violet-950/90 px-1.5 py-0.5 text-[8px] font-semibold text-violet-100 shadow"
title={t("storm.copies", { count: stormCopyCount })}
>
{t("storm.copies", { count: stormCopyCount })}
</span>
)}
{targetLabels.slice(0, 2).map((label) => (
<span
key={`target-${label}`}
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/de/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@
"organizeLabel": "Hand ordnen",
"reorderPausedHint": "Das Umsortieren per Ziehen ist pausiert, solange die Hand sortiert oder gefiltert ist."
},
"storm": {
"copies_one": "Sturm: {{count}} Kopie",
"copies_other": "Sturm: {{count}} Kopien"
},
"opponentHud": {
"ally": "Verbündeter",
"out": "Raus",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/en/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,10 @@
"organizeLabel": "Organize hand",
"reorderPausedHint": "Drag-to-reorder is paused while the hand is sorted or filtered."
},
"storm": {
"copies_one": "Storm: {{count}} copy",
"copies_other": "Storm: {{count}} copies"
},
"opponentHud": {
"ally": "Ally",
"out": "Out",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/es/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@
"organizeLabel": "Organizar la mano",
"reorderPausedHint": "El reordenamiento por arrastre está pausado mientras la mano está ordenada o filtrada."
},
"storm": {
"copies_one": "Tormenta: {{count}} copia",
"copies_other": "Tormenta: {{count}} copias"
},
"opponentHud": {
"ally": "Aliado",
"out": "Fuera",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/fr/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@
"organizeLabel": "Organiser la main",
"reorderPausedHint": "La réorganisation par glisser-déposer est en pause tant que la main est triée ou filtrée."
},
"storm": {
"copies_one": "Déluge : {{count}} copie",
"copies_other": "Déluge : {{count}} copies"
},
"opponentHud": {
"ally": "Allié",
"out": "Élim",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/it/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@
"organizeLabel": "Organizza la mano",
"reorderPausedHint": "Il trascinamento per riordinare è in pausa mentre la mano è ordinata o filtrata."
},
"storm": {
"copies_one": "Tempesta: {{count}} copia",
"copies_other": "Tempesta: {{count}} copie"
},
"opponentHud": {
"ally": "Alleato",
"out": "Fuori",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/pl/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@
"organizeLabel": "Uporządkuj rękę",
"reorderPausedHint": "Zmiana kolejności przeciąganiem jest wstrzymana, gdy ręka jest posortowana lub przefiltrowana."
},
"storm": {
"copies_one": "Sztorm: {{count}} kopia",
"copies_other": "Sztorm: {{count}} kopie"
},
"opponentHud": {
"ally": "Sojusznik",
"out": "Poza grą",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/pt/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@
"organizeLabel": "Organizar mão",
"reorderPausedHint": "Arrastar para reordenar está pausado enquanto a mão está ordenada ou filtrada."
},
"storm": {
"copies_one": "Tempestade: {{count}} cópia",
"copies_other": "Tempestade: {{count}} cópias"
},
"opponentHud": {
"ally": "Aliado",
"out": "Fora",
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/ai_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5980,6 +5980,7 @@ mod tests {
source_name: "Token".to_string(),
subject_match_count: None,
die_result: None,
provenance: None,
},
});
// A meaningful action keeps auto-pass OFF absent a yield (reach-guard:
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/analysis/loop_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1477,6 +1477,7 @@ mod tests {
source_name: String::new(),
subject_match_count: None,
die_result: None,
provenance: None,
},
}
}
Expand Down
8 changes: 8 additions & 0 deletions crates/engine/src/analysis/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6428,6 +6428,7 @@ mod tests {
source_name: String::new(),
subject_match_count: None,
die_result: None,
provenance: None,
},
}
}
Expand Down Expand Up @@ -6539,6 +6540,7 @@ mod tests {
// these rows test.
subject_match_count: Some(1),
die_result: None,
provenance: None,
},
}
}
Expand Down Expand Up @@ -10622,6 +10624,7 @@ mod tests {
may_trigger_origin: None,
subject_match_count: None,
die_result: None,
provenance: None,
},
)
};
Expand Down Expand Up @@ -13238,6 +13241,7 @@ mod tests {
source_name: String::new(),
subject_match_count: match_count,
die_result: None,
provenance: None,
},
}
}
Expand Down Expand Up @@ -13607,6 +13611,7 @@ mod tests {
source_name: String::new(),
subject_match_count: None,
die_result: None,
provenance: None,
},
}
}
Expand Down Expand Up @@ -14713,6 +14718,7 @@ mod tests {
source_name: String::new(),
subject_match_count: None,
die_result: None,
provenance: None,
},
}
}
Expand Down Expand Up @@ -15324,6 +15330,7 @@ mod tests {
source_name: String::new(),
subject_match_count: None,
die_result: None,
provenance: None,
},
}
};
Expand Down Expand Up @@ -15995,6 +16002,7 @@ mod tests {
source_name: String::new(),
subject_match_count: None,
die_result: None,
provenance: None,
},
}
}
Expand Down
Loading
Loading