diff --git a/server/game/Interfaces.ts b/server/game/Interfaces.ts index 1b1a8c3e2..c199598d4 100644 --- a/server/game/Interfaces.ts +++ b/server/game/Interfaces.ts @@ -284,7 +284,7 @@ interface IAmbushKeywordProperties extends IKeywordPropertiesBase { interface IBountyKeywordProperties extends IKeywordWithAbilityDefinitionProperties { keyword: KeywordName.Bounty; - ability: Omit, 'when' | 'aggregateWhen' | 'abilityController'>; + ability: Omit, 'abilityController'>; } interface IGritKeywordProperties extends IKeywordPropertiesBase { diff --git a/server/game/abilities/keyword/BountyAbility.ts b/server/game/abilities/keyword/BountyAbility.ts new file mode 100644 index 000000000..28c4caefd --- /dev/null +++ b/server/game/abilities/keyword/BountyAbility.ts @@ -0,0 +1,73 @@ +import TriggeredAbility from '../../core/ability/TriggeredAbility'; +import type { Card } from '../../core/card/Card'; +import type { UnitCard } from '../../core/card/CardTypes'; +import { EventName, KeywordName, RelativePlayer, WildcardZoneName } from '../../core/Constants'; +import { GameEvent } from '../../core/event/GameEvent'; +import type Game from '../../core/Game'; +import * as Contract from '../../core/utils/Contract'; +import type { ITriggeredAbilityBaseProps } from '../../Interfaces'; + +export type IResolvedBountyProperties = Omit & { + bountySource?: UnitCard; +}; + +/** + * Extension of {@link TriggeredAbility} to handle bounties. The major difference is that it emits an + * `onBountyCollected` event with the properties for the bounty ability (used for Bossk leader ability). + * This event is emitted regardless of whether the bounty's actual effects will change game state. + */ +export class BountyAbility extends TriggeredAbility { + public override readonly keyword: KeywordName | null = KeywordName.Bounty; + + private readonly bountyProperties: IResolvedBountyProperties; + + public constructor( + game: Game, + card: Card, + properties: Omit, + ) { + Contract.assertTrue(card.isUnit()); + + const { title, optional } = properties; + + const fullProps = { + ...properties, + title: 'Collect Bounty: ' + title, + // 7.5.13.E : Resolving a Bounty ability is optional. If a player chooses not to resolve a Bounty ability, they are not considered to have collected that Bounty. + // however, we do allow overriding the optional behavior in some special cases (e.g. for readying resources or Bossk ability) + optional: optional ?? true, + when: { + onCardDefeated: (event, context) => event.card === context.source, + onCardCaptured: (event, context) => event.card === context.source + }, + abilityController: RelativePlayer.Opponent, + zoneFilter: WildcardZoneName.AnyArena + }; + + super(game, card, fullProps); + + this.canResolveWithoutLegalTargets = true; + + this.bountyProperties = { ...properties, bountySource: card }; + } + + // Bounty abilities always have some game effect because we always do "collecting the bounty" / emitting the onBountyCollected event + public override hasAnyLegalEffects(context, includeSubSteps = false) { + return true; + } + + public override queueEventsForSystems(context: any) { + super.queueEventsForSystems(context); + + const bountyEvent = new GameEvent( + EventName.OnBountyCollected, + context, + { + card: this.card, + bountyProperties: this.bountyProperties + } + ); + + context.events.push(bountyEvent); + } +} diff --git a/server/game/cards/03_TWI/leaders/BosskHuntingHisPrey.ts b/server/game/cards/03_TWI/leaders/BosskHuntingHisPrey.ts new file mode 100644 index 000000000..b23db0745 --- /dev/null +++ b/server/game/cards/03_TWI/leaders/BosskHuntingHisPrey.ts @@ -0,0 +1,50 @@ +import AbilityHelper from '../../../AbilityHelper'; +import { LeaderUnitCard } from '../../../core/card/LeaderUnitCard'; +import { KeywordName, WildcardCardType } from '../../../core/Constants'; + +export default class BosskHuntingHisPrey extends LeaderUnitCard { + protected override getImplementationId() { + return { + id: '2526288781', + internalName: 'bossk#hunting-his-prey', + }; + } + + protected override setupLeaderSideAbilities() { + this.addActionAbility({ + title: 'Deal 1 damage to a unit with a Bounty. You may give it +1/+0 for this phase.', + cost: [AbilityHelper.costs.exhaustSelf()], + targetResolver: { + cardTypeFilter: WildcardCardType.Unit, + cardCondition: (card) => card.hasSomeKeyword(KeywordName.Bounty), + immediateEffect: AbilityHelper.immediateEffects.damage({ amount: 1 }) + }, + then: (thenContext) => ({ + title: 'Give it +1/+0 for this phase', + thenCondition: () => !!thenContext.target, + optional: true, + immediateEffect: AbilityHelper.immediateEffects.forThisPhaseCardEffect({ + effect: AbilityHelper.ongoingEffects.modifyStats({ power: 1, hp: 0 }), + target: thenContext.target + }) + }) + }); + } + + protected override setupLeaderUnitSideAbilities() { + this.addTriggeredAbility({ + title: 'Collect the Bounty again', + optional: true, + when: { + onBountyCollected: (event, context) => event.context.player === context.source.controller + }, + immediateEffect: AbilityHelper.immediateEffects.collectBounty((context) => ({ + bountyProperties: context.event.bountyProperties, + bountySource: context.event.card + })), + limit: AbilityHelper.limit.perRound(1) + }); + } +} + +BosskHuntingHisPrey.implemented = true; diff --git a/server/game/core/Constants.ts b/server/game/core/Constants.ts index c3232e7ac..731acfd8b 100644 --- a/server/game/core/Constants.ts +++ b/server/game/core/Constants.ts @@ -196,6 +196,7 @@ export enum EventName { OnAttackDamageResolved = 'onAttackDamageResolved', OnAttackDeclared = 'onAttackDeclared', OnBeginRound = 'onBeginRound', + OnBountyCollected = 'onBountyCollected', OnCardAbilityInitiated = 'onCardAbilityInitiated', OnCardAbilityTriggered = 'onCardAbilityTriggered', OnCardCaptured = 'onCardCaptured', diff --git a/server/game/core/Game.js b/server/game/core/Game.js index e653d2d29..235caf844 100644 --- a/server/game/core/Game.js +++ b/server/game/core/Game.js @@ -1,6 +1,4 @@ const EventEmitter = require('events'); - -// const ChatCommands = require('./chat/ChatCommands.js'); const { GameChat } = require('./chat/GameChat.js'); const { OngoingEffectEngine } = require('./ongoingEffect/OngoingEffectEngine.js'); const Player = require('./Player.js'); @@ -45,7 +43,6 @@ class Game extends EventEmitter { this.ongoingEffectEngine = new OngoingEffectEngine(this); this.playersAndSpectators = {}; this.gameChat = new GameChat(); - // this.chatCommands = new ChatCommands(this); this.pipeline = new GamePipeline(); this.id = details.id; this.name = details.name; diff --git a/server/game/core/ability/AbilityContext.ts b/server/game/core/ability/AbilityContext.ts index 1486673be..4a18a6077 100644 --- a/server/game/core/ability/AbilityContext.ts +++ b/server/game/core/ability/AbilityContext.ts @@ -89,11 +89,13 @@ export class AbilityContext { player: this.player, ability: this.ability, costs: Object.assign({}, this.costs), + costAspects: this.costAspects, targets: Object.assign({}, this.targets), selects: Object.assign({}, this.selects), events: this.events, stage: this.stage, - targetAbility: this.targetAbility + targetAbility: this.targetAbility, + playType: this.playType }; } } diff --git a/server/game/core/ability/CardAbility.ts b/server/game/core/ability/CardAbility.ts index d6d809dc9..72018f231 100644 --- a/server/game/core/ability/CardAbility.ts +++ b/server/game/core/ability/CardAbility.ts @@ -6,7 +6,7 @@ import * as AbilityLimit from './AbilityLimit'; import * as EnumHelpers from '../utils/EnumHelpers'; import type { Card } from '../card/Card'; -export class CardAbility extends CardAbilityStep { +export abstract class CardAbility extends CardAbilityStep { public readonly abilityController: RelativePlayer; public readonly abilityIdentifier: string; public readonly gainAbilitySource: Card; diff --git a/server/game/core/ability/CardAbilityStep.js b/server/game/core/ability/CardAbilityStep.js index 651be8d35..ad5511fb7 100644 --- a/server/game/core/ability/CardAbilityStep.js +++ b/server/game/core/ability/CardAbilityStep.js @@ -119,13 +119,9 @@ class CardAbilityStep extends PlayerOrCardAbility { executeGameActions(context) { context.events = []; - let systems = this.getGameSystems(context); - for (const system of systems) { - this.game.queueSimpleStep(() => { - system.queueGenerateEventGameSteps(context.events, context); - }, - `queue ${system.name} event generation steps for ${this}`); - } + + this.queueEventsForSystems(context); + this.game.queueSimpleStep(() => { let eventsToResolve = context.events.filter((event) => event.canResolve); if (eventsToResolve.length > 0) { @@ -142,6 +138,17 @@ class CardAbilityStep extends PlayerOrCardAbility { }, `resolve events for ${this}`); } + queueEventsForSystems(context) { + const systems = this.getGameSystems(context); + + for (const system of systems) { + this.game.queueSimpleStep(() => { + system.queueGenerateEventGameSteps(context.events, context); + }, + `queue ${system.name} event generation steps for ${this}`); + } + } + openEventWindow(events) { return this.game.openEventWindow(events); } diff --git a/server/game/core/ability/KeywordHelpers.ts b/server/game/core/ability/KeywordHelpers.ts index f28c5020b..e6c2383b9 100644 --- a/server/game/core/ability/KeywordHelpers.ts +++ b/server/game/core/ability/KeywordHelpers.ts @@ -1,8 +1,8 @@ -import type { IKeywordProperties, ITriggeredAbilityProps } from '../../Interfaces'; -import { AbilityType, Aspect, KeywordName, PlayType, RelativePlayer } from '../Constants'; +import type { IKeywordProperties } from '../../Interfaces'; +import { Aspect, KeywordName, PlayType } from '../Constants'; import * as Contract from '../utils/Contract'; import * as EnumHelpers from '../utils/EnumHelpers'; -import { KeywordInstance, KeywordWithAbilityDefinition, KeywordWithCostValues, KeywordWithNumericValue } from './KeywordInstance'; +import { BountyKeywordInstance, KeywordInstance, KeywordWithAbilityDefinition, KeywordWithCostValues, KeywordWithNumericValue } from './KeywordInstance'; import type { PlayCardAction } from './PlayCardAction'; export function parseKeywords(expectedKeywordsRaw: string[], cardText: string, cardName: string): KeywordInstance[] { @@ -21,7 +21,11 @@ export function parseKeywords(expectedKeywordsRaw: string[], cardText: string, c if (smuggleValuesOrNull != null) { keywords.push(smuggleValuesOrNull); } - } else if (keywordName === KeywordName.Bounty || keywordName === KeywordName.Coordinate) { + } else if (keywordName === KeywordName.Bounty) { + if (isKeywordEnabled(keywordName, cardText, cardName)) { + keywords.push(new BountyKeywordInstance(keywordName)); + } + } else if (keywordName === KeywordName.Coordinate) { if (isKeywordEnabled(keywordName, cardText, cardName)) { keywords.push(new KeywordWithAbilityDefinition(keywordName)); } @@ -43,8 +47,7 @@ export function keywordFromProperties(properties: IKeywordProperties) { return new KeywordWithNumericValue(properties.keyword, properties.amount); case KeywordName.Bounty: - const bountyAbilityProps = createBountyAbilityFromProps(properties.ability); - return new KeywordWithAbilityDefinition(properties.keyword, { ...bountyAbilityProps, type: AbilityType.Triggered }); + return new BountyKeywordInstance(properties.keyword, properties.ability); case KeywordName.Smuggle: return new KeywordWithCostValues(properties.keyword, properties.cost, properties.aspects, false); @@ -62,22 +65,6 @@ export function keywordFromProperties(properties: IKeywordProperties) { } } -export function createBountyAbilityFromProps(properties: Omit): ITriggeredAbilityProps { - const { title, ...otherProps } = properties; - - return { - title: 'Bounty: ' + title, - // 7.5.13.E : Resolving a Bounty ability is optional. If a player chooses not to resolve a Bounty ability, they are not considered to have collected that Bounty. - optional: true, - when: { - onCardDefeated: (event, context) => event.card === context.source, - onCardCaptured: (event, context) => event.card === context.source - }, - abilityController: RelativePlayer.Opponent, - ...otherProps - }; -} - export const isNumericType: Record = { [KeywordName.Ambush]: false, [KeywordName.Bounty]: false, diff --git a/server/game/core/ability/KeywordInstance.ts b/server/game/core/ability/KeywordInstance.ts index 012515cda..1f0b70452 100644 --- a/server/game/core/ability/KeywordInstance.ts +++ b/server/game/core/ability/KeywordInstance.ts @@ -1,4 +1,4 @@ -import type { IAbilityPropsWithType } from '../../Interfaces'; +import type { IAbilityPropsWithType, ITriggeredAbilityBaseProps } from '../../Interfaces'; import type { Card } from '../card/Card'; import type { Aspect, KeywordName } from '../Constants'; import type { LoseKeyword } from '../ongoingEffect/effectImpl/LoseKeyword'; @@ -78,6 +78,39 @@ export class KeywordWithCostValues extends KeywordInstance { } } +export class BountyKeywordInstance extends KeywordInstance { + private _abilityProps?: Omit, 'abilityController'> = null; + + public get abilityProps() { + if (this._abilityProps == null) { + Contract.fail(`Attempting to read property 'abilityProps' on a ${this.name} ability before it is defined`); + } + + return this._abilityProps; + } + + public override hasAbilityDefinition(): this is KeywordWithAbilityDefinition { + return true; + } + + public override get isFullyImplemented(): boolean { + return this._abilityProps != null; + } + + /** @param abilityProps Optional, but if not provided must be provided via `abilityProps` */ + public constructor(name: KeywordName, abilityProps: Omit, 'abilityController'> = null) { + super(name); + this._abilityProps = abilityProps; + } + + public setAbilityProps(abilityProps: Omit, 'abilityController'>) { + Contract.assertNotNullLike(abilityProps, `Attempting to set null ability definition for ${this.name}`); + Contract.assertIsNullLike(this._abilityProps, `Attempting to set ability definition for ${this.name} but it already has a value`); + + this._abilityProps = abilityProps; + } +} + export class KeywordWithAbilityDefinition extends KeywordInstance { private _abilityProps?: IAbilityPropsWithType = null; @@ -97,7 +130,7 @@ export class KeywordWithAbilityDefinition extends K return this._abilityProps != null; } - /** @param abilityProps Optional, but if not provided must be provided via {@link KeywordWithAbilityDefinition.setAbilityProps} */ + /** @param abilityProps Optional, but if not provided must be provided via `abilityProps` */ public constructor(name: KeywordName, abilityProps: IAbilityPropsWithType = null) { super(name); this._abilityProps = abilityProps; diff --git a/server/game/core/ability/PlayerOrCardAbility.js b/server/game/core/ability/PlayerOrCardAbility.js index b37d4fc40..efce4e632 100644 --- a/server/game/core/ability/PlayerOrCardAbility.js +++ b/server/game/core/ability/PlayerOrCardAbility.js @@ -59,6 +59,7 @@ class PlayerOrCardAbility { this.optional = !!properties.optional; this.immediateEffect = properties.immediateEffect; this.uuid = uuidv4(); + this.canResolveWithoutLegalTargets = false; // TODO: Ensure that nested abilities(triggers resolving during a trigger resolution) are resolving as expected. diff --git a/server/game/core/card/propertyMixins/UnitProperties.ts b/server/game/core/card/propertyMixins/UnitProperties.ts index 6432a2c00..fbb2a0e4c 100644 --- a/server/game/core/card/propertyMixins/UnitProperties.ts +++ b/server/game/core/card/propertyMixins/UnitProperties.ts @@ -1,6 +1,6 @@ import { InitiateAttackAction } from '../../../actions/InitiateAttackAction'; import type { Arena } from '../../Constants'; -import { AbilityType, CardType, EffectName, EventName, KeywordName, StatType, ZoneName } from '../../Constants'; +import { CardType, EffectName, EventName, KeywordName, StatType, ZoneName } from '../../Constants'; import StatsModifierWrapper from '../../ongoingEffect/effectImpl/StatsModifierWrapper'; import type { IOngoingCardEffect } from '../../ongoingEffect/IOngoingCardEffect'; import * as Contract from '../../utils/Contract'; @@ -11,7 +11,8 @@ import { WithPrintedPower } from './PrintedPower'; import * as EnumHelpers from '../../utils/EnumHelpers'; import type { UpgradeCard } from '../UpgradeCard'; import type { Card } from '../Card'; -import type { IAbilityPropsWithType, IConstantAbilityProps, ITriggeredAbilityProps } from '../../../Interfaces'; +import type { IAbilityPropsWithType, IConstantAbilityProps, ITriggeredAbilityBaseProps, ITriggeredAbilityProps } from '../../../Interfaces'; +import { BountyKeywordInstance } from '../../ability/KeywordInstance'; import { KeywordWithAbilityDefinition } from '../../ability/KeywordInstance'; import TriggeredAbility from '../../ability/TriggeredAbility'; import type { IConstantAbility } from '../../ongoingEffect/IConstantAbility'; @@ -25,10 +26,10 @@ import type { GameEvent } from '../../event/GameEvent'; import type { IDamageSource } from '../../../IDamageOrDefeatSource'; import { DefeatSourceType } from '../../../IDamageOrDefeatSource'; import { FrameworkDefeatCardSystem } from '../../../gameSystems/FrameworkDefeatCardSystem'; -import * as KeywordHelpers from '../../ability/KeywordHelpers'; import { CaptureZone } from '../../zone/CaptureZone'; import OngoingEffectLibrary from '../../../ongoingEffects/OngoingEffectLibrary'; import type Player from '../../Player'; +import { BountyAbility } from '../../../abilities/keyword/BountyAbility'; export const UnitPropertiesCard = WithUnitProperties(InPlayCard); @@ -216,9 +217,7 @@ export function WithUnitProperties(Bas this.addTriggeredAbility(triggeredProperties); } - protected addBountyAbility(properties: Omit, 'when' | 'aggregateWhen' | 'abilityController'>): void { - const triggeredProperties = KeywordHelpers.createBountyAbilityFromProps(properties); - + protected addBountyAbility(properties: Omit, 'abilityController'>): void { const bountyKeywords = this.printedKeywords.filter((keyword) => keyword.name === KeywordName.Bounty); const bountyKeywordsWithoutImpl = bountyKeywords.filter((keyword) => !keyword.isFullyImplemented); @@ -235,8 +234,8 @@ export function WithUnitProperties(Bas const bountyAbilityToAssign = bountyKeywordsWithoutImpl[0]; // TODO: see if there's a better way using discriminating unions to avoid needing a cast when getting keyword instances - Contract.assertTrue(bountyAbilityToAssign instanceof KeywordWithAbilityDefinition); - bountyAbilityToAssign.setAbilityProps({ ...triggeredProperties, type: AbilityType.Triggered }); + Contract.assertTrue(bountyAbilityToAssign instanceof BountyKeywordInstance); + bountyAbilityToAssign.setAbilityProps(properties); } protected addCoordinateAbility(properties: IAbilityPropsWithType): void { @@ -453,20 +452,13 @@ export function WithUnitProperties(Bas event.addCleanupHandler(() => this.unregisterWhenCapturedKeywords()); } - private registerBountyKeywords(bountyKeywords: KeywordWithAbilityDefinition[]): TriggeredAbility[] { + private registerBountyKeywords(bountyKeywords: BountyKeywordInstance[]): TriggeredAbility[] { const registeredAbilities: TriggeredAbility[] = []; for (const bountyKeyword of bountyKeywords) { const abilityProps = bountyKeyword.abilityProps; - Contract.assertTrue(abilityProps.type === AbilityType.Triggered, `Bounty abilities must be triggered abilities but instead found ${abilityProps.type}`); - - const { type, ...abilityPropsWithoutType } = abilityProps; - - const bountyAbility = this.createTriggeredAbility({ - ...this.buildGeneralAbilityProps('keyword_bounty'), - ...abilityPropsWithoutType, - }); + const bountyAbility = new BountyAbility(this.game, this, { ...this.buildGeneralAbilityProps('triggered'), ...abilityProps }); bountyAbility.registerEvents(); registeredAbilities.push(bountyAbility); @@ -477,7 +469,7 @@ export function WithUnitProperties(Bas private getBountyAbilities() { return this.getKeywords().filter((keyword) => keyword.name === KeywordName.Bounty) - .map((keyword) => keyword as KeywordWithAbilityDefinition); + .map((keyword) => keyword as BountyKeywordInstance); } private getCoordinateAbilities() { diff --git a/server/game/core/chat/ChatCommands.js b/server/game/core/chat/ChatCommands.js deleted file mode 100644 index 072f583ca..000000000 --- a/server/game/core/chat/ChatCommands.js +++ /dev/null @@ -1,172 +0,0 @@ -const GameSystems = require('../../gameSystems/GameSystemLibrary'); -const { RelativePlayer, DeckZoneDestination, WildcardZoneName } = require('../Constants.js'); - -class ChatCommands { - constructor(game) { - this.game = game; - this.commands = { - '/draw': this.draw, - // '/discard': this.discard, - '/reveal': this.reveal, - '/move-to-bottom-deck': this.moveCardToDeckBottom, - '/stop-clocks': this.stopClocks, - '/start-clocks': this.startClocks, - '/modify-clock': this.modifyClock, - '/roll': this.random, - '/disconnectme': this.disconnectMe, - '/manual': this.manual - }; - } - - executeCommand(player, command, args) { - if (!player || !this.commands[command]) { - return false; - } - - return this.commands[command].call(this, player, args) !== false; - } - - startClocks(player) { - this.game.addMessage('{0} restarts the timers', player); - this.game.getPlayers().forEach((player) => player.clock.manuallyResume()); - } - - stopClocks(player) { - this.game.addMessage('{0} stops the timers', player); - this.game.getPlayers().forEach((player) => player.clock.manuallyPause()); - } - - modifyClock(player, args) { - let num = this.getNumberOrDefault(args[1], 60); - this.game.addMessage('{0} adds {1} seconds to their clock', player, num); - player.clock.modify(num); - } - - random(player, args) { - let num = this.getNumberOrDefault(args[1], 4); - if (num > 1) { - this.game.addMessage('{0} rolls a d{1}: {2}', player, num, Math.floor(Math.random() * num) + 1); - } - } - - draw(player, args) { - var num = this.getNumberOrDefault(args[1], 1); - - this.game.addMessage('{0} uses the /draw command to draw {1} cards to their hand', player, num); - - player.drawCardsToHand(num); - } - - // discard(player, args) { - // var num = this.getNumberOrDefault(args[1], 1); - - // this.game.addMessage('{0} uses the /discard command to discard {1} card{2} at random', player, num, num > 1 ? 's' : ''); - - // GameSystems.discardAtRandom({ amount: num }).resolve(player, this.game.getFrameworkContext()); - // } - - moveCardToDeckBottom(player) { - this.game.promptForSelect(player, { - activePromptTitle: 'Select a card to send to the bottom of one of their decks', - waitingPromptTitle: 'Waiting for opponent to send a card to the bottom of one of their decks', - zone: WildcardZoneName.Any, - controller: RelativePlayer.Self, - onSelect: (p, card) => { - const cardInitialZone = card.zone; - GameSystems.moveCard({ target: card, destination: DeckZoneDestination.DeckBottom }).resolve(player, this.game.getFrameworkContext()); - this.game.addMessage('{0} uses a command to move {1} from their {2} to the bottom of their {3}.', player, card, cardInitialZone); - return true; - } - }); - } - - // setToken(player, args) { - // var token = args[1]; - // var num = this.getNumberOrDefault(args[2], 1); - - // if(!this.isValidToken(token)) { - // return false; - // } - - // this.game.promptForSelect(player, { - // activePromptTitle: 'Select a card', - // waitingPromptTitle: 'Waiting for opponent to set token', - // cardCondition: card => (EnumHelpers.isArena(card.zoneName) || card.zoneName === 'plot') && card.controller === player, - // onSelect: (p, card) => { - // var numTokens = card.tokens[token] || 0; - - // card.addToken(token, num - numTokens); - // this.game.addMessage('{0} uses the /token command to set the {1} token count of {2} to {3}', p, token, card, num - numTokens); - - // return true; - // } - // }); - // } - - reveal(player) { - this.game.promptForSelect(player, { - activePromptTitle: 'Select a card to reveal', - waitingPromptTitle: 'Waiting for opponent to reveal a card', - zone: WildcardZoneName.Any, - controller: RelativePlayer.Self, - cardCondition: (card) => card.isFacedown(), - onSelect: (player, card) => { - GameSystems.reveal({ target: card }).resolve(player, this.game.getFrameworkContext()); - this.game.addMessage('{0} reveals {1}', player, card); - return true; - } - }); - } - - // TODO: add some SWU stuff in here like add shields - - disconnectMe(player) { - player.socket.disconnect(); - } - - manual(player) { - if (this.game.manualMode) { - this.game.manualMode = false; - this.game.addMessage('{0} switches manual mode off', player); - } else { - this.game.manualMode = true; - this.game.addMessage('{0} switches manual mode on', player); - } - } - - getNumberOrDefault(string, defaultNumber) { - var num = parseInt(string); - - if (isNaN(num)) { - num = defaultNumber; - } - - if (num < 0) { - num = defaultNumber; - } - - return num; - } - - isValidIcon(icon) { - if (!icon) { - return false; - } - - var lowerIcon = icon.toLowerCase(); - - return lowerIcon === 'military' || lowerIcon === 'intrigue' || lowerIcon === 'power'; - } - - // isValidToken(token) { - // if(!token) { - // return false; - // } - - // var lowerToken = token.toLowerCase(); - - // return _.contains(this.tokens, lowerToken); - // } -} - -module.exports = ChatCommands; diff --git a/server/game/core/gameSteps/AbilityResolver.js b/server/game/core/gameSteps/AbilityResolver.js index abbb9dc8f..009c44156 100644 --- a/server/game/core/gameSteps/AbilityResolver.js +++ b/server/game/core/gameSteps/AbilityResolver.js @@ -245,16 +245,18 @@ class AbilityResolver extends BaseStepWithPipeline { } this.context.stage = Stage.Target; - if (this.context.ability.hasTargets() && !this.context.ability.hasSomeLegalTarget(this.context)) { + const ability = this.context.ability; + + if (this.context.ability.hasTargets() && !ability.hasSomeLegalTarget(this.context) && !ability.canResolveWithoutLegalTargets) { // Ability cannot resolve, so display a message and cancel it this.game.addMessage('{0} attempted to use {1}, but there are insufficient legal targets', this.context.player, this.context.source); this.cancelled = true; } else if (this.targetResults.delayTargeting) { // Targeting was delayed due to an opponent needing to choose targets (which shouldn't happen until costs have been paid), so continue - this.targetResults = this.context.ability.resolveRemainingTargets(this.context, this.targetResults.delayTargeting, this.passAbilityHandler); - } else if (this.targetResults.payCostsFirst || !this.context.ability.checkAllTargets(this.context)) { + this.targetResults = ability.resolveRemainingTargets(this.context, this.targetResults.delayTargeting, this.passAbilityHandler); + } else if (this.targetResults.payCostsFirst || !ability.checkAllTargets(this.context)) { // Targeting was stopped by the player choosing to pay costs first, or one of the chosen targets is no longer legal. Retarget from scratch - this.targetResults = this.context.ability.resolveTargets(this.context, this.passAbilityHandler); + this.targetResults = ability.resolveTargets(this.context, this.passAbilityHandler); } } diff --git a/server/game/gameSystems/CollectBountySystem.ts b/server/game/gameSystems/CollectBountySystem.ts new file mode 100644 index 000000000..5ec61d7de --- /dev/null +++ b/server/game/gameSystems/CollectBountySystem.ts @@ -0,0 +1,43 @@ +import type { AbilityContext } from '../core/ability/AbilityContext'; +import type { Card } from '../core/card/Card'; +import { EventName, GameStateChangeRequired, WildcardCardType } from '../core/Constants'; +import { CardTargetSystem, type ICardTargetSystemProperties } from '../core/gameSystem/CardTargetSystem'; +import type { ITriggeredAbilityBaseProps } from '../Interfaces'; +import { BountyAbility } from '../abilities/keyword/BountyAbility'; +import type { UnitCard } from '../core/card/CardTypes'; + +export interface ICollectBountyProperties extends ICardTargetSystemProperties { + bountyProperties: ITriggeredAbilityBaseProps; + bountySource?: UnitCard; +} + +export class CollectBountySystem extends CardTargetSystem { + public override readonly name = 'collect bounty'; + public override readonly eventName = EventName.OnBountyCollected; + protected override readonly targetTypeFilter = [WildcardCardType.Unit]; + + public eventHandler(event): void { + // force optional to false since the player has already chosen to resolve the bounty + const properties = { + ...event.bountyProperties, + optional: false + }; + + const ability = new BountyAbility(event.context.game, event.bountySource, properties); + + event.context.game.resolveAbility(ability.createContext(event.context.player, event)); + } + + // since the actual effect of the bounty is resolved in a sub-window, we don't check its effects here + public override canAffect(card: Card, context: TContext, additionalProperties: any = {}, mustChangeGameState = GameStateChangeRequired.None): boolean { + return card === context.source; + } + + protected override addPropertiesToEvent(event, card: Card, context: TContext, additionalProperties): void { + super.addPropertiesToEvent(event, card, context, additionalProperties); + + const { bountyProperties, bountySource } = this.generatePropertiesFromContext(context, additionalProperties); + event.bountyProperties = bountyProperties; + event.bountySource = bountySource ?? card; + } +} diff --git a/server/game/gameSystems/GameSystemLibrary.ts b/server/game/gameSystems/GameSystemLibrary.ts index 6e9358206..7050bf971 100644 --- a/server/game/gameSystems/GameSystemLibrary.ts +++ b/server/game/gameSystems/GameSystemLibrary.ts @@ -17,6 +17,8 @@ import { CardPhaseLastingEffectSystem } from './CardPhaseLastingEffectSystem'; import type { ICardTargetSystemProperties } from '../core/gameSystem/CardTargetSystem'; import type { IPlayModalCardProperties } from './ChooseModalEffectsSystem'; import { ChooseModalEffectsSystem } from './ChooseModalEffectsSystem'; +import type { ICollectBountyProperties } from './CollectBountySystem'; +import { CollectBountySystem } from './CollectBountySystem'; import type { IConditionalSystemProperties } from './ConditionalSystem'; import { ConditionalSystem } from './ConditionalSystem'; import type { ICreateBattleDroidProperties } from './CreateBattleDroidSystem'; @@ -126,6 +128,9 @@ export function capture(proper export function cardLastingEffect(propertyFactory: PropsFactory) { return new CardLastingEffectSystem(propertyFactory); } +export function collectBounty(propertyFactory: PropsFactory) { + return new CollectBountySystem(propertyFactory); +} export function createBattleDroid(propertyFactory: PropsFactory = {}) { return new CreateBattleDroidSystem(propertyFactory); } diff --git a/test/server/cards/02_SHD/leaders/JabbaTheHuttHisHighExaltedness.spec.ts b/test/server/cards/02_SHD/leaders/JabbaTheHuttHisHighExaltedness.spec.ts index 4f2654cff..3ab2bb5dc 100644 --- a/test/server/cards/02_SHD/leaders/JabbaTheHuttHisHighExaltedness.spec.ts +++ b/test/server/cards/02_SHD/leaders/JabbaTheHuttHisHighExaltedness.spec.ts @@ -1,7 +1,7 @@ describe('Jabba the Hutt, His High Exaltedness', function () { integration(function (contextRef) { describe('Jabba the Hutt\'s leader undeployed ability', function () { - const bountyPrompt = 'Bounty: The next unit you play this phase costs 1 resource less'; + const bountyPrompt = 'Collect Bounty: The next unit you play this phase costs 1 resource less'; beforeEach(function () { contextRef.setupTest({ @@ -131,7 +131,7 @@ describe('Jabba the Hutt, His High Exaltedness', function () { describe('Jabba the Hutt\'s leader deployed ability', function () { const abilityPrompt = 'Choose a unit. For this phase, it gains: "Bounty — The next unit you play this phase costs 2 resources less."'; - const bountyPrompt = 'Bounty: The next unit you play this phase costs 2 resources less'; + const bountyPrompt = 'Collect Bounty: The next unit you play this phase costs 2 resources less'; beforeEach(function () { contextRef.setupTest({ diff --git a/test/server/cards/02_SHD/units/CartelTurncoat.spec.ts b/test/server/cards/02_SHD/units/CartelTurncoat.spec.ts index a912fc9d4..ebed0ab17 100644 --- a/test/server/cards/02_SHD/units/CartelTurncoat.spec.ts +++ b/test/server/cards/02_SHD/units/CartelTurncoat.spec.ts @@ -17,8 +17,8 @@ describe('Cartel Turncoat', function() { context.player1.clickCard(context.cartelTurncoat); context.player1.clickCard(context.razorCrest); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Draw a card'); - context.player2.clickPrompt('Bounty: Draw a card'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player2.clickPrompt('Collect Bounty: Draw a card'); expect(context.player1.handSize).toBe(0); expect(context.player2.handSize).toBe(1); diff --git a/test/server/cards/02_SHD/units/CloneDeserter.spec.ts b/test/server/cards/02_SHD/units/CloneDeserter.spec.ts index 69934b1d0..8bf7deb2b 100644 --- a/test/server/cards/02_SHD/units/CloneDeserter.spec.ts +++ b/test/server/cards/02_SHD/units/CloneDeserter.spec.ts @@ -20,8 +20,8 @@ describe('Clone Deserter', function() { context.player1.clickCard(context.cloneDeserter); context.player1.clickCard(context.wampa); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Draw a card'); - context.player2.clickPrompt('Bounty: Draw a card'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player2.clickPrompt('Collect Bounty: Draw a card'); expect(context.player1.handSize).toBe(0); expect(context.player2.handSize).toBe(1); diff --git a/test/server/cards/02_SHD/units/GuavianAntagonizer.spec.ts b/test/server/cards/02_SHD/units/GuavianAntagonizer.spec.ts index 699c228bc..4a74d8f9f 100644 --- a/test/server/cards/02_SHD/units/GuavianAntagonizer.spec.ts +++ b/test/server/cards/02_SHD/units/GuavianAntagonizer.spec.ts @@ -17,8 +17,8 @@ describe('Guavian Antagonizer', function() { context.player1.clickCard(context.guavianAntagonizer); context.player1.clickCard(context.wampa); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Draw a card'); - context.player2.clickPrompt('Bounty: Draw a card'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player2.clickPrompt('Collect Bounty: Draw a card'); expect(context.player1.handSize).toBe(0); expect(context.player2.handSize).toBe(1); diff --git a/test/server/cards/02_SHD/units/HylobonEnforcer.spec.ts b/test/server/cards/02_SHD/units/HylobonEnforcer.spec.ts index 059d94ff1..10f2028da 100644 --- a/test/server/cards/02_SHD/units/HylobonEnforcer.spec.ts +++ b/test/server/cards/02_SHD/units/HylobonEnforcer.spec.ts @@ -17,8 +17,8 @@ describe('Hylobon Enforcer', function() { context.player1.clickCard(context.hylobonEnforcer); context.player1.clickCard(context.wampa); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Draw a card'); - context.player2.clickPrompt('Bounty: Draw a card'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player2.clickPrompt('Collect Bounty: Draw a card'); expect(context.player1.handSize).toBe(0); expect(context.player2.handSize).toBe(1); @@ -42,8 +42,8 @@ describe('Hylobon Enforcer', function() { context.player1.clickCard(context.hylobonEnforcer); context.player1.clickCard(context.wampa); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Draw a card'); - context.player2.clickPrompt('Bounty: Draw a card'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player2.clickPrompt('Collect Bounty: Draw a card'); expect(context.player1.handSize).toBe(0); expect(context.player2.handSize).toBe(0); diff --git a/test/server/cards/02_SHD/units/OutlawCorona.spec.ts b/test/server/cards/02_SHD/units/OutlawCorona.spec.ts index 2d36e120d..eead80ac7 100644 --- a/test/server/cards/02_SHD/units/OutlawCorona.spec.ts +++ b/test/server/cards/02_SHD/units/OutlawCorona.spec.ts @@ -21,8 +21,8 @@ describe('Outlaw Corona', function() { context.player1.clickCard(context.outlawCorona); context.player1.clickCard(context.fettsFirespray); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Put the top card of your deck into play as a resource.'); - context.player2.clickPrompt('Bounty: Put the top card of your deck into play as a resource.'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Put the top card of your deck into play as a resource.'); + context.player2.clickPrompt('Collect Bounty: Put the top card of your deck into play as a resource.'); expect(context.player2.resources.length).toBe(startingResources + 1); expect(context.player2).toBeActivePlayer(); diff --git a/test/server/cards/02_SHD/units/TheClientDictatedByDiscretion.spec.ts b/test/server/cards/02_SHD/units/TheClientDictatedByDiscretion.spec.ts index 18a29a705..ce293af32 100644 --- a/test/server/cards/02_SHD/units/TheClientDictatedByDiscretion.spec.ts +++ b/test/server/cards/02_SHD/units/TheClientDictatedByDiscretion.spec.ts @@ -35,7 +35,7 @@ describe('The Client, Dictated by Discretion', function() { context.player1.clickCard(context.wampa); // heal 5 from kestro city - context.player1.clickPrompt('Bounty: Heal 5 damage from a base'); + context.player1.clickPrompt('Collect Bounty: Heal 5 damage from a base'); expect(context.player1).toBeAbleToSelectExactly([context.p1Base, context.p2Base]); expect(context.player1).toHavePassAbilityButton(); diff --git a/test/server/cards/02_SHD/units/UnlicensedHeadhunter.spec.ts b/test/server/cards/02_SHD/units/UnlicensedHeadhunter.spec.ts index 0edc8abe5..09b56122b 100644 --- a/test/server/cards/02_SHD/units/UnlicensedHeadhunter.spec.ts +++ b/test/server/cards/02_SHD/units/UnlicensedHeadhunter.spec.ts @@ -19,8 +19,8 @@ describe('Unlicensed Headhunter', function() { context.player2.clickCard(context.survivorsGauntlet); context.player2.clickCard(context.unlicensedHeadhunter); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Heal 5 damage from your base'); - context.player2.clickPrompt('Bounty: Heal 5 damage from your base'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Heal 5 damage from your base'); + context.player2.clickPrompt('Collect Bounty: Heal 5 damage from your base'); expect(context.p2Base.damage).toBe(1); }); @@ -41,8 +41,8 @@ describe('Unlicensed Headhunter', function() { context.player1.clickCard(context.unlicensedHeadhunter); context.player1.clickCard(context.survivorsGauntlet); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Heal 5 damage from your base'); - context.player2.clickPrompt('Bounty: Heal 5 damage from your base'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Heal 5 damage from your base'); + context.player2.clickPrompt('Collect Bounty: Heal 5 damage from your base'); expect(context.p2Base.damage).toBe(1); }); diff --git a/test/server/cards/02_SHD/upgrades/BountyHuntersQuarry.spec.ts b/test/server/cards/02_SHD/upgrades/BountyHuntersQuarry.spec.ts index 253404019..db1d77e38 100644 --- a/test/server/cards/02_SHD/upgrades/BountyHuntersQuarry.spec.ts +++ b/test/server/cards/02_SHD/upgrades/BountyHuntersQuarry.spec.ts @@ -1,7 +1,7 @@ describe('Bounty hunter\'s quarry', function () { integration(function (contextRef) { describe('Bounty hunter\'s quarry bounty ability', function () { - const prompt = 'Bounty: Search the top 5 cards of your deck, or 10 cards instead if this unit is unique, for a unit that costs 3 or less and play it for free.'; + const prompt = 'Collect Bounty: Search the top 5 cards of your deck, or 10 cards instead if this unit is unique, for a unit that costs 3 or less and play it for free.'; it('should prompt to choose a unit with a cost of 3 or less from the top 5 cards or top 10 cards (if unit is unique) and play it for free', function () { contextRef.setupTest({ diff --git a/test/server/cards/02_SHD/upgrades/DeathMark.spec.ts b/test/server/cards/02_SHD/upgrades/DeathMark.spec.ts index 5860ab19a..c9ebe94ba 100644 --- a/test/server/cards/02_SHD/upgrades/DeathMark.spec.ts +++ b/test/server/cards/02_SHD/upgrades/DeathMark.spec.ts @@ -17,7 +17,7 @@ describe('Death Mark', function() { context.player1.clickCard(context.vanquish); context.player1.clickCard(context.restoredArc170); - const prompt = 'Bounty: Draw 2 cards'; + const prompt = 'Collect Bounty: Draw 2 cards'; expect(context.player1).toHavePassAbilityPrompt(prompt); context.player1.clickPrompt(prompt); diff --git a/test/server/cards/02_SHD/upgrades/EnticingReward.spec.ts b/test/server/cards/02_SHD/upgrades/EnticingReward.spec.ts index 1891f4299..5583e114d 100644 --- a/test/server/cards/02_SHD/upgrades/EnticingReward.spec.ts +++ b/test/server/cards/02_SHD/upgrades/EnticingReward.spec.ts @@ -1,7 +1,7 @@ describe('Enticing Reward', function () { integration(function (contextRef) { describe('Enticing reward bounty\'s ability', function () { - const prompt = 'Bounty: Search the top 10 cards of your deck for 2 non-unit cards, reveal them, and draw them.'; + const prompt = 'Collect Bounty: Search the top 10 cards of your deck for 2 non-unit cards, reveal them, and draw them.'; it('should prompt to choose up to 2 non-units from the top 10 cards, reveal them, draw them, and move the rest to the bottom of the deck and discard a card from hand because attached unit is not unique', function () { contextRef.setupTest({ diff --git a/test/server/cards/02_SHD/upgrades/PriceOnYourHead.spec.ts b/test/server/cards/02_SHD/upgrades/PriceOnYourHead.spec.ts index 7feb5723f..d50d18967 100644 --- a/test/server/cards/02_SHD/upgrades/PriceOnYourHead.spec.ts +++ b/test/server/cards/02_SHD/upgrades/PriceOnYourHead.spec.ts @@ -13,7 +13,7 @@ describe('Price on your Head', function() { }); const { context } = contextRef; - const prompt = 'Bounty: Put the top card of your deck into play as a resource'; + const prompt = 'Collect Bounty: Put the top card of your deck into play as a resource'; const startingResources = context.player2.resources.length; @@ -43,6 +43,11 @@ describe('Price on your Head', function() { context.player1.clickCard(context.greenSquadronAwing); context.player1.clickCard(context.restoredArc170); + + // bounty trigger still appears even though there's no effect, b/c the player still needs to decide whether to "collect the bounty" + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Put the top card of your deck into play as a resource'); + context.player1.clickPrompt('Collect Bounty: Put the top card of your deck into play as a resource'); + expect(context.player2).toBeActivePlayer(); }); }); diff --git a/test/server/cards/02_SHD/upgrades/RichReward.spec.ts b/test/server/cards/02_SHD/upgrades/RichReward.spec.ts index fadd746cc..72c83ba0f 100644 --- a/test/server/cards/02_SHD/upgrades/RichReward.spec.ts +++ b/test/server/cards/02_SHD/upgrades/RichReward.spec.ts @@ -83,6 +83,13 @@ describe('Rich Reward', function() { context.player1.clickCard(context.battlefieldMarine); + // bounty trigger still appears even though there's no effect, b/c the player still needs to decide whether to "collect the bounty" + // Dark Trooper ability happens in same window + expect(context.player1).toHaveExactPromptButtons(['You', 'Opponent']); + context.player1.clickPrompt('You'); + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Give an Experience token to each of up to 2 units'); + context.player1.clickPrompt('Collect Bounty: Give an Experience token to each of up to 2 units'); + expect(context.player2).toBeActivePlayer(); }); }); diff --git a/test/server/cards/03_TWI/leaders/BosskHuntingHisPrey.spec.ts b/test/server/cards/03_TWI/leaders/BosskHuntingHisPrey.spec.ts new file mode 100644 index 000000000..14c97c55d --- /dev/null +++ b/test/server/cards/03_TWI/leaders/BosskHuntingHisPrey.spec.ts @@ -0,0 +1,467 @@ +describe('Bossk, Hunting his Prey', function () { + integration(function (contextRef) { + it('Bossk\'s leader undeployed ability should deal 1 damage to a unit with a bounty and optionally give +1/+0', function () { + contextRef.setupTest({ + phase: 'action', + player1: { + leader: 'bossk#hunting-his-prey', + spaceArena: ['cartel-turncoat'], + resources: 4 + }, + player2: { + groundArena: [{ card: 'clone-trooper', upgrades: ['public-enemy'] }, 'wampa'] + } + }); + + const { context } = contextRef; + + // CASE 1: deal 1 damage and resolve optional +1/+0 + context.player1.clickCard(context.bossk); + expect(context.player1).toBeAbleToSelectExactly([context.cartelTurncoat, context.cloneTrooper]); + expect(context.player1).not.toHavePassAbilityButton(); + + context.player1.clickCard(context.cloneTrooper); + expect(context.cloneTrooper.damage).toBe(1); + + // resolve optional +1/+0 + expect(context.player1).toHavePassAbilityPrompt('Give it +1/+0 for this phase'); + context.player1.clickPrompt('Give it +1/+0 for this phase'); + expect(context.cloneTrooper.getPower()).toBe(3); + expect(context.cloneTrooper.getHp()).toBe(2); + + expect(context.bossk.exhausted).toBeTrue(); + expect(context.player2).toBeActivePlayer(); + + context.moveToNextActionPhase(); + + // check that +power effect has fallen off + expect(context.cloneTrooper.getPower()).toBe(2); + expect(context.cloneTrooper.getHp()).toBe(2); + + // CASE 2: deal 1 damage and don't resolve +1/+0 + context.player1.clickCard(context.bossk); + expect(context.player1).toBeAbleToSelectExactly([context.cartelTurncoat, context.cloneTrooper]); + expect(context.player1).not.toHavePassAbilityButton(); + + context.player1.clickCard(context.cartelTurncoat); + expect(context.cartelTurncoat.damage).toBe(1); + expect(context.player1).toHavePassAbilityPrompt('Give it +1/+0 for this phase'); + context.player1.clickPrompt('Pass'); + + expect(context.bossk.exhausted).toBeTrue(); + expect(context.player2).toBeActivePlayer(); + + context.moveToNextActionPhase(); + + // CASE 3: deal 1 damage and defeat bounty unit + context.player1.clickCard(context.bossk); + expect(context.player1).toBeAbleToSelectExactly([context.cartelTurncoat, context.cloneTrooper]); + expect(context.player1).not.toHavePassAbilityButton(); + + context.player1.clickCard(context.cloneTrooper); + expect(context.cloneTrooper).toBeInZone('outsideTheGame'); + expect(context.player1).toHavePassAbilityPrompt('Give it +1/+0 for this phase'); + context.player1.clickPrompt('Pass'); + + expect(context.player1).toBeAbleToSelectExactly([context.cartelTurncoat, context.wampa]); + context.player1.clickCard(context.wampa); + expect(context.wampa).toHaveExactUpgradeNames(['shield']); + + expect(context.bossk.exhausted).toBeTrue(); + expect(context.player2).toBeActivePlayer(); + }); + + describe('Bossk\'s leader deployed ability', function () { + it('should be able to collect a Bounty a second time (for "simple" bounty effects)', function () { + contextRef.setupTest({ + phase: 'action', + player1: { + leader: { card: 'bossk#hunting-his-prey', deployed: true }, + groundArena: ['clone-deserter'] + }, + player2: { + groundArena: [ + 'hylobon-enforcer', + 'guavian-antagonizer', + { card: 'jyn-erso#stardust', upgrades: ['guild-target'] }, + { card: 'clone-trooper', upgrades: ['death-mark'] }, + 'wanted-insurgents', + { card: 'trandoshan-hunters', upgrades: ['top-target'] } + ], + hand: ['vanquish'] + }, + }); + + const { context } = contextRef; + + function resetPhase() { + context.setDamage(context.bossk, 0); + context.moveToNextActionPhase(); + } + + function resetAttack() { + context.bossk.exhausted = false; + context.setDamage(context.bossk, 0); + } + + // CASE 1: simple ability with no target (draw a card) + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.hylobonEnforcer); + + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player1.clickPrompt('Collect Bounty: Draw a card'); + expect(context.player1.handSize).toBe(1); + expect(context.player2.handSize).toBe(1); + + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Collect the Bounty again'); + expect(context.player1.handSize).toBe(2); + expect(context.player2.handSize).toBe(1); + + // CASE 2: confirm the per-round limit + resetAttack(); + + context.player2.clickCard(context.guavianAntagonizer); + context.player2.clickCard(context.bossk); + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player1.clickPrompt('Collect Bounty: Draw a card'); + expect(context.player1.handSize).toBe(3); + expect(context.player2.handSize).toBe(1); + + expect(context.player1).toBeActivePlayer(); + + resetPhase(); + + // CASE 3: check that opponent resolving a Bounty on our unit does not trigger Bossk ability + context.player1.passAction(); + + const p2HandSizePhase2 = context.player2.handSize; + + context.player2.clickCard(context.vanquish); + context.player2.clickCard(context.cloneDeserter); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player2.clickPrompt('Collect Bounty: Draw a card'); + expect(context.player2.handSize).toBe(p2HandSizePhase2); // played Vanquish then drew a card + expect(context.player1).toBeActivePlayer(); + + // CASE 4: targeted ability with a condition (uniqueness) + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.jynErso); + + expect(context.player1).toBeAbleToSelectExactly([context.p1Base, context.p2Base]); + expect(context.player1).toHavePassAbilityButton(); + context.player1.clickCard(context.p2Base); + expect(context.p2Base.damage).toBe(3); + + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Collect the Bounty again'); + expect(context.player1).toBeAbleToSelectExactly([context.p1Base, context.p2Base]); + expect(context.player1).not.toHavePassAbilityButton(); + context.player1.clickCard(context.p2Base); + expect(context.p2Base.damage).toBe(6); + + resetPhase(); + + context.player1.exhaustResources(3); + + // CASE 5: not collecting the Bounty does not trigger Bossk (bounty with no target resolver) + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.cloneTrooper); + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Draw 2 cards'); + context.player1.clickPrompt('Pass'); + expect(context.player2).toBeActivePlayer(); + + resetAttack(); + + // CASE 6: not collecting the Bounty does not trigger Bossk (bounty with target resolver) + context.player2.clickCard(context.wantedInsurgents); + context.player2.clickCard(context.bossk); + expect(context.player1).toBeAbleToSelectExactly([context.bossk, context.trandoshanHunters]); + context.player1.clickPrompt('Pass ability'); + expect(context.player1).toBeActivePlayer(); + + resetPhase(); + + // CASE 7: Bossk dies in the attack, his ability does not trigger + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.trandoshanHunters); + + expect(context.player1).toBeAbleToSelectExactly([context.p1Base, context.p2Base]); + context.player1.clickCard(context.p2Base); + expect(context.p2Base.damage).toBe(2); // 6 damage - 4 b/c unit is not unique + + expect(context.player2).toBeActivePlayer(); + }); + + it('should be able to collect Jabba\'s play discount Bounty a second time', function () { + contextRef.setupTest({ + phase: 'action', + player1: { + leader: { card: 'bossk#hunting-his-prey', deployed: true }, + hand: ['super-battle-droid'] + }, + player2: { + leader: 'jabba-the-hutt#his-high-exaltedness', + groundArena: ['clone-trooper'], + hasInitiative: true, + resources: 6 + }, + }); + + const { context } = contextRef; + + // apply Jabba bounty to player2's own unit (Clone Trooper) + context.player2.clickCard(context.jabbaTheHutt); + context.player2.clickCard(context.cloneTrooper); + + // trigger bounty with Bossk attack + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.cloneTrooper); + + // trigger the bounty twice + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: The next unit you play this phase costs 1 resource less'); + context.player1.clickPrompt('Collect Bounty: The next unit you play this phase costs 1 resource less'); + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Collect the Bounty again'); + + // play a 3-cost unit with a 2-cost discount + context.player2.passAction(); + context.player1.clickCard(context.superBattleDroid); + expect(context.player1.exhaustedResourceCount).toBe(1); + }); + }); + + describe('Bossk\'s leader deployed ability,', function () { + beforeEach(function () { + contextRef.setupTest({ + phase: 'action', + player1: { + leader: { card: 'bossk#hunting-his-prey', deployed: true } + }, + player2: { + groundArena: [{ card: 'clone-deserter', upgrades: ['guild-target'] }] + }, + }); + }); + + it('when a unit has two bounties on it, can double trigger the first bounty and will then not be available for the second', function() { + const { context } = contextRef; + + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.cloneDeserter); + + expect(context.player1).toHaveExactPromptButtons([ + 'Collect Bounty: Draw a card', + 'Collect Bounty: Deal 2 damage to a base. If the Bounty unit is unique, deal 3 damage instead' + ]); + + context.player1.clickPrompt('Collect Bounty: Deal 2 damage to a base. If the Bounty unit is unique, deal 3 damage instead'); + expect(context.player1).toBeAbleToSelectExactly([context.p1Base, context.p2Base]); + context.player1.clickCard(context.p2Base); + expect(context.p2Base.damage).toBe(2); + + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Collect the Bounty again'); + expect(context.player1).toBeAbleToSelectExactly([context.p1Base, context.p2Base]); + context.player1.clickCard(context.p2Base); + expect(context.p2Base.damage).toBe(4); + + // resolve the Clone Deserter bounty, Bossk ability is already used and can't trigger + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player1.clickPrompt('Collect Bounty: Draw a card'); + expect(context.player1.handSize).toBe(1); + + expect(context.player2).toBeActivePlayer(); + }); + + it('when a unit has two bounties on it, can pass on double triggering the first bounty and will then double trigger the second', function() { + const { context } = contextRef; + + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.cloneDeserter); + + expect(context.player1).toHaveExactPromptButtons([ + 'Collect Bounty: Draw a card', + 'Collect Bounty: Deal 2 damage to a base. If the Bounty unit is unique, deal 3 damage instead' + ]); + + context.player1.clickPrompt('Collect Bounty: Deal 2 damage to a base. If the Bounty unit is unique, deal 3 damage instead'); + expect(context.player1).toBeAbleToSelectExactly([context.p1Base, context.p2Base]); + context.player1.clickCard(context.p2Base); + expect(context.p2Base.damage).toBe(2); + + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Pass'); + + // resolve the Clone Deserter bounty, Bossk ability is already used and can't trigger + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player1.clickPrompt('Collect Bounty: Draw a card'); + expect(context.player1.handSize).toBe(1); + + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Collect the Bounty again'); + expect(context.player1.handSize).toBe(2); + + expect(context.player2).toBeActivePlayer(); + }); + }); + + describe('Bossk\'s leader deployed ability', function () { + beforeEach(function () { + contextRef.setupTest({ + phase: 'action', + player1: { + leader: { card: 'bossk#hunting-his-prey', deployed: true }, + deck: ['atst', 'waylay'], + resources: 4 + }, + player2: { + groundArena: [ + { card: 'clone-trooper', upgrades: ['price-on-your-head'] }, + { card: 'battle-droid', upgrades: ['guild-target'] }, + ] + }, + }); + }); + + it('should be able to collect a "resource the top card of your deck" ability a second time', function() { + const { context } = contextRef; + + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.cloneTrooper); + + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Put the top card of your deck into play as a resource'); + context.player1.clickPrompt('Collect Bounty: Put the top card of your deck into play as a resource'); + expect(context.player1.resources.length).toBe(5); + expect(context.atst).toBeInZone('resource'); + + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Collect the Bounty again'); + expect(context.player1.resources.length).toBe(6); + expect(context.waylay).toBeInZone('resource'); + }); + + it('should be able to collect a "resource the top card of your deck" ability a second time even if the deck is empty', function() { + const { context } = contextRef; + + context.player1.setDeck([]); + + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.cloneTrooper); + + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Put the top card of your deck into play as a resource'); + context.player1.clickPrompt('Collect Bounty: Put the top card of your deck into play as a resource'); + expect(context.player1.resources.length).toBe(4); + + // Bossk ability triggers since we chose to resolve the Bounty (even though the resolution didn't change game state) + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Collect the Bounty again'); + expect(context.player1.resources.length).toBe(4); + + // trigger a second Bounty to confirm that Bossk's ability limit did trigger and is used up for the phase + context.player2.clickCard(context.battleDroid); + context.player2.clickCard(context.bossk); + expect(context.player1).toBeAbleToSelectExactly([context.p1Base, context.p2Base]); + context.player1.clickCard(context.p2Base); + expect(context.p2Base.damage).toBe(2); + + expect(context.player1).toBeActivePlayer(); + }); + }); + + describe('Bossk\'s leader deployed ability', function () { + beforeEach(function () { + contextRef.setupTest({ + phase: 'action', + player1: { + leader: { card: 'bossk#hunting-his-prey', deployed: true }, + groundArena: ['wampa'], + deck: ['sabine-wren#explosives-artist', 'battlefield-marine', 'waylay', 'protector', 'snowtrooper-lieutenant', 'inferno-four#unforgetting'], + }, + player2: { + groundArena: [{ card: 'clone-trooper', upgrades: ['bounty-hunters-quarry'] }, 'clone-deserter'] + }, + }); + }); + + it('should be able to collect a "search for a card" Bounty a second time which activates a "when played" ability', function() { + const { context } = contextRef; + const prompt = 'Collect Bounty: Search the top 5 cards of your deck, or 10 cards instead if this unit is unique, for a unit that costs 3 or less and play it for free.'; + + // first Bounty trigger + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.cloneTrooper); + expect(context.player1).toHavePassAbilityPrompt(prompt); + context.player1.clickPrompt(prompt); + expect(context.player1).toHaveEnabledPromptButtons([context.battlefieldMarine.title, context.snowtrooperLieutenant.title, context.sabineWren.title, 'Take nothing']); + expect(context.player1).toHaveDisabledPromptButtons([context.waylay, context.protector.title]); + + context.player1.clickPrompt(context.battlefieldMarine.title); + expect(context.battlefieldMarine).toBeInZone('groundArena'); + expect(context.player1.exhaustedResourceCount).toBe(0); + expect([context.sabineWren, context.waylay, context.snowtrooperLieutenant, context.protector]).toAllBeInBottomOfDeck(context.player1, 4); + + // second Bounty trigger, play a unit that has a "when played" which triggers an attack + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Collect the Bounty again'); + expect(context.player1).toHaveEnabledPromptButtons([context.infernoFour.title, context.sabineWren.title, context.snowtrooperLieutenant.title, 'Take nothing']); + expect(context.player1).toHaveDisabledPromptButtons([context.waylay, context.protector.title]); + + context.player1.clickPrompt(context.snowtrooperLieutenant.title); + expect(context.snowtrooperLieutenant).toBeInZone('groundArena'); + expect(context.player1.exhaustedResourceCount).toBe(0); + + // do the attack, trigger _another_ bounty + expect(context.player1).toBeAbleToSelectExactly([context.wampa]); + expect(context.player1).toHavePassAbilityButton(); + context.player1.clickCard(context.wampa); + context.player1.clickCard(context.cloneDeserter); + expect([context.sabineWren, context.waylay, context.infernoFour, context.protector]).toAllBeInBottomOfDeck(context.player1, 4); + + // resolve the Clone Deserter bounty + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player1.clickPrompt('Collect Bounty: Draw a card'); + expect(context.player1.handSize).toBe(1); + }); + + it('should be able to collect a nested bounty a second time, and lose the ability to resolve the original bounty a second time', function() { + const { context } = contextRef; + const prompt = 'Collect Bounty: Search the top 5 cards of your deck, or 10 cards instead if this unit is unique, for a unit that costs 3 or less and play it for free.'; + + // first Bounty trigger: Bounty Hunter's Quarry, play a unit that has a "when played" which triggers an attack + context.player1.clickCard(context.bossk); + context.player1.clickCard(context.cloneTrooper); + expect(context.player1).toHavePassAbilityPrompt(prompt); + context.player1.clickPrompt(prompt); + expect(context.player1).toHaveEnabledPromptButtons([context.battlefieldMarine.title, context.snowtrooperLieutenant.title, context.sabineWren.title, 'Take nothing']); + expect(context.player1).toHaveDisabledPromptButtons([context.waylay, context.protector.title]); + + context.player1.clickPrompt(context.snowtrooperLieutenant.title); + expect(context.snowtrooperLieutenant).toBeInZone('groundArena'); + expect(context.player1.exhaustedResourceCount).toBe(0); + + // TODO: need final confirmation from judges / FFG on whether these go in the same trigger window or if there's nesting happening + expect(context.player1).toHaveExactPromptButtons(['Collect the Bounty again', 'Attack with a unit']); + + // do the attack, trigger _another_ bounty + context.player1.clickPrompt('Attack with a unit'); + expect(context.player1).toBeAbleToSelectExactly([context.wampa]); + expect(context.player1).toHavePassAbilityButton(); + context.player1.clickCard(context.wampa); + context.player1.clickCard(context.cloneDeserter); + + // resolve the Clone Deserter bounty + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player1.clickPrompt('Collect Bounty: Draw a card'); + expect(context.player1.handSize).toBe(1); + + // activate Bossk ability here to re-collect the Clone Deserter bounty, using up the per-round limit so we can't activate BHQ's Bounty again + expect(context.player1).toHavePassAbilityPrompt('Collect the Bounty again'); + context.player1.clickPrompt('Collect the Bounty again'); + expect(context.player1.handSize).toBe(2); + + expect(context.player2).toBeActivePlayer(); + }); + }); + }); +}); diff --git a/test/server/core/abilities/keyword/Bounty.spec.ts b/test/server/core/abilities/keyword/Bounty.spec.ts index 4c4fd112e..423cda989 100644 --- a/test/server/core/abilities/keyword/Bounty.spec.ts +++ b/test/server/core/abilities/keyword/Bounty.spec.ts @@ -20,8 +20,8 @@ describe('Bounty', function() { context.player1.clickCard(context.hylobonEnforcer); context.player1.clickCard(context.wampa); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Draw a card'); - context.player2.clickPrompt('Bounty: Draw a card'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player2.clickPrompt('Collect Bounty: Draw a card'); expect(context.player1.handSize).toBe(0); expect(context.player2.handSize).toBe(1); @@ -73,8 +73,8 @@ describe('Bounty', function() { context.player1.clickCard(context.vanquish); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Draw a card'); - context.player2.clickPrompt('Bounty: Draw a card'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player2.clickPrompt('Collect Bounty: Draw a card'); expect(context.player1.handSize).toBe(0); expect(context.player2.handSize).toBe(1); @@ -103,8 +103,8 @@ describe('Bounty', function() { context.player2.clickCard(context.discerningVeteran); expect(context.hylobonEnforcer).toBeCapturedBy(context.discerningVeteran); - expect(context.player2).toHavePassAbilityPrompt('Bounty: Draw a card'); - context.player2.clickPrompt('Bounty: Draw a card'); + expect(context.player2).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player2.clickPrompt('Collect Bounty: Draw a card'); expect(context.player1.handSize).toBe(0); expect(context.player2.handSize).toBe(1); @@ -231,8 +231,8 @@ describe('Bounty', function() { context.player1.clickCard(context.atst); context.player1.clickCard(context.hylobonEnforcer); - expect(context.player1).toHavePassAbilityPrompt('Bounty: Draw a card'); - context.player1.clickPrompt('Bounty: Draw a card'); + expect(context.player1).toHavePassAbilityPrompt('Collect Bounty: Draw a card'); + context.player1.clickPrompt('Collect Bounty: Draw a card'); expect(context.player1.handSize).toBe(1); expect(context.player2.handSize).toBe(0);