Skip to content
Closed
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
151 changes: 77 additions & 74 deletions src/games/smashup/__tests__/archmageE2E.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,12 +13,13 @@
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';

Check warning on line 16 in src/games/smashup/__tests__/archmageE2E.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'SU_EVENTS' is defined but never used. Allowed unused vars must match /^_/u
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'];

Expand Down Expand Up @@ -51,14 +52,26 @@
return { core, sys: { ...sys, phase: 'playCards' } } as MatchState<SmashUpCore>;
}

function skipAllCurrentInteractions(state: MatchState<SmashUpCore>, playerId: string): MatchState<SmashUpCore> {
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'),
Expand All @@ -69,40 +82,61 @@
],
});

const fullState = makeFullMatchState(core);
// 设置为 P1 的 playCards 阶段
const stateWithP1Turn: MatchState<SmashUpCore> = {
...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: [
{ type: FLOW_COMMANDS.ADVANCE_PHASE, playerId: '1', payload: undefined },
] 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({
Expand All @@ -117,14 +151,7 @@
],
});

const fullState = makeFullMatchState(core);
const stateWithP1Turn: MatchState<SmashUpCore> = {
...fullState,
sys: { ...fullState.sys, phase: 'playCards' },
};

const runner = createCustomRunner(stateWithP1Turn);

const runner = createCustomRunner(makeFullMatchState(core));
const result = runner.run({
name: '大法师 E2E - P1 控制,P0 回合不触发',
commands: [
Expand All @@ -134,15 +161,11 @@

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({
Expand All @@ -157,14 +180,7 @@
],
});

const fullState = makeFullMatchState(core);
const stateWithP0Turn: MatchState<SmashUpCore> = {
...fullState,
sys: { ...fullState.sys, phase: 'playCards' },
};

const runner = createCustomRunner(stateWithP0Turn);

const runner = createCustomRunner(makeFullMatchState(core));
const result = runner.run({
name: '大法师 E2E - P0 控制,P1 回合不触发',
commands: [
Expand All @@ -174,17 +190,13 @@

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({
Expand All @@ -199,11 +211,7 @@
],
});

const fullState = makeFullMatchState(core);

const runner = createCustomRunner(fullState);

// P0 打出大法师
const runner = createCustomRunner(makeFullMatchState(core));
const result = runner.run({
name: '大法师 E2E - 打出当回合获得额外行动',
commands: [
Expand All @@ -212,14 +220,9 @@
});

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();
});
});
2 changes: 2 additions & 0 deletions src/games/smashup/__tests__/baseFactionOngoing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@

import { describe, test, expect, beforeEach } from 'vitest';
import {
registerProtection,

Check warning on line 13 in src/games/smashup/__tests__/baseFactionOngoing.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'registerProtection' is defined but never used. Allowed unused vars must match /^_/u
registerRestriction,

Check warning on line 14 in src/games/smashup/__tests__/baseFactionOngoing.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'registerRestriction' is defined but never used. Allowed unused vars must match /^_/u
registerTrigger,

Check warning on line 15 in src/games/smashup/__tests__/baseFactionOngoing.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'registerTrigger' is defined but never used. Allowed unused vars must match /^_/u
clearOngoingEffectRegistry,
registerPodOngoingAliases,
isMinionProtected,
Expand Down Expand Up @@ -286,7 +286,7 @@
minions: [minion],
ongoingActions: [{ uid: 'ongoing1', defId: 'test_ongoing', ownerId: '1' }],
});
const state = makeState([base]);

Check warning on line 289 in src/games/smashup/__tests__/baseFactionOngoing.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'state' is assigned a value but never used. Allowed unused vars must match /^_/u

// 直接测试 ninjaInfiltrateOnPlay 的逻辑:
// 它应该只收集 base.ongoingActions,不包括 minion.attachedActions
Expand Down Expand Up @@ -1021,6 +1021,7 @@
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)', () => {
Expand Down Expand Up @@ -1242,6 +1243,7 @@
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('非拥有者回合不触发', () => {
Expand Down
16 changes: 11 additions & 5 deletions src/games/smashup/__tests__/expansionAbilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@
bases: [{ defId: 'b1', minions: [], ongoingActions: [] }],
});

const events = execPlayMinion(state, '0', 'm1', 0);

Check warning on line 186 in src/games/smashup/__tests__/expansionAbilities.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'events' is assigned a value but never used. Allowed unused vars must match /^_/u
// 多张可弃手牌时应创建 Interaction
const interactions = getLastInteractions();
expect(interactions.length).toBe(1);
Expand All @@ -204,7 +204,7 @@
bases: [{ defId: 'b1', minions: [], ongoingActions: [] }],
});

const events = execPlayMinion(state, '0', 'm1', 0);

Check warning on line 207 in src/games/smashup/__tests__/expansionAbilities.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'events' is assigned a value but never used. Allowed unused vars must match /^_/u
// 单张手牌时创建 Interaction
const interactions = getLastInteractions();
expect(interactions.length).toBe(1);
Expand Down Expand Up @@ -528,21 +528,27 @@
});

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');
});
});
});
Expand All @@ -568,7 +574,7 @@
},
});

const events = execPlayAction(state, '0', 'a1');

Check warning on line 577 in src/games/smashup/__tests__/expansionAbilities.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'events' is assigned a value but never used. Allowed unused vars must match /^_/u
// 多张行动卡时应创建 Interaction
const interactions = getLastInteractions();
expect(interactions.length).toBe(1);
Expand All @@ -589,7 +595,7 @@
},
});

const events = execPlayAction(state, '0', 'a1');

Check warning on line 598 in src/games/smashup/__tests__/expansionAbilities.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'events' is assigned a value but never used. Allowed unused vars must match /^_/u
// 单张行动卡时创建 Interaction
const interactions = getLastInteractions();
expect(interactions.length).toBe(1);
Expand Down
34 changes: 34 additions & 0 deletions src/games/smashup/__tests__/factionAbilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
SmashUpCore,
SmashUpEvent,
PlayerState,
BaseInPlay,

Check warning on line 14 in src/games/smashup/__tests__/factionAbilities.test.ts

View workflow job for this annotation

GitHub Actions / quality-gate

'BaseInPlay' is defined but never used. Allowed unused vars must match /^_/u
MinionOnBase,
CardInstance,
} from '../domain/types';
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';
Expand Down Expand Up @@ -479,6 +480,7 @@
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随从都给额度', () => {
Expand All @@ -498,6 +500,38 @@
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')
Expand Down
6 changes: 2 additions & 4 deletions src/games/smashup/abilities/bear_cavalry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] };
}

// 让玩家选择要打出的手牌随从
Expand All @@ -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) };
}


Expand Down
6 changes: 3 additions & 3 deletions src/games/smashup/abilities/frankenstein.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'),
],
};
}
Expand Down Expand Up @@ -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),
],
};
Expand Down
Loading
Loading