From 8e5532325da5df42d718360a5401b14f3c877391 Mon Sep 17 00:00:00 2001 From: deathcats4 Date: Fri, 10 Apr 2026 17:06:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=A4=A7=E6=9D=80=E5=9B=9B?= =?UTF-8?q?=E6=96=B9=E9=A2=9D=E5=A4=96=E5=87=BA=E7=89=8C=E6=97=B6=E6=9C=BA?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../smashup/__tests__/archmageE2E.test.ts | 151 +++--- .../__tests__/baseFactionOngoing.test.ts | 2 + .../__tests__/expansionAbilities.test.ts | 16 +- .../__tests__/factionAbilities.test.ts | 34 ++ src/games/smashup/abilities/bear_cavalry.ts | 6 +- src/games/smashup/abilities/frankenstein.ts | 6 +- src/games/smashup/abilities/index.ts | 2 + src/games/smashup/abilities/robots.ts | 7 +- src/games/smashup/abilities/tricksters.ts | 28 +- src/games/smashup/abilities/wizards.ts | 17 +- src/games/smashup/domain/abilityHelpers.ts | 45 +- .../smashup/domain/baseAbilities_expansion.ts | 8 +- src/games/smashup/domain/extraPlay.ts | 463 ++++++++++++++++++ src/games/smashup/domain/index.ts | 9 + src/games/smashup/domain/reduce.ts | 6 +- src/games/smashup/domain/types.ts | 2 + ...33\346\226\271\350\247\204\345\210\231.md" | 4 + 17 files changed, 678 insertions(+), 128 deletions(-) create mode 100644 src/games/smashup/domain/extraPlay.ts diff --git a/src/games/smashup/__tests__/archmageE2E.test.ts b/src/games/smashup/__tests__/archmageE2E.test.ts index 554e3fe5a..335b98d0f 100644 --- a/src/games/smashup/__tests__/archmageE2E.test.ts +++ b/src/games/smashup/__tests__/archmageE2E.test.ts @@ -1,9 +1,9 @@ /** - * 大法师 (Archmage) 完整 E2E 测试 + * 大法师 (Archmage) E2E 测试 * - * 验证大法师的持续能力:"你可以在你的每个回合打出一个额外战术" - * 根据官方 FAQ:打出当回合也能获得额外行动 - * "You get the extra action on each of your turns, including the one when Archmage is played." + * 验证两类规则: + * 1. 自己的出牌阶段获得的 extra action 可以 bank 在本阶段内使用。 + * 2. 回合开始阶段获得的 extra action 必须立刻打出或放弃,不能带到出牌阶段。 */ import { describe, expect, it, beforeAll } from 'vitest'; @@ -13,12 +13,13 @@ import { smashUpFlowHooks } from '../domain/index'; import { createFlowSystem, createBaseSystems } from '../../../engine'; import { createSmashUpEventSystem } from '../domain/systems'; import type { SmashUpCore, SmashUpCommand, SmashUpEvent } from '../domain/types'; -import { SU_EVENTS, SU_COMMANDS } from '../domain/types'; +import { SU_COMMANDS, SU_EVENTS } from '../domain/types'; import { FLOW_COMMANDS } from '../../../engine/systems/FlowSystem'; import { initAllAbilities } from '../abilities'; import type { MatchState } from '../../../engine/types'; import { createInitialSystemState } from '../../../engine/pipeline'; import { makeMinion, makePlayer, makeState, makeBase, makeCard } from './helpers'; +import { runCommand } from './testRunner'; const PLAYER_IDS = ['0', '1']; @@ -51,14 +52,26 @@ function makeFullMatchState(core: SmashUpCore): MatchState { return { core, sys: { ...sys, phase: 'playCards' } } as MatchState; } +function skipAllCurrentInteractions(state: MatchState, playerId: string): MatchState { + let nextState = state; + for (let i = 0; i < 5 && nextState.sys.interaction.current; i += 1) { + const result = runCommand(nextState, { + type: 'SYS_INTERACTION_RESPOND', + playerId, + payload: { optionId: 'skip' }, + } as any); + expect(result.success).toBe(true); + nextState = result.finalState; + } + return nextState; +} + describe('大法师 E2E: 回合开始额外行动', () => { - it('P0 控制大法师,P0 回合开始时 actionLimit 应为 2', () => { - // 构造:P0 控制大法师在基地上,当前是 P1 的回合 - // 当 P1 结束回合后,P0 的回合开始,大法师应触发给 P0 额外行动 + it('P0 控制大法师时,P0 回合开始获得必须立即处理的额外战术', () => { const archmage = makeMinion('am-1', 'wizard_archmage', '0', 4, { powerModifier: 0 }); const core = makeState({ - currentPlayerIndex: 1, // P1 的回合 + currentPlayerIndex: 1, turnNumber: 1, players: { '0': makePlayer('0'), @@ -69,16 +82,7 @@ describe('大法师 E2E: 回合开始额外行动', () => { ], }); - const fullState = makeFullMatchState(core); - // 设置为 P1 的 playCards 阶段 - const stateWithP1Turn: MatchState = { - ...fullState, - sys: { ...fullState.sys, phase: 'playCards' }, - }; - - const runner = createCustomRunner(stateWithP1Turn); - - // P1 推进阶段 → 自动链条:playCards → scoreBases → draw → endTurn → startTurn(P0) → playCards(P0) + const runner = createCustomRunner(makeFullMatchState(core)); const result = runner.run({ name: '大法师 E2E - P1 结束回合后 P0 开始', commands: [ @@ -86,23 +90,53 @@ describe('大法师 E2E: 回合开始额外行动', () => { ] as any[], }); - // 验证:现在是 P0 的 playCards 阶段 - expect(result.finalState.sys.phase).toBe('playCards'); expect(result.finalState.core.currentPlayerIndex).toBe(0); + expect(result.finalState.sys.phase).toBe('startTurn'); + expect(result.finalState.core.players['0'].actionLimit).toBe(1); + + const currentInteraction = result.finalState.sys.interaction.current as any; + expect(currentInteraction).toBeDefined(); + expect(currentInteraction?.data?.sourceId).toBe('smashup_immediate_extra_action'); + + const extraContext = currentInteraction?.data?.continuationContext?.extra; + expect(extraContext?.playerId).toBe('0'); + expect(extraContext?.limitType).toBe('action'); + expect(extraContext?.delta).toBe(1); + expect(extraContext?.playTiming).toBe('immediate'); + }); + + it('放弃 start turn 的额外战术后,不会把额度继承到 playCards', () => { + const archmage = makeMinion('am-1', 'wizard_archmage', '0', 4, { powerModifier: 0 }); + + const core = makeState({ + currentPlayerIndex: 1, + turnNumber: 1, + players: { + '0': makePlayer('0'), + '1': makePlayer('1'), + }, + bases: [ + makeBase('base_tar_pits', [archmage]), + ], + }); - // 关键验证:P0 的 actionLimit 应该是 2(基础 1 + 大法师 1) - const p0 = result.finalState.core.players['0']; - expect(p0.actionLimit).toBe(2); + const runner = createCustomRunner(makeFullMatchState(core)); + const startTurnResult = runner.run({ + name: '大法师 E2E - startTurn immediate extra', + commands: [ + { type: FLOW_COMMANDS.ADVANCE_PHASE, playerId: '1', payload: undefined }, + ] as any[], + }); - // 验证事件链中包含 LIMIT_MODIFIED - const allEvents = result.steps.flatMap(s => s.events ?? []); - const limitModifiedEvents = allEvents.filter(e => e === SU_EVENTS.LIMIT_MODIFIED); - expect(limitModifiedEvents.length).toBeGreaterThanOrEqual(1); + const finalState = skipAllCurrentInteractions(startTurnResult.finalState, '0'); + + expect(finalState.core.currentPlayerIndex).toBe(0); + expect(finalState.sys.phase).toBe('playCards'); + expect(finalState.core.players['0'].actionLimit).toBe(1); + expect(finalState.sys.interaction.current).toBeUndefined(); }); - it('P1 控制大法师,P0 回合开始时 actionLimit 应为 1(不触发)', () => { - // 构造:P1 控制大法师,当前是 P1 的回合 - // 当 P1 结束回合后,P0 的回合开始,大法师不应触发(因为控制者是 P1) + it('P1 控制大法师,P0 回合开始时不触发', () => { const archmage = makeMinion('am-1', 'wizard_archmage', '1', 4, { powerModifier: 0 }); const core = makeState({ @@ -117,14 +151,7 @@ describe('大法师 E2E: 回合开始额外行动', () => { ], }); - const fullState = makeFullMatchState(core); - const stateWithP1Turn: MatchState = { - ...fullState, - sys: { ...fullState.sys, phase: 'playCards' }, - }; - - const runner = createCustomRunner(stateWithP1Turn); - + const runner = createCustomRunner(makeFullMatchState(core)); const result = runner.run({ name: '大法师 E2E - P1 控制,P0 回合不触发', commands: [ @@ -134,15 +161,11 @@ describe('大法师 E2E: 回合开始额外行动', () => { expect(result.finalState.sys.phase).toBe('playCards'); expect(result.finalState.core.currentPlayerIndex).toBe(0); - - // P0 的 actionLimit 应该是 1(基础值,大法师不触发) - const p0 = result.finalState.core.players['0']; - expect(p0.actionLimit).toBe(1); + expect(result.finalState.core.players['0'].actionLimit).toBe(1); + expect(result.finalState.sys.interaction.current).toBeUndefined(); }); - it('P0 控制大法师,P1 回合开始时 actionLimit 应为 1(不触发)', () => { - // 构造:P0 控制大法师,当前是 P0 的回合 - // 当 P0 结束回合后,P1 的回合开始,大法师不应触发(因为不是控制者的回合) + it('P0 控制大法师,P1 回合开始时不触发', () => { const archmage = makeMinion('am-1', 'wizard_archmage', '0', 4, { powerModifier: 0 }); const core = makeState({ @@ -157,14 +180,7 @@ describe('大法师 E2E: 回合开始额外行动', () => { ], }); - const fullState = makeFullMatchState(core); - const stateWithP0Turn: MatchState = { - ...fullState, - sys: { ...fullState.sys, phase: 'playCards' }, - }; - - const runner = createCustomRunner(stateWithP0Turn); - + const runner = createCustomRunner(makeFullMatchState(core)); const result = runner.run({ name: '大法师 E2E - P0 控制,P1 回合不触发', commands: [ @@ -174,17 +190,13 @@ describe('大法师 E2E: 回合开始额外行动', () => { expect(result.finalState.sys.phase).toBe('playCards'); expect(result.finalState.core.currentPlayerIndex).toBe(1); - - // P1 的 actionLimit 应该是 1(基础值,大法师不触发) - const p1 = result.finalState.core.players['1']; - expect(p1.actionLimit).toBe(1); + expect(result.finalState.core.players['1'].actionLimit).toBe(1); + expect(result.finalState.sys.interaction.current).toBeUndefined(); }); }); describe('大法师 E2E: 打出当回合额外行动', () => { - it('打出大法师当回合立即获得额外行动(官方 FAQ)', () => { - // 构造:P0 手牌有大法师,当前是 P0 的回合 - // P0 打出大法师后,应立即获得额外行动 + it('打出大法师当回合仍可获得 banked 额外行动', () => { const archmageCard = makeCard('am-card', 'wizard_archmage', 'minion', '0'); const core = makeState({ @@ -199,11 +211,7 @@ describe('大法师 E2E: 打出当回合额外行动', () => { ], }); - const fullState = makeFullMatchState(core); - - const runner = createCustomRunner(fullState); - - // P0 打出大法师 + const runner = createCustomRunner(makeFullMatchState(core)); const result = runner.run({ name: '大法师 E2E - 打出当回合获得额外行动', commands: [ @@ -212,14 +220,9 @@ describe('大法师 E2E: 打出当回合额外行动', () => { }); expect(result.steps[0]?.success).toBe(true); + expect(result.finalState.core.players['0'].actionLimit).toBe(2); + expect(result.finalState.sys.phase).toBe('playCards'); - // 关键验证:P0 的 actionLimit 应该是 2(基础 1 + 大法师打出时 1) - const p0 = result.finalState.core.players['0']; - expect(p0.actionLimit).toBe(2); - - // 验证事件链中包含 LIMIT_MODIFIED - const allEvents = result.steps.flatMap(s => s.events ?? []); - const limitModifiedEvents = allEvents.filter(e => e === SU_EVENTS.LIMIT_MODIFIED); - expect(limitModifiedEvents.length).toBeGreaterThanOrEqual(1); + expect(result.finalState.sys.interaction.current).toBeUndefined(); }); }); diff --git a/src/games/smashup/__tests__/baseFactionOngoing.test.ts b/src/games/smashup/__tests__/baseFactionOngoing.test.ts index c70460205..97b88598b 100644 --- a/src/games/smashup/__tests__/baseFactionOngoing.test.ts +++ b/src/games/smashup/__tests__/baseFactionOngoing.test.ts @@ -1021,6 +1021,7 @@ describe('巫师 ongoing 能力', () => { expect((events[0] as any).payload.playerId).toBe('0'); expect((events[0] as any).payload.limitType).toBe('action'); expect((events[0] as any).payload.delta).toBe(1); + expect((events[0] as any).payload.playTiming).toBe('immediate'); }); test('POD 版不在 onTurnStart 触发(POD 为 talent)', () => { @@ -1242,6 +1243,7 @@ describe('诡术师 ongoing 能力', () => { expect(events).toHaveLength(1); expect(events[0].type).toBe(SU_EVENTS.LIMIT_MODIFIED); expect((events[0] as any).payload.limitType).toBe('minion'); + expect((events[0] as any).payload.playTiming).toBe('immediate'); }); test('非拥有者回合不触发', () => { diff --git a/src/games/smashup/__tests__/expansionAbilities.test.ts b/src/games/smashup/__tests__/expansionAbilities.test.ts index 8ecdae966..b0fcd7ae1 100644 --- a/src/games/smashup/__tests__/expansionAbilities.test.ts +++ b/src/games/smashup/__tests__/expansionAbilities.test.ts @@ -528,21 +528,27 @@ describe('黑熊骑兵派系能力', () => { }); describe('bear_cavalry_commission(委任:额外随从)', () => { - it('给予额外随从额度', () => { + it('立即创建额外随从选择交互,而不是留下可暂存额度', () => { const state = makeState({ players: { '0': makePlayer('0', { - hand: [makeCard('a1', 'bear_cavalry_commission', 'action', '0')], + hand: [ + makeCard('a1', 'bear_cavalry_commission', 'action', '0'), + makeCard('m1', 'robot_microbot_guard', 'minion', '0'), + ], }), '1': makePlayer('1'), }, + bases: [{ defId: 'b1', minions: [], ongoingActions: [] }], }); const events = execPlayAction(state, '0', 'a1'); const limitEvents = events.filter(e => e.type === SU_EVENTS.LIMIT_MODIFIED); - expect(limitEvents.length).toBe(1); - expect((limitEvents[0] as any).payload.limitType).toBe('minion'); - expect((limitEvents[0] as any).payload.delta).toBe(1); + expect(limitEvents.length).toBe(0); + + const interactions = getLastInteractions(); + expect(interactions.length).toBe(1); + expect(interactions[0].data.sourceId).toBe('bear_cavalry_commission_choose_minion'); }); }); }); diff --git a/src/games/smashup/__tests__/factionAbilities.test.ts b/src/games/smashup/__tests__/factionAbilities.test.ts index 3cc447430..e83ab256f 100644 --- a/src/games/smashup/__tests__/factionAbilities.test.ts +++ b/src/games/smashup/__tests__/factionAbilities.test.ts @@ -18,6 +18,7 @@ import type { import { initAllAbilities, resetAbilityInit } from '../abilities'; import { clearRegistry } from '../domain/abilityRegistry'; import { clearBaseAbilityRegistry } from '../domain/baseAbilities'; +import { resolveAbility } from '../domain/abilityRegistry'; import { runCommand } from './testRunner'; import type { MatchState, RandomFn } from '../../../engine/types'; import { makeMatchState } from './helpers'; @@ -479,6 +480,7 @@ describe('机器人派系能力', () => { const limitEvents = events.filter(e => e.type === SU_EVENTS.LIMIT_MODIFIED); expect(limitEvents.length).toBe(1); expect((limitEvents[0] as any).payload.powerMax).toBe(2); + expect((limitEvents[0] as any).payload.playTiming).toBe('banked'); }); it('robot_zapbot: 无论手牌是否有力量≤2随从都给额度', () => { @@ -498,6 +500,38 @@ describe('机器人派系能力', () => { expect(limitEvents.length).toBe(1); }); + it('robot_zapbot: 非 playCards 阶段获得的额外随从必须立即处理', () => { + const state = makeState({ + players: { + '0': makePlayer('0'), + '1': makePlayer('1'), + }, + bases: [{ defId: 'b1', minions: [], ongoingActions: [] }], + }); + + const matchState = makeMatchState(state); + matchState.sys.phase = 'startTurn'; + + const executor = resolveAbility('robot_zapbot', 'onPlay'); + expect(executor).toBeDefined(); + + const result = executor!({ + state, + matchState, + playerId: '0', + cardUid: 'm1', + defId: 'robot_zapbot', + baseIndex: 0, + random: defaultRandom, + now: 1000, + }); + + const limitEvents = result.events.filter(e => e.type === SU_EVENTS.LIMIT_MODIFIED); + expect(limitEvents.length).toBe(1); + expect((limitEvents[0] as any).payload.powerMax).toBe(2); + expect((limitEvents[0] as any).payload.playTiming).toBe('immediate'); + }); + it('robot_tech_center: 单个基地时创建 Prompt', () => { const deckCards = Array.from({ length: 5 }, (_, i) => makeCard(`d${i}`, 'test_card', 'minion', '0') diff --git a/src/games/smashup/abilities/bear_cavalry.ts b/src/games/smashup/abilities/bear_cavalry.ts index b6a631fb7..3d7924342 100644 --- a/src/games/smashup/abilities/bear_cavalry.ts +++ b/src/games/smashup/abilities/bear_cavalry.ts @@ -192,10 +192,8 @@ function bearHugProcessNext( function bearCavalryCommission(ctx: AbilityContext): AbilityResult { const player = ctx.state.players[ctx.playerId]; const handMinions = player.hand.filter(c => c.type === 'minion'); - const events: SmashUpEvent[] = [grantExtraMinion(ctx.playerId, 'bear_cavalry_commission', ctx.now)]; if (handMinions.length === 0) { - // 原版语义:即使当前没有可打出的随从,也先给予额外随从额度 - return { events }; + return { events: [] }; } // 让玩家选择要打出的手牌随从 @@ -212,7 +210,7 @@ function bearCavalryCommission(ctx: AbilityContext): AbilityResult { ); // 标记是否为 POD 版本,用于后续交互链区分“必须移动”与“可以跳过” (interaction.data as any).isPod = ctx.defId === 'bear_cavalry_commission_pod'; - return { events, matchState: queueInteraction(ctx.matchState, interaction) }; + return { events: [], matchState: queueInteraction(ctx.matchState, interaction) }; } diff --git a/src/games/smashup/abilities/frankenstein.ts b/src/games/smashup/abilities/frankenstein.ts index dc98a46c6..9a5cbd357 100644 --- a/src/games/smashup/abilities/frankenstein.ts +++ b/src/games/smashup/abilities/frankenstein.ts @@ -8,7 +8,7 @@ import { registerAbility } from '../domain/abilityRegistry'; import type { AbilityContext, AbilityResult } from '../domain/abilityRegistry'; import { addPowerCounter, removePowerCounter, destroyMinion, - getMinionPower, grantExtraMinion, queueMinionPlayEffect, buildMinionTargetOptions, + getMinionPower, grantContextualExtraMinion, grantExtraMinion, queueMinionPlayEffect, buildMinionTargetOptions, resolveOrPrompt, findMinionOnBases, buildAbilityFeedback, buildValidatedCardToDeckBottomEvents, buildValidatedDestroyEvents, } from '../domain/abilityHelpers'; import { SU_EVENTS } from '../domain/types'; @@ -150,7 +150,7 @@ function frankensteinTheMonster(ctx: AbilityContext): AbilityResult { return { events: [ removePowerCounter(found.minion.uid, found.baseIndex, 1, 'frankenstein_the_monster', ctx.now), - grantExtraMinion(ctx.playerId, 'frankenstein_the_monster', ctx.now), + grantContextualExtraMinion(ctx, 'frankenstein_the_monster'), ], }; } @@ -209,7 +209,7 @@ function frankensteinJolt(ctx: AbilityContext): AbilityResult { function frankensteinItsAlive(ctx: AbilityContext): AbilityResult { return { events: [ - grantExtraMinion(ctx.playerId, 'frankenstein_its_alive', ctx.now), + grantExtraMinion(ctx.playerId, 'frankenstein_its_alive', ctx.now, undefined, { playTiming: 'immediate' }), queueMinionPlayEffect(ctx.playerId, 'addPowerCounter', 1, ctx.now), ], }; diff --git a/src/games/smashup/abilities/index.ts b/src/games/smashup/abilities/index.ts index d8939e367..abe31a5e4 100644 --- a/src/games/smashup/abilities/index.ts +++ b/src/games/smashup/abilities/index.ts @@ -42,6 +42,7 @@ import { registerBaseAbilities, registerBaseInteractionHandlers, clearBaseAbilit import { registerMultiBaseScoringInteractionHandler } from '../domain/index'; import { registerReactionQueueInteractionHandlers } from '../domain/reactionQueueHandlers'; import { registerMulliganInteractionHandlers } from '../domain/mulliganHandlers'; +import { registerImmediateExtraPlayInteractionHandlers } from '../domain/extraPlay'; import { registerAllOngoingModifiers } from './ongoing_modifiers'; import { clearPowerModifierRegistry, registerPodPowerModifierAliases } from '../domain/ongoingModifiers'; import { clearOngoingEffectRegistry, registerPodOngoingAliases } from '../domain/ongoingEffects'; @@ -93,6 +94,7 @@ export function initAllAbilities(): void { registerReactionQueueInteractionHandlers(); registerMulliganInteractionHandlers(); registerBuryInteractionHandlers(); + registerImmediateExtraPlayInteractionHandlers(); // 扩展派系 registerGhostAbilities(); diff --git a/src/games/smashup/abilities/robots.ts b/src/games/smashup/abilities/robots.ts index 09eecb06b..1989ba690 100644 --- a/src/games/smashup/abilities/robots.ts +++ b/src/games/smashup/abilities/robots.ts @@ -7,6 +7,7 @@ import { registerAbility } from '../domain/abilityRegistry'; import type { AbilityContext, AbilityResult } from '../domain/abilityRegistry'; import { + grantContextualExtraMinion, grantExtraMinion, destroyMinion, getMinionPower, @@ -82,7 +83,7 @@ function robotMicrobotFixer(ctx: AbilityContext): AbilityResult { // onPlay 在 reduce 之后执行,第一个随从打出后 minionsPlayed 已从 0 变为 1 // 所以 minionsPlayed > 1 表示“之前已经打过随从”,此时不触发 if (player.minionsPlayed > 1) return { events: [] }; - return { events: [grantExtraMinion(ctx.playerId, 'robot_microbot_fixer', ctx.now)] }; + return { events: [grantContextualExtraMinion(ctx, 'robot_microbot_fixer')] }; } /** 微型机回收者 onPlay:如果是本回合第一个随从,额外出牌;将弃牌堆中的微型机洗回牌库 */ @@ -93,7 +94,7 @@ function robotMicrobotReclaimer(ctx: AbilityContext): AbilityResult { // onPlay 在 reduce 之后执行,第一个随从打出后 minionsPlayed 已从 0 变为 1 // 所以 minionsPlayed === 1 表示“这是本回合第一个随从” if (player.minionsPlayed === 1) { - events.push(grantExtraMinion(ctx.playerId, 'robot_microbot_reclaimer', ctx.now)); + events.push(grantContextualExtraMinion(ctx, 'robot_microbot_reclaimer')); } // 将弃牌堆中的微型机洗回牌库(“任意数量”:玩家选择) @@ -220,7 +221,7 @@ function robotHoverbot(ctx: AbilityContext): AbilityResult { /** 高速机器人 onPlay:你可以打出一张力量≤2的额外随从(+1 额度,力量限制由验证层自动检查) */ function robotZapbot(ctx: AbilityContext): AbilityResult { return { - events: [grantExtraMinion(ctx.playerId, 'robot_zapbot', ctx.now, undefined, { powerMax: 2 })], + events: [grantContextualExtraMinion(ctx, 'robot_zapbot', undefined, { powerMax: 2 })], }; } diff --git a/src/games/smashup/abilities/tricksters.ts b/src/games/smashup/abilities/tricksters.ts index 4eca903d3..f80099cf7 100644 --- a/src/games/smashup/abilities/tricksters.ts +++ b/src/games/smashup/abilities/tricksters.ts @@ -10,7 +10,9 @@ import { destroyMinion, getMinionPower, buildMinionTargetOptions, + grantContextualExtraMinion, resolveOrPrompt, + resolveExtraPlayTiming, buildAbilityFeedback, createSkipOption, } from '../domain/abilityHelpers'; @@ -132,19 +134,8 @@ function tricksterDisenchant(ctx: AbilityContext): AbilityResult { /** 隐蔽迷雾 onPlay:打出当回合给予额外随从(与大法师同理,ongoing 能力在进入场上时生效) */ function tricksterEnshroudingMistOnPlay(ctx: AbilityContext): AbilityResult { - // 打出当回合立即给予额外随从(限定到此基地) return { - events: [{ - type: SU_EVENTS.LIMIT_MODIFIED, - payload: { - playerId: ctx.playerId, - limitType: 'minion' as const, - delta: 1, - reason: 'trickster_enshrouding_mist', - restrictToBase: ctx.baseIndex, - }, - timestamp: ctx.now, - } as LimitModifiedEvent], + events: [grantContextualExtraMinion(ctx, 'trickster_enshrouding_mist', ctx.baseIndex)], }; } @@ -171,17 +162,7 @@ export function registerTricksterAbilities(): void { function tricksterEnshroudingMistPodTalent(ctx: AbilityContext): AbilityResult { return { - events: [{ - type: SU_EVENTS.LIMIT_MODIFIED, - payload: { - playerId: ctx.playerId, - limitType: 'minion' as const, - delta: 1, - reason: 'trickster_enshrouding_mist_pod', - restrictToBase: ctx.baseIndex, - }, - timestamp: ctx.now, - } as LimitModifiedEvent], + events: [grantContextualExtraMinion(ctx, 'trickster_enshrouding_mist_pod', ctx.baseIndex)], }; } @@ -876,6 +857,7 @@ function registerTricksterOngoingEffects(): void { delta: 1, reason: 'trickster_enshrouding_mist', restrictToBase: bi, + playTiming: 'immediate', }, timestamp: trigCtx.now, }]; diff --git a/src/games/smashup/abilities/wizards.ts b/src/games/smashup/abilities/wizards.ts index ac05f8a40..e24cc692a 100644 --- a/src/games/smashup/abilities/wizards.ts +++ b/src/games/smashup/abilities/wizards.ts @@ -8,6 +8,8 @@ import { registerAbility } from '../domain/abilityRegistry'; import type { AbilityContext, AbilityResult } from '../domain/abilityRegistry'; import { resolveOnPlay, resolveSpecial } from '../domain/abilityRegistry'; import { + grantContextualExtraAction, + grantContextualExtraMinion, grantExtraAction, grantExtraMinion, destroyMinion, @@ -19,6 +21,7 @@ import { buildAbilityFeedback, findCardInPlayerZone, filterCardsPresentInPlayerZone, + resolveExtraPlayTiming, } from '../domain/abilityHelpers'; import { SU_EVENTS } from '../domain/types'; import type { CardsDrawnEvent, SmashUpEvent, DeckReorderedEvent, MinionCardDef, CardToDeckTopEvent, ActionCardDef, SmashUpCore } from '../domain/types'; @@ -32,12 +35,12 @@ import { reduce } from '../domain/reduce'; /** 时间法师 onPlay:额外打出一个行动*/ function wizardChronomage(ctx: AbilityContext): AbilityResult { - return { events: [grantExtraAction(ctx.playerId, 'wizard_chronomage', ctx.now)] }; + return { events: [grantContextualExtraAction(ctx, 'wizard_chronomage')] }; } /** 大法师 POD talent:额外打出一个行动 */ function wizardArchmagePodTalent(ctx: AbilityContext): AbilityResult { - return { events: [grantExtraAction(ctx.playerId, 'wizard_archmage_pod', ctx.now)] }; + return { events: [grantContextualExtraAction(ctx, 'wizard_archmage_pod')] }; } /** 女巫 onPlay:抽一张牌 */ @@ -68,15 +71,15 @@ function wizardMysticStudies(ctx: AbilityContext): AbilityResult { /** 召唤 onPlay:额外打出一个随从*/ function wizardSummon(ctx: AbilityContext): AbilityResult { - return { events: [grantExtraMinion(ctx.playerId, 'wizard_summon', ctx.now)] }; + return { events: [grantContextualExtraMinion(ctx, 'wizard_summon')] }; } /** 时间圆环 onPlay:额外打出两个行动*/ function wizardTimeLoop(ctx: AbilityContext): AbilityResult { return { events: [ - grantExtraAction(ctx.playerId, 'wizard_time_loop', ctx.now), - grantExtraAction(ctx.playerId, 'wizard_time_loop', ctx.now), + grantContextualExtraAction(ctx, 'wizard_time_loop'), + grantContextualExtraAction(ctx, 'wizard_time_loop'), ], }; } @@ -485,7 +488,7 @@ function wizardWindsOfChange(ctx: AbilityContext): AbilityResult { } // 3. 额外打出一个行动 - events.push(grantExtraAction(ctx.playerId, 'wizard_winds_of_change', ctx.now)); + events.push(grantContextualExtraAction(ctx, 'wizard_winds_of_change')); return { events }; } @@ -548,6 +551,7 @@ function registerWizardOngoingEffects(): void { limitType: 'action' as const, delta: 1, reason: archmageDefId ?? 'wizard_archmage', + playTiming: 'immediate', }, timestamp: trigCtx.now, }]; @@ -566,6 +570,7 @@ function registerWizardOngoingEffects(): void { limitType: 'action' as const, delta: 1, reason: trigCtx.triggerMinionDefId, + playTiming: resolveExtraPlayTiming(trigCtx.matchState), }, timestamp: trigCtx.now, }]; diff --git a/src/games/smashup/domain/abilityHelpers.ts b/src/games/smashup/domain/abilityHelpers.ts index 3092d3827..5b85e34ea 100644 --- a/src/games/smashup/domain/abilityHelpers.ts +++ b/src/games/smashup/domain/abilityHelpers.ts @@ -735,12 +735,13 @@ export function grantExtraMinion( /** 限定额度只能用于指定基地(不设则为全局额度) */ restrictToBase?: number, /** 额外选项 */ - options?: { sameNameOnly?: boolean; sameNameDefId?: string; powerMax?: number }, + options?: { sameNameOnly?: boolean; sameNameDefId?: string; powerMax?: number; playTiming?: 'banked' | 'immediate' }, ): LimitModifiedEvent { return { type: SU_EVENTS.LIMIT_MODIFIED, payload: { playerId, limitType: 'minion', delta: 1, reason, + ...(options?.playTiming ? { playTiming: options.playTiming } : {}), ...(restrictToBase !== undefined ? { restrictToBase } : {}), ...(options?.powerMax !== undefined ? { powerMax: options.powerMax } : {}), ...(options?.sameNameOnly ? { sameNameOnly: true } : {}), @@ -755,15 +756,53 @@ export function grantExtraMinion( export function grantExtraAction( playerId: PlayerId, reason: string, - now: number + now: number, + options?: { playTiming?: 'banked' | 'immediate' }, ): LimitModifiedEvent { return { type: SU_EVENTS.LIMIT_MODIFIED, - payload: { playerId, limitType: 'action', delta: 1, reason }, + payload: { + playerId, + limitType: 'action', + delta: 1, + reason, + ...(options?.playTiming ? { playTiming: options.playTiming } : {}), + }, timestamp: now, }; } +export function resolveExtraPlayTiming(matchState?: Pick, 'sys'>): 'banked' | 'immediate' { + return matchState?.sys?.phase === 'playCards' ? 'banked' : 'immediate'; +} + +export function grantContextualExtraMinion( + ctx: { playerId: PlayerId; now: number; matchState?: Pick, 'sys'> }, + reason: string, + restrictToBase?: number, + options?: { sameNameOnly?: boolean; sameNameDefId?: string; powerMax?: number }, +): LimitModifiedEvent { + return grantExtraMinion( + ctx.playerId, + reason, + ctx.now, + restrictToBase, + { + ...options, + playTiming: resolveExtraPlayTiming(ctx.matchState), + }, + ); +} + +export function grantContextualExtraAction( + ctx: { playerId: PlayerId; now: number; matchState?: Pick, 'sys'> }, + reason: string, +): LimitModifiedEvent { + return grantExtraAction(ctx.playerId, reason, ctx.now, { + playTiming: resolveExtraPlayTiming(ctx.matchState), + }); +} + // ============================================================================ // 查找辅助 // ============================================================================ diff --git a/src/games/smashup/domain/baseAbilities_expansion.ts b/src/games/smashup/domain/baseAbilities_expansion.ts index 277f986e0..e5d2435ec 100644 --- a/src/games/smashup/domain/baseAbilities_expansion.ts +++ b/src/games/smashup/domain/baseAbilities_expansion.ts @@ -16,6 +16,7 @@ import type { import { SU_EVENTS, MADNESS_CARD_DEF_ID } from './types'; import { getEffectivePower } from './ongoingModifiers'; import { + grantContextualExtraMinion, returnMadnessCard, grantExtraMinion, grantExtraAction, @@ -282,12 +283,7 @@ export function registerExpansionBaseAbilities(): void { // 力量的 限制通过 BaseCardDef.restrictions ?extraPlayMinionPowerMax 数据驱动实现(同母星模式) registerBaseAbility('base_secret_garden', 'onTurnStart', (ctx) => { return { - events: [grantExtraMinion( - ctx.playerId, - '神秘花园:额外打出力量≤2的随从', - ctx.now, - ctx.baseIndex, - )], + events: [grantContextualExtraMinion(ctx, '神秘花园:额外打出力量≤2的随从', ctx.baseIndex)], }; }); diff --git a/src/games/smashup/domain/extraPlay.ts b/src/games/smashup/domain/extraPlay.ts new file mode 100644 index 000000000..a34bf8006 --- /dev/null +++ b/src/games/smashup/domain/extraPlay.ts @@ -0,0 +1,463 @@ +import type { MatchState } from '../../../engine/types'; +import { createSimpleChoice, queueInteraction } from '../../../engine/systems/InteractionSystem'; +import { getBaseDef, getCardDef, getMinionLikePower } from '../data/cards'; +import { validate } from './commands'; +import { execute } from './reducer'; +import { reduce } from './reduce'; +import { buildBaseTargetOptions, buildMinionTargetOptions, createSkipOption, grantExtraAction, grantExtraMinion } from './abilityHelpers'; +import { registerInteractionHandler } from './abilityInteractionHandlers'; +import { SU_COMMANDS, SU_EVENTS, type ActionCardDef, type FusionCardDef, type LimitModifiedEvent, type MinionOnBase, type SmashUpCore } from './types'; +import { isCardActionLike, isCardMinionLike } from './utils'; + +type ImmediateExtraLimitPayload = LimitModifiedEvent['payload'] & { playTiming: 'immediate' }; +type ImmediateExtraMinionPayload = ImmediateExtraLimitPayload & { limitType: 'minion' }; +type ImmediateExtraActionPayload = ImmediateExtraLimitPayload & { limitType: 'action' }; + +type ImmediateMinionCardChoice = { cardUid: string; defId: string }; +type ImmediateActionCardChoice = { cardUid: string; defId: string }; +type ImmediateBaseChoice = { baseIndex: number }; +type ImmediateMinionTargetChoice = { baseIndex: number; minionUid: string }; + +let immediateExtraPromptCounter = 0; + +function buildValidationState( + state: MatchState, + extra: ImmediateExtraLimitPayload, +): MatchState { + const bankedExtra = extra.limitType === 'minion' + ? grantExtraMinion( + extra.playerId, + extra.reason, + 0, + extra.restrictToBase, + { + powerMax: extra.powerMax, + sameNameOnly: extra.sameNameOnly, + sameNameDefId: extra.sameNameDefId, + }, + ) + : grantExtraAction(extra.playerId, extra.reason, 0); + + return { + ...state, + core: reduce(state.core, bankedExtra), + sys: { + ...state.sys, + phase: 'playCards', + responseWindow: undefined, + }, + }; +} + +function buildImmediateExtraMinionCardOptions( + state: MatchState, + extra: ImmediateExtraMinionPayload, +) { + const validationState = buildValidationState(state, extra); + const player = state.core.players[extra.playerId]; + if (!player) return [createSkipOption('放弃这次额外随从') as any]; + + const options = player.hand + .filter(card => isCardMinionLike(card)) + .flatMap((card, index) => { + const validBaseIndices = state.core.bases + .map((_, baseIndex) => baseIndex) + .filter(baseIndex => validate(validationState, { + type: SU_COMMANDS.PLAY_MINION, + playerId: extra.playerId, + payload: { cardUid: card.uid, baseIndex }, + }).valid); + + if (validBaseIndices.length === 0) return []; + + const def = getCardDef(card.defId); + const power = getMinionLikePower(card.defId) ?? 0; + return [{ + id: `card-${index}`, + label: `${def?.name ?? card.defId} (力量 ${power})`, + value: { cardUid: card.uid, defId: card.defId } satisfies ImmediateMinionCardChoice, + displayMode: 'card' as const, + _source: 'hand' as const, + }]; + }); + + return [...options, createSkipOption('放弃这次额外随从') as any]; +} + +function buildImmediateExtraMinionBaseOptions( + state: MatchState, + extra: ImmediateExtraMinionPayload, + choice: ImmediateMinionCardChoice, +) { + const validationState = buildValidationState(state, extra); + const candidates = state.core.bases + .map((base, baseIndex) => ({ baseIndex, label: getBaseDef(base.defId)?.name ?? `基地 ${baseIndex + 1}` })) + .filter(candidate => validate(validationState, { + type: SU_COMMANDS.PLAY_MINION, + playerId: extra.playerId, + payload: { cardUid: choice.cardUid, baseIndex: candidate.baseIndex }, + }).valid); + + return buildBaseTargetOptions(candidates, state.core); +} + +function buildImmediateExtraActionCardOptions( + state: MatchState, + extra: ImmediateExtraActionPayload, +) { + const validationState = buildValidationState(state, extra); + const player = state.core.players[extra.playerId]; + if (!player) return [createSkipOption('放弃这次额外战术') as any]; + + const options = player.hand + .filter(card => isCardActionLike(card)) + .flatMap((card, index) => { + const def = getCardDef(card.defId) as ActionCardDef | FusionCardDef | undefined; + if (!def) return []; + + const targetMode = getImmediateActionTargetMode(def); + const playable = targetMode === 'none' + ? validate(validationState, { + type: SU_COMMANDS.PLAY_ACTION, + playerId: extra.playerId, + payload: { cardUid: card.uid }, + }).valid + : targetMode === 'base' + ? state.core.bases.some((_, baseIndex) => validate(validationState, { + type: SU_COMMANDS.PLAY_ACTION, + playerId: extra.playerId, + payload: { cardUid: card.uid, targetBaseIndex: baseIndex }, + }).valid) + : state.core.bases.some((base, baseIndex) => base.minions.some(minion => validate(validationState, { + type: SU_COMMANDS.PLAY_ACTION, + playerId: extra.playerId, + payload: { cardUid: card.uid, targetBaseIndex: baseIndex, targetMinionUid: minion.uid }, + }).valid)); + + if (!playable) return []; + + return [{ + id: `card-${index}`, + label: def.name ?? card.defId, + value: { cardUid: card.uid, defId: card.defId } satisfies ImmediateActionCardChoice, + displayMode: 'card' as const, + _source: 'hand' as const, + }]; + }); + + return [...options, createSkipOption('放弃这次额外战术') as any]; +} + +function getImmediateActionTargetMode(def: ActionCardDef | FusionCardDef): 'none' | 'base' | 'minion' { + const subtype = (def as any).type === 'fusion' + ? (def as FusionCardDef).actionSubtype + : (def as ActionCardDef).subtype; + + if (subtype !== 'ongoing') return 'none'; + + const ongoingTarget = (def as any).type === 'fusion' + ? ((def as FusionCardDef).actionOngoingTarget ?? 'base') + : ((def as ActionCardDef).ongoingTarget ?? 'base'); + + return ongoingTarget === 'minion' ? 'minion' : 'base'; +} + +function buildImmediateExtraActionBaseOptions( + state: MatchState, + extra: ImmediateExtraActionPayload, + choice: ImmediateActionCardChoice, +) { + const validationState = buildValidationState(state, extra); + const candidates = state.core.bases + .map((base, baseIndex) => ({ baseIndex, label: getBaseDef(base.defId)?.name ?? `基地 ${baseIndex + 1}` })) + .filter(candidate => validate(validationState, { + type: SU_COMMANDS.PLAY_ACTION, + playerId: extra.playerId, + payload: { cardUid: choice.cardUid, targetBaseIndex: candidate.baseIndex }, + }).valid); + + return buildBaseTargetOptions(candidates, state.core); +} + +function buildImmediateExtraActionMinionOptions( + state: MatchState, + extra: ImmediateExtraActionPayload, + choice: ImmediateActionCardChoice, +) { + const validationState = buildValidationState(state, extra); + const candidates: Array<{ uid: string; defId: string; baseIndex: number; label: string }> = []; + + for (let baseIndex = 0; baseIndex < state.core.bases.length; baseIndex += 1) { + const base = state.core.bases[baseIndex]; + const baseName = getBaseDef(base.defId)?.name ?? `基地 ${baseIndex + 1}`; + for (const minion of base.minions) { + if (!validate(validationState, { + type: SU_COMMANDS.PLAY_ACTION, + playerId: extra.playerId, + payload: { cardUid: choice.cardUid, targetBaseIndex: baseIndex, targetMinionUid: minion.uid }, + }).valid) { + continue; + } + + const def = getCardDef(minion.defId); + candidates.push({ + uid: minion.uid, + defId: minion.defId, + baseIndex, + label: `${def?.name ?? minion.defId} @ ${baseName}`, + }); + } + } + + return buildMinionTargetOptions(candidates, { state: state.core }); +} + +function buildImmediateExtraInteraction( + extra: ImmediateExtraLimitPayload, +): ReturnType { + const id = `smashup_immediate_extra_${immediateExtraPromptCounter++}`; + const isMinion = extra.limitType === 'minion'; + const interaction = createSimpleChoice( + id, + extra.playerId, + isMinion ? '立刻打出一个额外随从,或放弃这次机会' : '立刻打出一张额外战术,或放弃这次机会', + [isMinion ? createSkipOption('放弃这次额外随从') : createSkipOption('放弃这次额外战术')] as any[], + { + sourceId: isMinion ? 'smashup_immediate_extra_minion' : 'smashup_immediate_extra_action', + targetType: 'hand', + autoResolveIfSingle: false, + }, + ); + + (interaction.data as any).continuationContext = { extra }; + (interaction.data as any).autoRefresh = 'hand'; + (interaction.data as any).responseValidationMode = 'live'; + (interaction.data as any).optionsGenerator = (state: MatchState, data: { continuationContext?: { extra?: ImmediateExtraLimitPayload } }) => { + const latestExtra = data?.continuationContext?.extra; + if (!latestExtra) { + return [isMinion ? createSkipOption('放弃这次额外随从') : createSkipOption('放弃这次额外战术')] as any[]; + } + + return latestExtra.limitType === 'minion' + ? buildImmediateExtraMinionCardOptions(state, latestExtra) + : buildImmediateExtraActionCardOptions(state, latestExtra); + }; + + return interaction; +} + +function executeImmediateExtraMinionPlay( + state: MatchState, + extra: ImmediateExtraMinionPayload, + choice: ImmediateMinionCardChoice, + baseIndex: number, + timestamp: number, + random: Parameters[2], +) { + const validationState = buildValidationState(state, extra); + const validation = validate(validationState, { + type: SU_COMMANDS.PLAY_MINION, + playerId: extra.playerId, + payload: { cardUid: choice.cardUid, baseIndex }, + }); + if (!validation.valid) { + return { state, events: [] }; + } + + const execState: MatchState = { ...state, sys: { ...state.sys } }; + const events = execute(execState, { + type: SU_COMMANDS.PLAY_MINION, + playerId: extra.playerId, + payload: { cardUid: choice.cardUid, baseIndex }, + timestamp, + }, random); + + return { + state: execState, + events: [ + grantExtraMinion( + extra.playerId, + extra.reason, + timestamp, + extra.restrictToBase, + { + powerMax: extra.powerMax, + sameNameOnly: extra.sameNameOnly, + sameNameDefId: extra.sameNameDefId, + }, + ), + ...events, + ], + }; +} + +function executeImmediateExtraActionPlay( + state: MatchState, + extra: ImmediateExtraActionPayload, + choice: ImmediateActionCardChoice, + timestamp: number, + random: Parameters[2], + targetBaseIndex?: number, + targetMinionUid?: string, +) { + const validationState = buildValidationState(state, extra); + const validation = validate(validationState, { + type: SU_COMMANDS.PLAY_ACTION, + playerId: extra.playerId, + payload: { cardUid: choice.cardUid, targetBaseIndex, targetMinionUid }, + }); + if (!validation.valid) { + return { state, events: [] }; + } + + const execState: MatchState = { ...state, sys: { ...state.sys } }; + const events = execute(execState, { + type: SU_COMMANDS.PLAY_ACTION, + playerId: extra.playerId, + payload: { cardUid: choice.cardUid, targetBaseIndex, targetMinionUid }, + timestamp, + }, random); + + return { + state: execState, + events: [grantExtraAction(extra.playerId, extra.reason, timestamp), ...events], + }; +} + +export function queueImmediateExtraPlayInteractions( + state: MatchState, + events: LimitModifiedEvent[], +): MatchState { + let nextState = state; + + for (const event of events) { + if (event.payload.playTiming !== 'immediate' || event.payload.delta <= 0) continue; + nextState = queueInteraction(nextState, buildImmediateExtraInteraction(event.payload as ImmediateExtraLimitPayload)); + } + + return nextState; +} + +export function registerImmediateExtraPlayInteractionHandlers(): void { + registerInteractionHandler('smashup_immediate_extra_minion', (state, playerId, value, interactionData, random, timestamp) => { + if ((value as { skip?: boolean })?.skip) return { state, events: [] }; + + const choice = value as ImmediateMinionCardChoice; + const extra = (interactionData?.continuationContext as { extra?: ImmediateExtraMinionPayload } | undefined)?.extra; + if (!choice.cardUid || !extra || extra.playerId !== playerId) return { state, events: [] }; + + const baseOptions = buildImmediateExtraMinionBaseOptions(state, extra, choice); + if (baseOptions.length === 0) return { state, events: [] }; + if (baseOptions.length === 1) { + const selected = baseOptions[0].value as ImmediateBaseChoice; + return executeImmediateExtraMinionPlay(state, extra, choice, selected.baseIndex, timestamp, random); + } + + const interaction = createSimpleChoice( + `smashup_immediate_extra_minion_base_${immediateExtraPromptCounter++}`, + playerId, + '选择要打出该额外随从的基地', + [...baseOptions, createSkipOption('放弃这次额外随从')] as any[], + { sourceId: 'smashup_immediate_extra_minion_base', targetType: 'base', autoResolveIfSingle: false }, + ); + (interaction.data as any).continuationContext = { extra, choice }; + (interaction.data as any).optionsGenerator = (latestState: MatchState, data: { continuationContext?: { extra?: ImmediateExtraMinionPayload; choice?: ImmediateMinionCardChoice } }) => { + const latestExtra = data?.continuationContext?.extra; + const latestChoice = data?.continuationContext?.choice; + if (!latestExtra || !latestChoice) return []; + return [...buildImmediateExtraMinionBaseOptions(latestState, latestExtra, latestChoice), createSkipOption('放弃这次额外随从')] as any[]; + }; + + return { state: queueInteraction(state, interaction), events: [] }; + }); + + registerInteractionHandler('smashup_immediate_extra_minion_base', (state, playerId, value, interactionData, random, timestamp) => { + if ((value as { skip?: boolean })?.skip) return { state, events: [] }; + const { baseIndex } = value as ImmediateBaseChoice; + const ctx = interactionData?.continuationContext as { extra?: ImmediateExtraMinionPayload; choice?: ImmediateMinionCardChoice } | undefined; + if (baseIndex === undefined || !ctx?.extra || !ctx.choice || ctx.extra.playerId !== playerId) return { state, events: [] }; + return executeImmediateExtraMinionPlay(state, ctx.extra, ctx.choice, baseIndex, timestamp, random); + }); + + registerInteractionHandler('smashup_immediate_extra_action', (state, playerId, value, interactionData, random, timestamp) => { + if ((value as { skip?: boolean })?.skip) return { state, events: [] }; + + const choice = value as ImmediateActionCardChoice; + const extra = (interactionData?.continuationContext as { extra?: ImmediateExtraActionPayload } | undefined)?.extra; + if (!choice.cardUid || !extra || extra.playerId !== playerId) return { state, events: [] }; + + const def = getCardDef(choice.defId) as ActionCardDef | FusionCardDef | undefined; + if (!def) return { state, events: [] }; + + const targetMode = getImmediateActionTargetMode(def); + if (targetMode === 'none') { + return executeImmediateExtraActionPlay(state, extra, choice, timestamp, random); + } + + if (targetMode === 'base') { + const baseOptions = buildImmediateExtraActionBaseOptions(state, extra, choice); + if (baseOptions.length === 0) return { state, events: [] }; + if (baseOptions.length === 1) { + const selected = baseOptions[0].value as ImmediateBaseChoice; + return executeImmediateExtraActionPlay(state, extra, choice, timestamp, random, selected.baseIndex); + } + + const interaction = createSimpleChoice( + `smashup_immediate_extra_action_base_${immediateExtraPromptCounter++}`, + playerId, + '选择该额外战术的目标基地', + [...baseOptions, createSkipOption('放弃这次额外战术')] as any[], + { sourceId: 'smashup_immediate_extra_action_base', targetType: 'base', autoResolveIfSingle: false }, + ); + (interaction.data as any).continuationContext = { extra, choice }; + (interaction.data as any).optionsGenerator = (latestState: MatchState, data: { continuationContext?: { extra?: ImmediateExtraActionPayload; choice?: ImmediateActionCardChoice } }) => { + const latestExtra = data?.continuationContext?.extra; + const latestChoice = data?.continuationContext?.choice; + if (!latestExtra || !latestChoice) return []; + return [...buildImmediateExtraActionBaseOptions(latestState, latestExtra, latestChoice), createSkipOption('放弃这次额外战术')] as any[]; + }; + + return { state: queueInteraction(state, interaction), events: [] }; + } + + const minionOptions = buildImmediateExtraActionMinionOptions(state, extra, choice); + if (minionOptions.length === 0) return { state, events: [] }; + if (minionOptions.length === 1) { + const selected = minionOptions[0].value as ImmediateMinionTargetChoice; + return executeImmediateExtraActionPlay(state, extra, choice, timestamp, random, selected.baseIndex, selected.minionUid); + } + + const interaction = createSimpleChoice( + `smashup_immediate_extra_action_minion_${immediateExtraPromptCounter++}`, + playerId, + '选择该额外战术的目标随从', + [...minionOptions, createSkipOption('放弃这次额外战术')] as any[], + { sourceId: 'smashup_immediate_extra_action_minion', targetType: 'minion', autoResolveIfSingle: false }, + ); + (interaction.data as any).continuationContext = { extra, choice }; + (interaction.data as any).optionsGenerator = (latestState: MatchState, data: { continuationContext?: { extra?: ImmediateExtraActionPayload; choice?: ImmediateActionCardChoice } }) => { + const latestExtra = data?.continuationContext?.extra; + const latestChoice = data?.continuationContext?.choice; + if (!latestExtra || !latestChoice) return []; + return [...buildImmediateExtraActionMinionOptions(latestState, latestExtra, latestChoice), createSkipOption('放弃这次额外战术')] as any[]; + }; + + return { state: queueInteraction(state, interaction), events: [] }; + }); + + registerInteractionHandler('smashup_immediate_extra_action_base', (state, playerId, value, interactionData, random, timestamp) => { + if ((value as { skip?: boolean })?.skip) return { state, events: [] }; + const { baseIndex } = value as ImmediateBaseChoice; + const ctx = interactionData?.continuationContext as { extra?: ImmediateExtraActionPayload; choice?: ImmediateActionCardChoice } | undefined; + if (baseIndex === undefined || !ctx?.extra || !ctx.choice || ctx.extra.playerId !== playerId) return { state, events: [] }; + return executeImmediateExtraActionPlay(state, ctx.extra, ctx.choice, timestamp, random, baseIndex); + }); + + registerInteractionHandler('smashup_immediate_extra_action_minion', (state, playerId, value, interactionData, random, timestamp) => { + if ((value as { skip?: boolean })?.skip) return { state, events: [] }; + const { baseIndex, minionUid } = value as ImmediateMinionTargetChoice; + const ctx = interactionData?.continuationContext as { extra?: ImmediateExtraActionPayload; choice?: ImmediateActionCardChoice } | undefined; + if (baseIndex === undefined || !minionUid || !ctx?.extra || !ctx.choice || ctx.extra.playerId !== playerId) return { state, events: [] }; + return executeImmediateExtraActionPlay(state, ctx.extra, ctx.choice, timestamp, random, baseIndex, minionUid); + }); +} diff --git a/src/games/smashup/domain/index.ts b/src/games/smashup/domain/index.ts index ceab7413d..7687c4e92 100644 --- a/src/games/smashup/domain/index.ts +++ b/src/games/smashup/domain/index.ts @@ -21,6 +21,7 @@ import type { BaseClearedEvent, BaseReplacedEvent, DeckReshuffledEvent, + LimitModifiedEvent, MinionPlayedEvent, MinionPowerBreakdown, } from './types'; @@ -51,6 +52,7 @@ import { RESPONSE_WINDOW_EVENTS } from '../../../engine/systems/ResponseWindowSy import { resolveSpecial } from './abilityRegistry'; import type { AbilityContext } from './abilityRegistry'; import type { SpecialAfterScoringConsumedEvent } from './types'; +import { queueImmediateExtraPlayInteractions } from './extraPlay'; // ============================================================================ // 基地记分辅助函数(供 FlowHooks 和 Prompt 继续函数共用) @@ -1845,6 +1847,13 @@ function postProcessSystemEvents( return { events: [...combined, ...rq.events], matchState: rq.state }; } + const immediateExtraEvents = combined.filter((event): event is LimitModifiedEvent => + event.type === SU_EVENTS.LIMIT_MODIFIED && event.payload.playTiming === 'immediate', + ); + if (immediateExtraEvents.length > 0) { + ms = queueImmediateExtraPlayInteractions(ms, immediateExtraEvents); + } + return { events: combined, matchState: ms }; } diff --git a/src/games/smashup/domain/reduce.ts b/src/games/smashup/domain/reduce.ts index 6a4284742..704cf7602 100644 --- a/src/games/smashup/domain/reduce.ts +++ b/src/games/smashup/domain/reduce.ts @@ -985,8 +985,12 @@ export function reduce(state: SmashUpCore, event: SmashUpEvent): SmashUpCore { } case SU_EVENTS.LIMIT_MODIFIED: { - const { playerId, limitType, delta, restrictToBase, powerMax, sameNameOnly, sameNameDefId } = event.payload; + const { playerId, limitType, delta, restrictToBase, powerMax, sameNameOnly, sameNameDefId, playTiming } = event.payload; const player = state.players[playerId]; + if (playTiming === 'immediate') { + // 立即额外出牌只作为交互信号存在,不应沉淀为可跨时机保留的额度。 + return state; + } if (limitType === 'minion') { // 基地限定额度:写入 baseLimitedMinionQuota if (restrictToBase !== undefined) { diff --git a/src/games/smashup/domain/types.ts b/src/games/smashup/domain/types.ts index 6802359db..52971286c 100644 --- a/src/games/smashup/domain/types.ts +++ b/src/games/smashup/domain/types.ts @@ -910,6 +910,8 @@ export interface LimitModifiedEvent extends GameEvent<'su:limit_modified'> { limitType: 'minion' | 'action'; delta: number; reason: string; + /** 额外出牌时机:banked=可在当前出牌阶段暂存;immediate=必须立刻打出或放弃 */ + playTiming?: 'banked' | 'immediate'; /** 限定额度只能用于指定基地(不设则为全局额度) */ restrictToBase?: number; /** 额外出牌的力量上限(如家园:力量≤2),不设则无限制 */ diff --git "a/src/games/smashup/rule/\345\244\247\346\235\200\345\233\233\346\226\271\350\247\204\345\210\231.md" "b/src/games/smashup/rule/\345\244\247\346\235\200\345\233\233\346\226\271\350\247\204\345\210\231.md" index 352570dd2..b2f6315b2 100644 --- "a/src/games/smashup/rule/\345\244\247\346\235\200\345\233\233\346\226\271\350\247\204\345\210\231.md" +++ "b/src/games/smashup/rule/\345\244\247\346\235\200\345\233\233\346\226\271\350\247\204\345\210\231.md" @@ -15,12 +15,14 @@ ### 1. 回合开始 (Start Turn) * 清理在"回合开始时"过期的持续效果。 * 触发所有"在你的回合开始时"能力。 +* 若此阶段获得**额外随从**或**额外行动**,必须**立刻打出或放弃**,不能保留到本回合的出牌阶段。 ### 2. 出牌阶段 (Play Cards) 可选操作(顺序不限): * 打出 1 张**随从**。 * 打出 1 张**行动**。 * 使用卡牌能力。 +* 若在此阶段获得普通的**额外随从**或**额外行动**,可以在本阶段内稍后再用,但一旦离开此阶段就失效,不能跨到后续阶段或未来回合。 #### 随从 (Minions) * 选择一个基地放置,立即结算其效果。 @@ -157,6 +159,8 @@ * **弃掉 (Discard)**:将非场上卡牌放入弃牌堆(默认指手牌)。 * **回合结束 (End of a turn)**:"回合结束时"指当前玩家回合结束。 * **额外 (Extra)**:不计入每回合出牌限额。可选。 + * 在**出牌阶段内**获得的普通 extra 可在该阶段内暂存。 + * 在**非出牌阶段**获得的 extra,或由 **Special** 给予的 extra,必须立刻打出或放弃。 * **拥有 (Have)**:在基地拥有卡牌 = 控制该卡牌。 * **这里 (Here)**:该卡牌所在基地。 * **忽略能力 (Ignore)**:不执行、不受限、不受影响也不受保护。