Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
02a8183
fix: manual sync factions
Gbnssucharitha Mar 15, 2026
646f039
feat: update giant ants core and POD
Gbnssucharitha Mar 16, 2026
547e40b
fix: apply collector errata for aliens
Gbnssucharitha Mar 16, 2026
ff27e77
fix: allow Spirit to target itself
Gbnssucharitha Mar 16, 2026
a37bb9e
fix: reshuffle discard for deck peek
Gbnssucharitha Mar 16, 2026
b3555fc
fix: discard attachments when placing minion to deck bottom
Gbnssucharitha Mar 16, 2026
7327ba7
fix: reshuffle base discard when base deck empty
Gbnssucharitha Mar 16, 2026
b4d69d3
fix: split deck mill from hand discard
Gbnssucharitha Mar 16, 2026
ebe626a
feat: add reaction queue for simultaneous triggers
Gbnssucharitha Mar 16, 2026
2abf8ee
refactor: enqueue onMinionAffected triggers
Gbnssucharitha Mar 16, 2026
8072828
feat: queue onMinionDestroyed reactions with replacement split
Gbnssucharitha Mar 16, 2026
5e17fff
fix: mark destroy replacements as immediate
Gbnssucharitha Mar 16, 2026
d14b626
fix: treat deck redirects as destroy replacements
Gbnssucharitha Mar 16, 2026
f2d6545
docs: add Smash Up engine guide
Gbnssucharitha Mar 16, 2026
b8b8495
fix: make starting-hand mulligan optional
Gbnssucharitha Mar 16, 2026
bf365f4
refactor: queue onMinionPlayed triggers
Gbnssucharitha Mar 16, 2026
fe0169a
refactor: queue before/afterScoring triggers
Gbnssucharitha Mar 16, 2026
4a302f2
refactor: queue onTurnStart/onTurnEnd triggers
Gbnssucharitha Mar 16, 2026
d6e4adc
refactor: queue onMinionDiscardedFromBase triggers
Gbnssucharitha Mar 16, 2026
5c41241
Align base abilities with reaction queue and FAQ
Gbnssucharitha Mar 16, 2026
b277898
Refine Field of Honor and Tsar's Palace to match FAQ
Gbnssucharitha Mar 16, 2026
ff782e3
Align Cthulhu bases ownership triggers
Gbnssucharitha Mar 16, 2026
dcdfb13
Align monster bases with FAQ (counters, batching, ignore)
Gbnssucharitha Mar 16, 2026
c82875e
Smash Up: implement fusion playAs and Tricksters POD updates
Gbnssucharitha Mar 17, 2026
0e28dd8
Smash Up: Giant Ants POD alignment
Gbnssucharitha Mar 17, 2026
09d7b36
Smash Up: Miskatonic POD alignment
Gbnssucharitha Mar 17, 2026
cd9f99e
Smash Up: Elder Things base errata + removed-from-game
Gbnssucharitha Mar 17, 2026
9cc30a8
Smash Up: fix Power of Madness POD order
Gbnssucharitha Mar 17, 2026
003758b
Smash Up: scope Power of Madness POD naming list
Gbnssucharitha Mar 17, 2026
dd40f08
Smash Up: Vampires POD implementation
Gbnssucharitha Mar 17, 2026
393a98d
Smash Up: add bury/uncover engine
Gbnssucharitha Mar 17, 2026
a6cf335
Smash Up: align Frankenstein POD + FAQ
Gbnssucharitha Mar 17, 2026
0320c45
Smash Up: fix buried play + POD madness
Gbnssucharitha Mar 17, 2026
b15fdbc
Smash Up: fix Shoggoth POD + base auto-clear
Gbnssucharitha Mar 17, 2026
33b08e8
Smash Up: fix card image atlas mappings
Gbnssucharitha Mar 17, 2026
7f1a49a
Merge branch 'main' into smashup-faction-fixes-v2
deathcats4 Mar 18, 2026
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
329 changes: 175 additions & 154 deletions public/locales/en/game-smashup.json

Large diffs are not rendered by default.

361 changes: 193 additions & 168 deletions public/locales/zh-CN/game-smashup.json

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions src/core/AssetLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,8 +958,14 @@ export function buildLocalizedImageSet(src: string, locale?: string): string {
console.warn(`[AssetLoader] invalid_src type=${typeof src} value=${String(src)}`);
return '';
}
const { primary } = getLocalizedImageUrls(src, locale);
return `url("${primary.webp}")`;
const { primary, fallback } = getLocalizedImageUrls(src, locale);
const primaryUrl = primary.webp;
const fallbackUrl = fallback.webp;
if (primaryUrl && fallbackUrl && fallbackUrl !== primaryUrl) {
// CSS 会依次尝试加载多个背景图;primary 失败(404)时会自动回退到 fallback
return `url("${primaryUrl}"), url("${fallbackUrl}")`;
}
return primaryUrl ? `url("${primaryUrl}")` : '';
}

/**
Expand Down
131 changes: 121 additions & 10 deletions src/games/smashup/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import type { GameBoardProps } from '../../engine/transport/protocol';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import type { MatchState } from '../../engine/types';
import type { SmashUpCore, CardInstance, ActionCardDef, MinionCardDef } from './domain/types';
import type { SmashUpCore, CardInstance, ActionCardDef, FusionCardDef, MinionCardDef } from './domain/types';
import { SU_COMMANDS, HAND_LIMIT, getCurrentPlayerId } from './domain/types';
import { FLOW_COMMANDS } from '../../engine/systems/FlowSystem';
import { asSimpleChoice, INTERACTION_COMMANDS } from '../../engine/systems/InteractionSystem';
import { getCardDef, getBaseDef, getMinionDef, resolveCardName, resolveCardText } from './data/cards';
import { getCardDef, getBaseDef, getFusionDef, getMinionDef, resolveCardName, resolveCardText } from './data/cards';
import { getPlayerEffectivePowerOnBase, getScoringEligibleBaseIndices } from './domain/ongoingModifiers';
import { isOperationRestricted } from './domain/ongoingEffects';
import { isSpecialLimitBlocked } from './domain/abilityHelpers';
Expand Down Expand Up @@ -50,6 +50,8 @@ import {
getMaxRemainingGlobalPowerLimitedQuota,
mustUseBaseLimitedMinionQuota,
mustUseGlobalPowerLimitedMinionQuota,
isCardActionLike,
isCardMinionLike,
} from './domain/utils';
import { SMASHUP_AUDIO_CONFIG } from './audio.config';
import { useTutorialBridge, useTutorial } from '../../contexts/TutorialContext';
Expand Down Expand Up @@ -159,6 +161,7 @@ const SmashUpBoardInner: React.FC<Props> = ({ G, dispatch, playerID: rawPlayerID

const [selectedCardUid, setSelectedCardUid] = useState<string | null>(null);
const [selectedCardMode, setSelectedCardMode] = useState<'minion' | 'ongoing' | 'ongoing-minion' | null>(null);
const [pendingFusionChoiceUid, setPendingFusionChoiceUid] = useState<string | null>(null);
const [discardSelection, setDiscardSelection] = useState<Set<string>>(new Set());
const [meFirstPendingCard, setMeFirstPendingCard] = useState<MeFirstPendingCard | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
Expand Down Expand Up @@ -431,6 +434,7 @@ const SmashUpBoardInner: React.FC<Props> = ({ G, dispatch, playerID: rawPlayerID
if (currentPrompt) {
setSelectedCardUid(null);
setSelectedCardMode(null);
setPendingFusionChoiceUid(null);
}
setDiscardStripSelectedUid(null);
setMultiSelectedOptionIds(new Set());
Expand Down Expand Up @@ -998,36 +1002,46 @@ const SmashUpBoardInner: React.FC<Props> = ({ G, dispatch, playerID: rawPlayerID
const windowType = responseWindow?.windowType;

// beforeScoringPlayable 随从(影舞者等):只在 meFirst 窗口可用
if (card.type === 'minion') {
if (isCardMinionLike(card)) {
if (windowType !== 'meFirst') {
playDeniedSound();
return;
}
const mDef = getMinionDef(card.defId);
if (!mDef?.beforeScoringPlayable) {
const fDef = getFusionDef(card.defId);
const canBeforeScoring = mDef?.beforeScoringPlayable || fDef?.minionBeforeScoringPlayable;
if (!canBeforeScoring) {
playDeniedSound();
return;
}
// toggle 选中状态,进入基地选择模式
if (selectedCardUid === card.uid) {
setSelectedCardUid(null);
setSelectedCardMode(null);
setPendingFusionChoiceUid(null);
} else {
setSelectedCardUid(card.uid);
setSelectedCardMode('minion');
setPendingFusionChoiceUid(null);
}
return;
}
if (card.type !== 'action') {
if (!isCardActionLike(card)) {
playDeniedSound();
return;
}
const cardDef = getCardDef(card.defId) as ActionCardDef | undefined;
if (cardDef?.subtype !== 'special') {
const cardDef = getCardDef(card.defId) as ActionCardDef | FusionCardDef | undefined;
const subtype = (cardDef as any)?.type === 'fusion'
? (cardDef as FusionCardDef).actionSubtype
: (cardDef as ActionCardDef | undefined)?.subtype;
if (subtype !== 'special') {
playDeniedSound();
return;
}
if (cardDef.specialNeedsBase) {
const needsBase = (cardDef as any)?.type === 'fusion'
? (cardDef as FusionCardDef).actionSpecialNeedsBase
: (cardDef as ActionCardDef | undefined)?.specialNeedsBase;
if (needsBase) {
// 需要选基地:进入基地选择模式
setMeFirstPendingCard({ cardUid: card.uid, defId: card.defId });
} else {
Expand All @@ -1044,7 +1058,7 @@ const SmashUpBoardInner: React.FC<Props> = ({ G, dispatch, playerID: rawPlayerID
}

// 教学模式下检查命令权限
const commandType = card.type === 'action' ? SU_COMMANDS.PLAY_ACTION : SU_COMMANDS.PLAY_MINION;
const commandType = isCardActionLike(card) ? SU_COMMANDS.PLAY_ACTION : SU_COMMANDS.PLAY_MINION;
if (!isTutorialCommandAllowed(commandType)) {
playDeniedSound();
return;
Expand All @@ -1057,6 +1071,20 @@ const SmashUpBoardInner: React.FC<Props> = ({ G, dispatch, playerID: rawPlayerID
}

// Normal play logic
// Fusion card: ask playAs first (minion vs action)
if (card.type === 'fusion') {
// toggle: if already selected and no pending choice, clear
if (selectedCardUid === card.uid && !pendingFusionChoiceUid) {
setSelectedCardUid(null);
setSelectedCardMode(null);
return;
}
setPendingFusionChoiceUid(card.uid);
setSelectedCardUid(card.uid);
setSelectedCardMode(null);
return;
}

if (card.type === 'action') {
// 行动额度检查(special 卡不消耗额度,在 Me First! 窗口打出)
const cardDef = getCardDef(card.defId) as ActionCardDef | undefined;
Expand Down Expand Up @@ -1090,6 +1118,41 @@ const SmashUpBoardInner: React.FC<Props> = ({ G, dispatch, playerID: rawPlayerID
}
}, [isMyTurn, phase, dispatch, isTutorialCommandAllowed, isTutorialTargetAllowed, selectedCardUid, isHandDiscardPrompt, currentPrompt, myPlayer, t, needDiscard, discardCount, isMeFirstResponse, isAfterScoringResponse, responseWindow]);

const confirmFusionPlayAs = useCallback((playAs: 'minion' | 'action') => {
if (!pendingFusionChoiceUid || !myPlayer) return;
const card = myPlayer.hand.find(c => c.uid === pendingFusionChoiceUid);
if (!card || card.type !== 'fusion') return;
const def = getCardDef(card.defId) as FusionCardDef | undefined;
if (!def) return;

setPendingFusionChoiceUid(null);

if (playAs === 'minion') {
setSelectedCardUid(card.uid);
setSelectedCardMode('minion');
return;
}

// playAs === 'action'
if (phase === 'playCards' && def.actionSubtype !== 'special' && myPlayer.actionsPlayed >= myPlayer.actionLimit) {
playDeniedSound();
toast(t('ui.action_limit_reached', { defaultValue: '本回合战术额度已用完' }));
setSelectedCardUid(null);
setSelectedCardMode(null);
return;
}

if (def.actionSubtype === 'ongoing') {
setSelectedCardUid(card.uid);
setSelectedCardMode((def.actionOngoingTarget ?? 'base') === 'minion' ? 'ongoing-minion' : 'ongoing');
return;
}

dispatch(SU_COMMANDS.PLAY_ACTION, { cardUid: card.uid });
setSelectedCardUid(null);
setSelectedCardMode(null);
}, [pendingFusionChoiceUid, myPlayer, phase, dispatch, t]);

/** 随从点击回调:ongoing-minion 模式下附着行动卡到随从,或交互驱动的随从选择 */
const handleMinionSelect = useCallback((minionUid: string, baseIndex: number) => {
// 交互驱动的随从选择
Expand Down Expand Up @@ -1764,7 +1827,55 @@ const SmashUpBoardInner: React.FC<Props> = ({ G, dispatch, playerID: rawPlayerID
isOpponentView={viewMode === 'opponent'}
/>


{/* Fusion card playAs selector */}
<AnimatePresence>
{pendingFusionChoiceUid && (
<motion.div
className="fixed inset-0 flex items-center justify-center"
style={{ zIndex: UI_Z_INDEX.overlayRaised }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => {
setPendingFusionChoiceUid(null);
setSelectedCardUid(null);
setSelectedCardMode(null);
}}
>
<div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" />
<motion.div
className="relative w-[92vw] max-w-[520px] rounded-lg border-2 border-amber-300/60 bg-[#f3f0e8] shadow-2xl p-4 pointer-events-auto"
initial={{ y: 16, scale: 0.98 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 10, scale: 0.98 }}
onClick={(e) => e.stopPropagation()}
>
<div className="font-black text-slate-900 text-lg mb-3">
{t('ui.fusion_choose_playas', { defaultValue: '选择打出方式' })}
</div>
<div className="text-slate-700 text-sm mb-4">
{t('ui.fusion_choose_playas_desc', { defaultValue: '融合卡可以作为随从或战术打出。请选择本次打出的类型。' })}
</div>
<div className="flex gap-3">
<SmashUpGameButton
variant="primary"
className="flex-1"
onClick={() => confirmFusionPlayAs('minion')}
>
{t('ui.play_as_minion', { defaultValue: '作为随从' })}
</SmashUpGameButton>
<SmashUpGameButton
variant="secondary"
className="flex-1"
onClick={() => confirmFusionPlayAs('action')}
>
{t('ui.play_as_action', { defaultValue: '作为战术' })}
</SmashUpGameButton>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>

{/* NEW: Deck & Discard Zone */}
<DeckDiscardZone
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ describe('Alien Scout - No Duplicate Scoring', () => {
commands: [
// 执行 ADVANCE_PHASE 进入记分阶段
{ type: 'ADVANCE_PHASE', playerId: '0', payload: undefined },
// Base abilities are queued; choose Scout first if an ordering prompt appears.
{ type: INTERACTION_COMMANDS.RESPOND, playerId: '0', payload: { optionId: 'afterScoring:alien_scout:0:0' } },
// 用户选择"返回手牌"
{ type: INTERACTION_COMMANDS.RESPOND, playerId: '0', payload: { optionId: 'yes' } },
] as any[],
Expand Down Expand Up @@ -142,8 +144,11 @@ describe('Alien Scout - No Duplicate Scoring', () => {
commands: [
// 执行 ADVANCE_PHASE 进入记分阶段
{ type: 'ADVANCE_PHASE', playerId: '0', payload: undefined },
// Choose each Scout trigger first (reaction ordering prompt).
{ type: INTERACTION_COMMANDS.RESPOND, playerId: '0', payload: { optionId: 'afterScoring:alien_scout:0:0' } },
// 用户选择"返回手牌"(第一个侦察兵)
{ type: INTERACTION_COMMANDS.RESPOND, playerId: '0', payload: { optionId: 'yes' } },
{ type: INTERACTION_COMMANDS.RESPOND, playerId: '0', payload: { optionId: 'afterScoring:alien_scout:0:1' } },
// 用户选择"返回手牌"(第二个侦察兵)
{ type: INTERACTION_COMMANDS.RESPOND, playerId: '0', payload: { optionId: 'yes' } },
] as any[],
Expand Down
89 changes: 89 additions & 0 deletions src/games/smashup/__tests__/baseAbilityNeutralProtection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { initAllAbilities } from '../abilities';
import { makeMatchState } from './helpers';
import { getInteractionHandler } from '../domain/abilityInteractionHandlers';
import { filterProtectedMoveEvents } from '../domain/reducer';
import type { SmashUpCore, MinionOnBase } from '../domain/types';
import { SU_EVENTS } from '../domain/types';

beforeAll(() => {
initAllAbilities();
});

function makeMinion(
uid: string,
controller: string,
defId = 'test_minion',
attachedActions: MinionOnBase['attachedActions'] = []
): MinionOnBase {
return {
uid,
defId,
controller,
owner: controller,
basePower: 3,
powerCounters: 0,
powerModifier: 0,
tempPowerModifier: 0,
talentUsed: false,
attachedActions,
};
}

function makeCore(overrides: Partial<SmashUpCore> = {}): SmashUpCore {
return {
players: {
'0': { id: '0', vp: 0, hand: [], discard: [], deck: [], minionsPlayed: 0, minionLimit: 1, actionsPlayed: 0, actionLimit: 1, factions: [] } as any,
'1': { id: '1', vp: 0, hand: [], discard: [], deck: [], minionsPlayed: 0, minionLimit: 1, actionsPlayed: 0, actionLimit: 1, factions: [] } as any,
},
turnOrder: ['0', '1'],
currentPlayerIndex: 0,
bases: [],
baseDeck: [],
turnNumber: 1,
nextUid: 100,
...overrides,
} as SmashUpCore;
}

describe('Wiki/FAQ: 基地能力归因中立(Deep Roots vs Infiltrate)', () => {
it('Deep Roots(只拦截对手)不应阻止 base_mushroom_kingdom 的移动', () => {
const target = makeMinion('t1', '1');
const core = makeCore({
bases: [
{ defId: 'base_mushroom_kingdom', minions: [], ongoingActions: [] } as any,
{
defId: 'base_central_brain',
minions: [target],
ongoingActions: [{ uid: 'dr-1', defId: 'killer_plant_deep_roots', ownerId: '1' }],
} as any,
],
});
const ms = makeMatchState(core);
const handler = getInteractionHandler('base_mushroom_kingdom');
const iData = { continuationContext: { mushroomBaseIndex: 0 } } as any;
const res = handler(ms, '0', { minionUid: 't1', minionDefId: 'test_minion', fromBaseIndex: 1 }, iData, () => 0.5, 123);
const moveEvents = res.events.filter(e => e.type === SU_EVENTS.MINION_MOVED);
expect(moveEvents.length).toBe(1);
// 保护过滤:如果仍把基地能力当成“玩家0造成”,deep_roots 会错误拦截
const filtered = filterProtectedMoveEvents(res.events, core, '0');
expect(filtered.some(e => e.type === SU_EVENTS.MINION_MOVED)).toBe(true);
});

it('Infiltrate(让随从不受基地能力影响)应阻止 base_mushroom_kingdom 的移动', () => {
const target = makeMinion('t1', '1', 'test_minion', [{ uid: 'inf-1', defId: 'ninja_infiltrate', ownerId: '0' }]);
const core = makeCore({
bases: [
{ defId: 'base_mushroom_kingdom', minions: [], ongoingActions: [] } as any,
{ defId: 'base_central_brain', minions: [target], ongoingActions: [] } as any,
],
});
const ms = makeMatchState(core);
const handler = getInteractionHandler('base_mushroom_kingdom');
const iData = { continuationContext: { mushroomBaseIndex: 0 } } as any;
const res = handler(ms, '0', { minionUid: 't1', minionDefId: 'test_minion', fromBaseIndex: 1 }, iData, () => 0.5, 123);
const filtered = filterProtectedMoveEvents(res.events, core, '0');
expect(filtered.some(e => e.type === SU_EVENTS.MINION_MOVED)).toBe(false);
});
});

Loading
Loading