diff --git a/.vscode/bookmarks.json b/.vscode/bookmarks.json new file mode 100644 index 000000000..b26942bd8 --- /dev/null +++ b/.vscode/bookmarks.json @@ -0,0 +1,104 @@ +{ + "files": [ + { + "path": "server/game/core/Game.js", + "bookmarks": [ + { + "line": 686, + "column": 8, + "label": "Game setup and start" + } + ] + }, + { + "path": "server/game/core/event/EventWindow.js", + "bookmarks": [ + { + "line": 25, + "column": 26, + "label": "Event resolution steps" + } + ] + }, + { + "path": "server/game/core/gameSteps/abilityWindow/ForcedTriggeredAbilityWindow.js", + "bookmarks": [ + { + "line": 40, + "column": 7, + "label": "Simultaneous reaction resolver" + } + ] + }, + { + "path": "server/game/core/gameSteps/ActionWindow.js", + "bookmarks": [ + { + "line": 23, + "column": 14, + "label": "Card click entrypoint" + } + ] + }, + { + "path": "server/game/core/card/Card.ts", + "bookmarks": [ + { + "line": 298, + "column": 64, + "label": "EVENT CARDS: play action filter #1" + }, + { + "line": 610, + "column": 151, + "label": "EVENT CARDS: bluff window reaction re-registration" + }, + { + "line": 636, + "column": 43, + "label": "UPGRADES: effect management" + }, + { + "line": 1013, + "column": 43, + "label": "EVENT CARDS: play action filter #2" + } + ] + }, + { + "path": "server/game/core/ability/CardAbility.js", + "bookmarks": [ + { + "line": 33, + "column": 37, + "label": "EVENT CARDS: manual cost handling for play" + } + ] + }, + { + "path": "server/game/core/Player.js", + "bookmarks": [ + { + "line": 636, + "column": 105, + "label": "EVENT CARDS: register for bluff window" + }, + { + "line": 1180, + "column": 42, + "label": "EVENT CARDS: being played zone rules" + } + ] + }, + { + "path": "legacy_jigoku/server/game/cards/08-MotC/ChukanNobue.js", + "bookmarks": [ + { + "line": 3, + "column": 15, + "label": "L5R persistent effect example" + } + ] + } + ] +} \ No newline at end of file diff --git a/legacy_jigoku/server/game/EffectEngine.ts b/legacy_jigoku/server/game/EffectEngine.ts index 41b3994a1..6243f35f1 100644 --- a/legacy_jigoku/server/game/EffectEngine.ts +++ b/legacy_jigoku/server/game/EffectEngine.ts @@ -1,4 +1,5 @@ import { Durations, EffectNames, EventNames } from './Constants'; +import { Event } from './Events/Event'; import type Effect from './Effects/Effect'; import type EffectSource from './EffectSource'; import { EventRegistrar } from './EventRegistrar'; @@ -7,8 +8,8 @@ import type Game from './game'; export class EffectEngine { events: EventRegistrar; effects: Array = []; - customDurationEvents = []; - newEffect = false; + customDurationEvents: any[] = []; + effectsChangedSinceLastCheck = false; constructor(private game: Game) { this.events = new EventRegistrar(game, this); @@ -26,13 +27,13 @@ export class EffectEngine { if (effect.duration === Durations.Custom) { this.registerCustomDurationEvents(effect); } - this.newEffect = true; + this.effectsChangedSinceLastCheck = true; return effect; } - checkDelayedEffects(events: any[]) { - let effectsToTrigger = []; - const effectsToRemove = []; + checkDelayedEffects(events: Event[]) { + let effectsToTrigger: Effect[] = []; + const effectsToRemove: Effect[] = []; for (const effect of this.effects.filter( (effect) => effect.isEffectActive() && effect.effect.type === EffectNames.DelayedEffect )) { @@ -53,7 +54,7 @@ export class EffectEngine { } } } - effectsToTrigger = effectsToTrigger.map((effect) => { + const effectTriggers = effectsToTrigger.map((effect) => { const properties = effect.effect.getValue(); const context = effect.context; const targets = effect.targets; @@ -78,8 +79,8 @@ export class EffectEngine { if (effectsToRemove.length > 0) { this.unapplyAndRemove((effect) => effectsToRemove.includes(effect)); } - if (effectsToTrigger.length > 0) { - this.game.openSimultaneousEffectWindow(effectsToTrigger); + if (effectTriggers.length > 0) { + this.game.openSimultaneousEffectWindow(effectTriggers); } } @@ -102,11 +103,11 @@ export class EffectEngine { } checkEffects(prevStateChanged = false, loops = 0) { - if (!prevStateChanged && !this.newEffect) { + if (!prevStateChanged && !this.effectsChangedSinceLastCheck) { return false; } let stateChanged = false; - this.newEffect = false; + this.effectsChangedSinceLastCheck = false; // Check each effect's condition and find new targets stateChanged = this.effects.reduce((stateChanged, effect) => effect.checkCondition(stateChanged), stateChanged); if (loops === 10) { @@ -118,19 +119,19 @@ export class EffectEngine { } onConflictFinished() { - this.newEffect = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilEndOfConflict); + this.effectsChangedSinceLastCheck = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilEndOfConflict); } onDuelFinished() { - this.newEffect = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilEndOfDuel); + this.effectsChangedSinceLastCheck = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilEndOfDuel); } onPhaseEnded() { - this.newEffect = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilEndOfPhase); + this.effectsChangedSinceLastCheck = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilEndOfPhase); } onRoundEnded() { - this.newEffect = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilEndOfRound); + this.effectsChangedSinceLastCheck = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilEndOfRound); } onPassActionPhasePriority(event) { @@ -143,7 +144,7 @@ export class EffectEngine { } } - this.newEffect = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilPassPriority); + this.effectsChangedSinceLastCheck = this.unapplyAndRemove((effect) => effect.duration === Durations.UntilPassPriority); for (const effect of this.effects) { if ( effect.duration === Durations.UntilOpponentPassPriority || @@ -173,7 +174,7 @@ export class EffectEngine { } unregisterCustomDurationEvents(effect: Effect) { - const remainingEvents = []; + const remainingEvents: Event[] = []; for (const event of this.customDurationEvents) { if (event.effect === effect) { this.game.removeListener(event.name, event.handler); @@ -197,11 +198,11 @@ export class EffectEngine { } unapplyAndRemove(match: (effect: Effect) => boolean) { - let removedEffect = false; - const remainingEffects = []; + let anyEffectRemoved = false; + const remainingEffects: Effect[] = []; for (const effect of this.effects) { if (match(effect)) { - removedEffect = true; + anyEffectRemoved = true; effect.cancel(); if (effect.duration === Durations.Custom) { this.unregisterCustomDurationEvents(effect); @@ -211,7 +212,7 @@ export class EffectEngine { } } this.effects = remainingEffects; - return removedEffect; + return anyEffectRemoved; } getDebugInfo() { diff --git a/legacy_jigoku/server/game/Effects/DynamicEffect.js b/legacy_jigoku/server/game/Effects/DynamicEffect.js index 5a1b492d5..d2fbec848 100644 --- a/legacy_jigoku/server/game/Effects/DynamicEffect.js +++ b/legacy_jigoku/server/game/Effects/DynamicEffect.js @@ -15,17 +15,17 @@ class DynamicEffect extends StaticEffect { recalculate(target) { let oldValue = this.getValue(target); let newValue = this.setValue(target, this.calculate(target, this.context)); - if(typeof oldValue === 'function' && typeof newValue === 'function') { + if (typeof oldValue === 'function' && typeof newValue === 'function') { return oldValue.toString() !== newValue.toString(); } - if(Array.isArray(oldValue) && Array.isArray(newValue)) { + if (Array.isArray(oldValue) && Array.isArray(newValue)) { return JSON.stringify(oldValue) !== JSON.stringify(newValue); } return oldValue !== newValue; } getValue(target) { - if(target) { + if (target) { return this.values[target.uuid]; } } diff --git a/legacy_jigoku/server/game/gamesteps/actionwindow.js b/legacy_jigoku/server/game/gamesteps/actionwindow.js index ba4d78038..daeb2aded 100644 --- a/legacy_jigoku/server/game/gamesteps/actionwindow.js +++ b/legacy_jigoku/server/game/gamesteps/actionwindow.js @@ -7,11 +7,8 @@ class ActionWindow extends UiPrompt { this.title = title; this.windowName = windowName; - if(this.game.currentConflict && !this.game.currentConflict.isSinglePlayer) { - this.currentPlayer = this.game.currentConflict.defendingPlayer; - } else { - this.currentPlayer = game.getFirstPlayer(); - } + this.currentPlayer = game.actionPhaseActivePlayer; + this.currentPlayerConsecutiveActions = 0; this.opportunityCounter = 0; this.prevPlayerPassed = false; diff --git a/server/game/Interfaces.ts b/server/game/Interfaces.ts index 7707ef52d..e54e64696 100644 --- a/server/game/Interfaces.ts +++ b/server/game/Interfaces.ts @@ -4,7 +4,7 @@ import type { GameSystem } from './core/gameSystem/GameSystem'; import type Card from './core/card/Card'; import type CardAbility from './core/ability/CardAbility'; import type { IAttackProperties } from './gameSystems/AttackSystem'; -import type { RelativePlayer, TargetMode, CardType, Location, EventName, PhaseName } from './core/Constants'; +import type { RelativePlayer, TargetMode, CardType, Location, EventName, PhaseName, LocationFilter } from './core/Constants'; // import type { StatusToken } from './StatusToken'; interface IBaseTarget { @@ -169,8 +169,9 @@ export interface ITriggeredAbilityAggregateWhenProps extends IAbilityProps { - location?: Location | Location[]; +export interface IConstantAbilityProps { + // TODO: is this the right name or should it be 'location' like it was before? + locationFilter?: LocationFilter | LocationFilter[]; // TODO: what's the difference between condition and match? condition?: (context: AbilityContext) => boolean; match?: (card: Card, context?: AbilityContext) => boolean; diff --git a/server/game/actions/PlayUnitAction.ts b/server/game/actions/PlayUnitAction.ts index 0b8da7597..2e55f182d 100644 --- a/server/game/actions/PlayUnitAction.ts +++ b/server/game/actions/PlayUnitAction.ts @@ -1,5 +1,5 @@ import type { AbilityContext } from '../core/ability/AbilityContext.js'; -import BaseAction from '../core/ability/PlayerAction.js'; +import PlayerAction from '../core/ability/PlayerAction.js'; import { EffectName, EventName, Location, PhaseName, PlayType, RelativePlayer } from '../core/Constants.js'; import { payAdjustableResourceCost } from '../costs/CostLibrary.js'; import { putIntoPlay } from '../gameSystems/GameSystemLibrary.js'; @@ -8,7 +8,7 @@ import type Player from '../core/Player.js'; type ExecutionContext = AbilityContext & { onPlayCardSource: any }; -export class PlayUnitAction extends BaseAction { +export class PlayUnitAction extends PlayerAction { public title = 'Play this unit'; public constructor(card: Card) { @@ -70,8 +70,7 @@ export class PlayUnitAction extends BaseAction { const player = effect.length > 0 ? RelativePlayer.Opponent : RelativePlayer.Self; context.game.openEventWindow([ putIntoPlay({ - controller: player, - overrideLocation: Location.Hand // TODO: should we be doing this? + controller: player }).getEvent(context.source, context), cardPlayedEvent ]); diff --git a/server/game/actions/TriggerAttackAction.ts b/server/game/actions/TriggerAttackAction.ts index c23c9bcb4..f03246dd8 100644 --- a/server/game/actions/TriggerAttackAction.ts +++ b/server/game/actions/TriggerAttackAction.ts @@ -1,16 +1,19 @@ import type { AbilityContext } from '../core/ability/AbilityContext.js'; -import BaseAction from '../core/ability/PlayerAction.js'; +import PlayerAction from '../core/ability/PlayerAction.js'; import { EffectName, EventName, Location, PhaseName, PlayType, TargetMode, WildcardLocation } from '../core/Constants.js'; import { isArena } from '../core/utils/EnumHelpers.js'; import { exhaustSelf } from '../costs/CostLibrary.js'; import { attack } from '../gameSystems/GameSystemLibrary.js'; import type Player from '../core/Player.js'; import Card from '../core/card/Card.js'; +import { unlimited } from '../core/ability/AbilityLimit.js'; -export class TriggerAttackAction extends BaseAction { - public title = 'Attack'; +export class TriggerAttackAction extends PlayerAction { + title = 'Attack'; + + // UP NEXT: this is a hack to get this to behave like a regular card ability for testing + limit = unlimited(); - // TODO: rename to 'gameSystem' or 'triggeredSystem' or something and centralize where it is created, since it's also emitted from executeHandler public constructor(card: Card) { super(card, [exhaustSelf()], { gameSystem: attack({ attacker: card }), diff --git a/server/game/cardImplementations/01_SOR/SabineWrenExplosivesArtist.ts b/server/game/cardImplementations/01_SOR/SabineWrenExplosivesArtist.ts new file mode 100644 index 000000000..0a8d5ade0 --- /dev/null +++ b/server/game/cardImplementations/01_SOR/SabineWrenExplosivesArtist.ts @@ -0,0 +1,27 @@ +import AbilityDsl from '../../AbilityDsl'; +import Card from '../../core/card/Card'; +import { countUniqueAspects } from '../../core/utils/Helpers'; + +export default class SabineWrenExplosivesArtist extends Card { + protected override getImplementationId() { + return { + id: '3646264648', + internalName: 'sabine-wren#explosives-artist', + }; + } + + override setupCardAbilities() { + this.constantAbility({ + // UP NEXT: helper fn on Card to get all friendly units in play + condition: () => countUniqueAspects(this.controller.getUnitsInPlay((card) => card !== this)) >= 3, + + // UP NEXT: convert this to a named effect + effect: AbilityDsl.ongoingEffects.cardCannot('beAttacked') + }); + } +} + +// sabine is only partially implemented, still need to handle: +// - the effect override if she gains sentinel +// - her active ability +SabineWrenExplosivesArtist.implemented = false; \ No newline at end of file diff --git a/server/game/cardImplementations/02_SHD/GroguIrresistible.ts b/server/game/cardImplementations/02_SHD/GroguIrresistible.ts index 1bb480838..b9f20631f 100644 --- a/server/game/cardImplementations/02_SHD/GroguIrresistible.ts +++ b/server/game/cardImplementations/02_SHD/GroguIrresistible.ts @@ -1,6 +1,5 @@ import AbilityDsl from '../../AbilityDsl'; import Card from '../../core/card/Card'; -import { CardType } from '../../core/Constants'; export default class GroguIrresistible extends Card { protected override getImplementationId() { @@ -11,7 +10,7 @@ export default class GroguIrresistible extends Card { } override setupCardAbilities() { - this.action({ + this.actionAbility({ title: 'Exhaust an enemy unit', cost: AbilityDsl.costs.exhaustSelf(), target: { @@ -20,4 +19,6 @@ export default class GroguIrresistible extends Card { } }); } -} \ No newline at end of file +} + +GroguIrresistible.implemented = true; \ No newline at end of file diff --git a/server/game/cardImplementations/02_SHD/SalaciousCrumbObnoxiousPet.ts b/server/game/cardImplementations/02_SHD/SalaciousCrumbObnoxiousPet.ts new file mode 100644 index 000000000..dc628d01b --- /dev/null +++ b/server/game/cardImplementations/02_SHD/SalaciousCrumbObnoxiousPet.ts @@ -0,0 +1,39 @@ +import AbilityDsl from '../../AbilityDsl'; +import Card from '../../core/card/Card'; +import { CardType, Location, RelativePlayer } from '../../core/Constants'; + +export default class SalaciousCrumbObnoxiousPet extends Card { + protected override getImplementationId() { + return { + id: '2744523125', + internalName: 'salacious-crumb#obnoxious-pet' + }; + } + + override setupCardAbilities() { + this.whenPlayedAbility({ + title: 'Heal 1 damage from friendly base', + target: { + // UP NEXT: add a contract check if location and cardType are mutually exclusive + cardType: CardType.Base, + location: Location.Base, + controller: RelativePlayer.Self, + gameSystem: AbilityDsl.immediateEffects.heal({ amount: 1 }) + } + }); + + this.actionAbility({ + title: 'Deal 1 damage to a ground unit', + cost: [ + AbilityDsl.costs.exhaustSelf(), + AbilityDsl.costs.returnSelfToHandFromPlay() + ], + target: { + cardCondition: (card) => card.location === Location.GroundArena, + gameSystem: AbilityDsl.immediateEffects.damage({ amount: 1 }) + } + }); + } +} + +SalaciousCrumbObnoxiousPet.implemented = true; \ No newline at end of file diff --git a/server/game/cardImplementations/Index.ts b/server/game/cardImplementations/Index.ts index a6b0aa672..01634b2c5 100644 --- a/server/game/cardImplementations/Index.ts +++ b/server/game/cardImplementations/Index.ts @@ -35,6 +35,10 @@ for (const filepath of allJsFiles(__dirname)) { throw Error(`Importing card class with repeated id!: ${card}`); } + if (!card.implemented) { + console.warn(`Warning: Loading partially implemented card '${cardId.internalName}'`); + } + cardsMap.set(cardId.id, card); } diff --git a/server/game/core/Constants.ts b/server/game/core/Constants.ts index 2faf0cf68..29613b5ce 100644 --- a/server/game/core/Constants.ts +++ b/server/game/core/Constants.ts @@ -18,7 +18,7 @@ export enum WildcardLocation { AnyAttackable = 'any attackable' } -export type TargetableLocation = Location | WildcardLocation; +export type LocationFilter = Location | WildcardLocation; export enum PlayType { PlayFromHand = 'playFromHand', @@ -42,8 +42,6 @@ export enum EffectName { Blank = 'blank', AddKeyword = 'addKeyword', LoseKeyword = 'loseKeyword', - CopyCharacter = 'copyCharacter', // currently unused - GainAbility = 'gainAbility', CanBeTriggeredByOpponent = 'canBeTriggeredByOpponent', UnlessActionCost = 'unlessActionCost', MustBeChosen = 'mustBeChosen', @@ -53,11 +51,11 @@ export enum EffectName { AdditionalTriggerCost = 'additionalTriggercost', AdditionalPlayCost = 'additionalPlaycost', ModifyStats = 'modifyStats', - ModifyPower = 'modifyPower', // currently unused - SetBasePower = 'setBasePower', // currently unused - SetPower = 'setPower', // currently unused - CalculatePrintedPower = 'calculatePrintedPower', // currently unused - ModifyHp = 'modifyHp', // currently unused + ModifyPower = 'modifyPower', + SetBasePower = 'setBasePower', + SetPower = 'setPower', + CalculatePrintedPower = 'calculatePrintedPower', + ModifyHp = 'modifyHp', UpgradePowerModifier = 'upgradePowerModifier', UpgradeHpModifier = 'upgradeHpModifier', CanAttackGroundArenaFromSpaceArena = 'canAttackGroundArenaFromSpaceArena', @@ -66,6 +64,9 @@ export enum EffectName { LoseTrait = 'loseTrait', DelayedEffect = 'delayedEffect', IncreaseLimitOnAbilities = 'increaseLimitOnAbilities', + LoseAllNonKeywordAbilities = 'loseAllNonKeywordAbilities', + CannotApplyLastingEffects = 'cannotApplyLastingEffects', + CannotBeAttacked = 'cannotBeAttacked' } export enum Duration { @@ -152,15 +153,11 @@ export enum EventName { OnAttackDeclared = 'onAttackDeclared', OnDamageDealt = 'onDamageDealt', OnAttackCompleted = 'onAttackCompleted', + OnCardReturnedToHand = 'onCardReturnedToHand', } export enum AbilityType { Action = 'action', - WouldInterrupt = 'cancelInterrupt', - ForcedInterrupt = 'forcedInterrupt', - KeywordInterrupt = 'keywordInterrupt', - Interrupt = 'interrupt', - KeywordReaction = 'keywordReaction', ForcedReaction = 'forcedReaction', Reaction = 'reaction', Persistent = 'persistent', diff --git a/server/game/core/Game.js b/server/game/core/Game.js index 326f3ad37..247a92ead 100644 --- a/server/game/core/Game.js +++ b/server/game/core/Game.js @@ -257,21 +257,20 @@ class Game extends EventEmitter { return this.allCards.filter(predicate); } - // /** - // * Returns all cards (i.e. characters) which matching the passed predicated - // * function from either players 'in play' area. - // * @param {Function} predicate - card => Boolean - // * @returns {Array} Array of DrawCard objects - // */ - // findAnyCardsInPlay(predicate) { - // var foundCards = []; + /** + * Returns all cards which matching the passed predicated function from either players arenas + * @param {Function} predicate - card => Boolean + * @returns {Array} Array of DrawCard objects + */ + findAnyCardsInPlay(predicate) { + var foundCards = []; - // _.each(this.getPlayers(), (player) => { - // foundCards = foundCards.concat(player.findCards(player.cardsInPlay, predicate)); - // }); + _.each(this.getPlayers(), (player) => { + foundCards = foundCards.concat(player.findCards(player.getCardsInPlay(), predicate)); + }); - // return foundCards; - // } + return foundCards; + } /** * Returns if a card is in play (characters, attachments, provinces, holdings) that has the passed trait @@ -713,7 +712,6 @@ class Game extends EventEmitter { [] ) ); - this.provinceCards = this.allCards.filter((card) => card.isProvince); // if (this.gameMode !== GameMode.Skirmish) { // if (playerWithNoStronghold) { @@ -1065,6 +1063,7 @@ class Game extends EventEmitter { this.addMessage('{0} has reconnected', player); } + // UP NEXT: rename this to something that makes it clearer that it's needed for updating effect state checkGameState(hasChanged = false, events = []) { // check for a game state change (recalculating attack stats if necessary) if ( @@ -1094,60 +1093,50 @@ class Game extends EventEmitter { this.pipeline.continue(); } - formatDeckForSaving(deck) { - var result = { - faction: {}, - conflictCards: [], - dynastyCards: [], - provinceCards: [], - stronghold: undefined, - role: undefined - }; - - //faction - result.faction = deck.faction; - - //conflict - deck.conflictCards.forEach((cardData) => { - if (cardData && cardData.card) { - result.conflictCards.push(`${cardData.count}x ${cardData.card.id}`); - } - }); + // formatDeckForSaving(deck) { + // var result = { + // faction: {}, + // conflictCards: [], + // dynastyCards: [], + // stronghold: undefined, + // role: undefined + // }; - //dynasty - deck.dynastyCards.forEach((cardData) => { - if (cardData && cardData.card) { - result.dynastyCards.push(`${cardData.count}x ${cardData.card.id}`); - } - }); + // //faction + // result.faction = deck.faction; - //provinces - if (deck.provinceCards) { - deck.provinceCards.forEach((cardData) => { - if (cardData && cardData.card) { - result.provinceCards.push(cardData.card.id); - } - }); - } + // //conflict + // deck.conflictCards.forEach((cardData) => { + // if (cardData && cardData.card) { + // result.conflictCards.push(`${cardData.count}x ${cardData.card.id}`); + // } + // }); - //stronghold & role - if (deck.stronghold) { - deck.stronghold.forEach((cardData) => { - if (cardData && cardData.card) { - result.stronghold = cardData.card.id; - } - }); - } - if (deck.role) { - deck.role.forEach((cardData) => { - if (cardData && cardData.card) { - result.role = cardData.card.id; - } - }); - } + // //dynasty + // deck.dynastyCards.forEach((cardData) => { + // if (cardData && cardData.card) { + // result.dynastyCards.push(`${cardData.count}x ${cardData.card.id}`); + // } + // }); - return result; - } + // //stronghold & role + // if (deck.stronghold) { + // deck.stronghold.forEach((cardData) => { + // if (cardData && cardData.card) { + // result.stronghold = cardData.card.id; + // } + // }); + // } + // if (deck.role) { + // deck.role.forEach((cardData) => { + // if (cardData && cardData.card) { + // result.role = cardData.card.id; + // } + // }); + // } + + // return result; + // } // /* // * This information is all logged when a game is won diff --git a/server/game/core/Player.js b/server/game/core/Player.js index b9c11d673..019fcdc82 100644 --- a/server/game/core/Player.js +++ b/server/game/core/Player.js @@ -112,11 +112,15 @@ class Player extends GameObject { this.clock.reset(); } - // TODO: does this / should this return upgrades? + // TODO: this should get upgrades, but we need to confirm once they're implemented getCardsInPlay() { return _(this.spaceArena.value().concat(this.groundArena.value())); } + getUnitsInPlay(cardCondition = (card) => true) { + return this.getCardsInPlay().filter((card) => card.type === CardType.Unit && cardCondition(card)); + } + /** * Checks whether a card with a uuid matching the passed card is in the passed _(Array) * @param list _(Array) @@ -634,8 +638,9 @@ class Player extends GameObject { this.preparedDeck = preparedDeck; this.deck.each((card) => { // register event reactions in case event-in-deck bluff window is enabled + // TODO: probably we need to do this differently since we have actual reactions on our events if (card.type === CardType.Event) { - for (let reaction of card.abilities.reactions) { + for (let reaction of card.abilities.triggeredAbilities) { reaction.registerEvents(); } } @@ -726,22 +731,26 @@ class Player extends GameObject { */ getMinimumPossibleCost(playingType, context, target, ignoreType = false) { const card = context.source; - let adjustedCost = this.getAdjustedCost(playingType, card, target, ignoreType, context.costAspects); - let triggeredCostAdjusters = 0; - let fakeWindow = { addChoice: () => triggeredCostAdjusters++ }; - let fakeEvent = this.game.getEvent(EventName.OnCardPlayed, { card: card, player: this, context: context }); - this.game.emit(EventName.OnCardPlayed + ':' + AbilityType.Interrupt, fakeEvent, fakeWindow); - let fakeResolverEvent = this.game.getEvent(EventName.OnAbilityResolverInitiated, { - card: card, - player: this, - context: context - }); - this.game.emit( - EventName.OnAbilityResolverInitiated + ':' + AbilityType.Interrupt, - fakeResolverEvent, - fakeWindow - ); - return Math.max(adjustedCost - triggeredCostAdjusters, 0); + const adjustedCost = this.getAdjustedCost(playingType, card, target, ignoreType, context.costAspects); + + // TODO: not sure yet if we need this code, I think it's checking to see if any potential interrupts would create additional cost + // let triggeredCostAdjusters = 0; + // let fakeWindow = { addChoice: () => triggeredCostAdjusters++ }; + // let fakeEvent = this.game.getEvent(EventName.OnCardPlayed, { card: card, player: this, context: context }); + // this.game.emit(EventName.OnCardPlayed + ':' + AbilityType.Interrupt, fakeEvent, fakeWindow); + // let fakeResolverEvent = this.game.getEvent(EventName.OnAbilityResolverInitiated, { + // card: card, + // player: this, + // context: context + // }); + // this.game.emit( + // EventName.OnAbilityResolverInitiated + ':' + AbilityType.Interrupt, + // fakeResolverEvent, + // fakeWindow + // ); + // return Math.max(adjustedCost - triggeredCostAdjusters, 0); + + return Math.max(adjustedCost, 0); } /** @@ -759,7 +768,7 @@ class Player extends GameObject { aspectPenaltiesTotal += this.runAdjustersForCostType(playingType, 2, card, target, ignoreType, aspect); } - let penalizedCost = card.getCost() + aspectPenaltiesTotal; + let penalizedCost = card.cost + aspectPenaltiesTotal; return this.runAdjustersForCostType(playingType, penalizedCost, card, target, ignoreType); } @@ -1211,6 +1220,12 @@ class Player extends GameObject { let updatedPile = this.removeCardByUuid(originalPile, card.uuid); switch (originalLocation) { + case Location.SpaceArena: + this.spaceArena = updatedPile; + break; + case Location.GroundArena: + this.groundArena = updatedPile; + break; case Location.Hand: this.hand = updatedPile; break; @@ -1223,6 +1238,9 @@ class Player extends GameObject { case Location.RemovedFromGame: this.removedFromGame = updatedPile; break; + case Location.Leader: + this.leaderZone = updatedPile; + break; default: if (this.additionalPiles[originalPile]) { this.additionalPiles[originalPile].cards = updatedPile; diff --git a/server/game/core/ability/CardActionAbility.ts b/server/game/core/ability/CardActionAbility.ts index f4a0fdb16..6dbd55fba 100644 --- a/server/game/core/ability/CardActionAbility.ts +++ b/server/game/core/ability/CardActionAbility.ts @@ -63,7 +63,6 @@ export class CardActionAbility extends CardAbility { const canOpponentTrigger = this.card.anyEffect(EffectName.CanBeTriggeredByOpponent) && - this.abilityType !== AbilityType.ForcedInterrupt && this.abilityType !== AbilityType.ForcedReaction; const canPlayerTrigger = this.anyPlayer || context.player === this.card.controller || canOpponentTrigger; if (!ignoredRequirements.includes('player') && this.card.type !== CardType.Event && !canPlayerTrigger) { diff --git a/server/game/core/ability/PlayerAction.js b/server/game/core/ability/PlayerAction.js index 90baa4281..b52ce59c6 100644 --- a/server/game/core/ability/PlayerAction.js +++ b/server/game/core/ability/PlayerAction.js @@ -26,8 +26,8 @@ class PlayerAction extends PlayerOrCardAbility { // TODO: replace 'fate' with 'resource' everywhere getReducedCost(context) { - let fateCost = this.cost.find((cost) => cost.getReducedCost); - return fateCost ? fateCost.getReducedCost(context) : 0; + let resourceCost = this.cost.find((cost) => cost.getReducedCost); + return resourceCost ? resourceCost.getReducedCost(context) : 0; } /** @override */ @@ -37,4 +37,3 @@ class PlayerAction extends PlayerOrCardAbility { } module.exports = PlayerAction; - diff --git a/server/game/core/ability/PlayerOrCardAbility.js b/server/game/core/ability/PlayerOrCardAbility.js index 5d147a01c..20de06f15 100644 --- a/server/game/core/ability/PlayerOrCardAbility.js +++ b/server/game/core/ability/PlayerOrCardAbility.js @@ -56,6 +56,7 @@ class PlayerOrCardAbility { return cost; } + // UP NEXT: better naming and general clarification for the target construction pipeline buildTargets(properties) { this.targets = []; if (properties.target) { @@ -120,7 +121,7 @@ class PlayerOrCardAbility { return this.getCosts(context).every((cost) => cost.canPay(contextCopy)); } - // eslint-disable-next-line no-unused-vars + getCosts(context, playCosts = true, triggerCosts = true) { let costs = this.cost.map((a) => a); if (context.ignoreResourceCost) { @@ -220,7 +221,7 @@ class PlayerOrCardAbility { ); } - // eslint-disable-next-line no-unused-vars + displayMessage(context) {} /** @@ -228,7 +229,7 @@ class PlayerOrCardAbility { * should override this method to implement their behavior; by default it * does nothing. */ - // eslint-disable-next-line no-unused-vars + executeHandler(context) {} isAction() { diff --git a/server/game/core/ability/TriggeredAbility.js b/server/game/core/ability/TriggeredAbility.js new file mode 100644 index 000000000..ccb19624c --- /dev/null +++ b/server/game/core/ability/TriggeredAbility.js @@ -0,0 +1,144 @@ +const _ = require('underscore'); + +const CardAbility = require('./CardAbility.js'); +const { TriggeredAbilityContext } = require('./TriggeredAbilityContext.js'); +const { Stage, CardType, EffectName, AbilityType } = require('../Constants'); + +/** + * Represents a reaction ability provided by card text. + * + * Properties: + * when - object whose keys are event names to listen to for the reaction and + * whose values are functions that return a boolean about whether to + * trigger the reaction when that event is fired. For example, to + * trigger only at the end of the challenge phase, you would do: + * when: { + * onPhaseEnded: event => event.phase === 'conflict' + * } + * Multiple events may be specified for cards that have multiple + * possible triggers for the same reaction. + * title - string which is displayed to the player to reference this ability + * cost - object or array of objects representing the cost required to be + * paid before the action will activate. See Costs. + * target - object giving properties for the target API + * handler - function that will be executed if the player chooses 'Yes' when + * asked to trigger the reaction. If the reaction has more than one + * choice, use the choices sub object instead. + * limit - optional AbilityLimit object that represents the max number of uses + * for the reaction as well as when it resets. + * location - string or array of strings indicating the location the card should + * be in in order to activate the reaction. Defaults to 'play area'. + */ + +class TriggeredAbility extends CardAbility { + constructor(game, card, abilityType, properties) { + super(game, card, properties); + this.when = properties.when; + this.aggregateWhen = properties.aggregateWhen; + this.anyPlayer = !!properties.anyPlayer; + this.abilityType = abilityType; + this.collectiveTrigger = !!properties.collectiveTrigger; + } + + /** @override */ + meetsRequirements(context, ignoredRequirements = []) { + let canOpponentTrigger = + this.card.anyEffect(EffectName.CanBeTriggeredByOpponent) && + this.abilityType !== AbilityType.ForcedReaction; + let canPlayerTrigger = this.anyPlayer || context.player === this.card.controller || canOpponentTrigger; + + if (!ignoredRequirements.includes('player') && !canPlayerTrigger) { + if ( + this.card.type !== CardType.Event || + !context.player.isCardInPlayableLocation(this.card, context.playType) + ) { + return 'player'; + } + } + + return super.meetsRequirements(context, ignoredRequirements); + } + + eventHandler(event, window) { + for (const player of this.game.getPlayers()) { + let context = this.createContext(player, event); + //console.log(event.name, this.card.name, this.isTriggeredByEvent(event, context), this.meetsRequirements(context)); + if ( + this.card.triggeredAbilities.includes(this) && + this.isTriggeredByEvent(event, context) && + this.meetsRequirements(context) === '' + ) { + window.addChoice(context); + } + } + } + + checkAggregateWhen(events, window) { + for (const player of this.game.getPlayers()) { + let context = this.createContext(player, events); + //console.log(events.map(event => event.name), this.card.name, this.aggregateWhen(events, context), this.meetsRequirements(context)); + if ( + this.card.triggeredAbilities.includes(this) && + this.aggregateWhen(events, context) && + this.meetsRequirements(context) === '' + ) { + window.addChoice(context); + } + } + } + + /** @override */ + createContext(player = this.card.controller, event) { + return new TriggeredAbilityContext({ + event: event, + game: this.game, + source: this.card, + player: player, + ability: this, + stage: Stage.PreTarget + }); + } + + isTriggeredByEvent(event, context) { + let listener = this.when[event.name]; + + return listener && listener(event, context); + } + + registerEvents() { + if (this.events) { + return; + } else if (this.aggregateWhen) { + const event = { + name: 'aggregateEvent:' + this.abilityType, + handler: (events, window) => this.checkAggregateWhen(events, window) + }; + this.events = [event]; + this.game.on(event.name, event.handler); + return; + } + + var eventNames = _.keys(this.when); + + this.events = []; + _.each(eventNames, (eventName) => { + var event = { + name: eventName + ':' + this.abilityType, + handler: (event, window) => this.eventHandler(event, window) + }; + this.game.on(event.name, event.handler); + this.events.push(event); + }); + } + + unregisterEvents() { + if (this.events) { + _.each(this.events, (event) => { + this.game.removeListener(event.name, event.handler); + }); + this.events = null; + } + } +} + +module.exports = TriggeredAbility; \ No newline at end of file diff --git a/server/game/core/ability/abilityTargets/AbilityTargetAbility.js b/server/game/core/ability/abilityTargets/AbilityTargetAbility.js index c21680d64..c5fb7921b 100644 --- a/server/game/core/ability/abilityTargets/AbilityTargetAbility.js +++ b/server/game/core/ability/abilityTargets/AbilityTargetAbility.js @@ -17,7 +17,7 @@ class AbilityTargetAbility { getSelector(properties) { let cardCondition = (card, context) => { - let abilities = card.actions.concat(card.reactions).filter((ability) => ability.isTriggeredAbility() && this.abilityCondition(ability)); + let abilities = card.actions.concat(card.triggeredAbilities).filter((ability) => ability.isTriggeredAbility() && this.abilityCondition(ability)); return abilities.some((ability) => { let contextCopy = context.copy(); contextCopy.targetAbility = ability; @@ -73,7 +73,7 @@ class AbilityTargetAbility { context: context, selector: this.selector, onSelect: (player, card) => { - let abilities = card.actions.concat(card.reactions).filter((ability) => ability.isTriggeredAbility() && this.abilityCondition(ability)); + let abilities = card.actions.concat(card.triggeredAbilities).filter((ability) => ability.isTriggeredAbility() && this.abilityCondition(ability)); if (abilities.length === 1) { context.targetAbility = abilities[0]; } else if (abilities.length > 1) { diff --git a/server/game/core/card/Card.ts b/server/game/core/card/Card.ts index 7cdde2366..c2da90f16 100644 --- a/server/game/core/card/Card.ts +++ b/server/game/core/card/Card.ts @@ -2,7 +2,6 @@ import AbilityDsl from '../../AbilityDsl.js'; import Effects from '../../effects/EffectLibrary.js'; import EffectSource from '../effect/EffectSource.js'; import CardAbility from '../ability/CardAbility.js'; -// import TriggeredAbility from './triggeredability'; import Game from '../Game.js'; import Contract from '../utils/Contract'; @@ -24,7 +23,7 @@ import { isArena, cardLocationMatches, checkConvertToEnum } from '../utils/EnumH import { IActionProps, IAttachmentConditionProps, - IPersistentEffectProps, + IConstantAbilityProps, ITriggeredAbilityProps, ITriggeredAbilityWhenProps } from '../../Interfaces.js'; @@ -33,9 +32,12 @@ import { import Player from '../Player.js'; import StatModifier from './StatModifier.js'; import type { ICardEffect } from '../effect/ICardEffect.js'; -// import type { GainAllAbilities } from './Effects/Library/gainAllAbilities'; import { PlayUnitAction } from '../../actions/PlayUnitAction.js'; import { TriggerAttackAction } from '../../actions/TriggerAttackAction.js'; +import TriggeredAbility from '../ability/TriggeredAbility.js'; +import { IConstantAbility } from '../effect/IConstantAbility.js'; +import PlayerAction from '../ability/PlayerAction.js'; +import PlayerOrCardAbility from '../ability/PlayerOrCardAbility.js'; // TODO: convert enums to unions type PrintedKeyword = @@ -69,11 +71,21 @@ const ValidKeywords = new Set([ 'sincerity' ]); +interface ICardAbilities { + // TODO: maybe rethink some of the inheritance tree (specifically for TriggerAttackAction) so that this can be more specific + action: any[]; + triggered: TriggeredAbility[]; + constant: IConstantAbility[]; + playCardAction: PlayerAction[]; +} + // TODO: switch to using mixins for the different card types class Card extends EffectSource { controller: Player; override game: Game; + static implemented = false; + // TODO: readonly pass on class properties throughout the repo override readonly id: string; readonly printedTitle: string; @@ -93,7 +105,7 @@ class Card extends EffectSource { showPopup = false; popupMenuText = ''; - abilities: any = { actions: [], reactions: [], persistentEffects: [], playActions: [] }; + abilities: ICardAbilities = { action: [], triggered: [], constant: [], playCardAction: [] }; traits: string[]; printedFaction: string; location: Location; @@ -144,6 +156,7 @@ class Card extends EffectSource { this.printedKeywords = cardData.keywords; // TODO: enum for these this.setupCardAbilities(AbilityDsl); + this.setupPlayAbilities(); // this.parseKeywords(cardData.text ? cardData.text.replace(/<[^>]*>/g, '').toLowerCase() : ''); // this.applyAttachmentBonus(); @@ -171,20 +184,20 @@ class Card extends EffectSource { } // if (cardData.type === CardType.Character) { - // this.abilities.reactions.push(new CourtesyAbility(this.game, this)); - // this.abilities.reactions.push(new PrideAbility(this.game, this)); - // this.abilities.reactions.push(new SincerityAbility(this.game, this)); + // this.abilities.triggered.push(new CourtesyAbility(this.game, this)); + // this.abilities.triggered.push(new PrideAbility(this.game, this)); + // this.abilities.triggered.push(new SincerityAbility(this.game, this)); // } // if (cardData.type === CardType.Attachment) { - // this.abilities.reactions.push(new CourtesyAbility(this.game, this)); - // this.abilities.reactions.push(new SincerityAbility(this.game, this)); + // this.abilities.triggered.push(new CourtesyAbility(this.game, this)); + // this.abilities.triggered.push(new SincerityAbility(this.game, this)); // } // if (cardData.type === CardType.Event && this.hasEphemeral()) { // this.eventRegistrarForEphemeral = new EventRegistrar(this.game, this); // this.eventRegistrarForEphemeral.register([{ [EventName.OnCardPlayed]: 'handleEphemeral' }]); // } // if (this.isDynasty) { - // this.abilities.reactions.push(new RallyAbility(this.game, this)); + // this.abilities.triggered.push(new RallyAbility(this.game, this)); // } } @@ -224,57 +237,34 @@ class Card extends EffectSource { return null; } + /** + * Equivalent to {@link Card.title} + */ override get name(): string { - const copyEffect = this.mostRecentEffect(EffectName.CopyCharacter); - return copyEffect ? copyEffect.printedName : this.printedTitle; + return this.title; } - override get type(): CardType { - return this.typeField; + get title(): string { + return this.printedTitle; } - // TODO: do we need to have this when super.mostRecentEffect is available? - private mostRecentCardEffect(predicate: (effect: ICardEffect) => boolean): ICardEffect | undefined { - const effects = this.getRawEffects().filter(predicate); - return effects[effects.length - 1]; + override get type(): CardType { + return this.typeField; } - get actions(): CardActionAbility[] { + // UP NEXT: don't always return play actions + /** + * The union of the card's "Action Abilities" (ie abilities that enable an action, SWU 2.1) and + * any other general card actions such as playing a card + */ + get actions(): any[] { return this.getActions(); } - getActions(location = this.location, ignoreDynamicGains = false): CardActionAbility[] { - let actions = this.abilities.actions; - - const mostRecentEffect = this.mostRecentCardEffect((effect) => effect.type === EffectName.CopyCharacter); - if (mostRecentEffect) { - actions = mostRecentEffect.value.getActions(this); - } - - const effectActions = this.getEffects(EffectName.GainAbility).filter( - (ability) => ability.abilityType === AbilityType.Action - ); - - // for (const effect of this.getRawEffects()) { - // if (effect.type === EffectName.GainAllAbilities) { - // actions = actions.concat((effect.value as GainAllAbilities).getActions(this)); - // } - // } - // if (!ignoreDynamicGains) { - // if (this.anyEffect(EffectName.GainAllAbilitiesDynamic)) { - // const context = this.game.getFrameworkContext(this.controller); - // const effects = this.getRawEffects().filter( - // (effect) => effect.type === EffectName.GainAllAbilitiesDynamic - // ); - // effects.forEach((effect) => { - // effect.value.calculate(this, context); //fetch new abilities - // actions = actions.concat(effect.value.getActions(this)); - // }); - // } - // } + private getActions(location = this.location): any[] { + const allAbilities = this.abilities.action; // const lostAllNonKeywordsAbilities = this.anyEffect(EffectName.LoseAllNonKeywordAbilities); - const allAbilities = actions.concat(effectActions); // if (lostAllNonKeywordsAbilities) { // allAbilities = allAbilities.filter((a) => a.isKeywordAbility()); // } @@ -288,173 +278,114 @@ class Card extends EffectSource { return allAbilities; } - // TODO: add base / leader actions if this doesn't already cover them + // TODO: do we need to explicitly add base / leader actions here? // otherwise (i.e. card is in hand), return play card action(s) + other available card actions return allAbilities.concat(this.getPlayCardActions()); } - // _getReactions(ignoreDynamicGains = false): TriggeredAbility[] { - // const TriggeredAbilityTypes = [ - // AbilityType.ForcedInterrupt, - // AbilityType.ForcedReaction, - // AbilityType.Interrupt, - // AbilityType.Reaction, - // AbilityType.WouldInterrupt - // ]; - // let reactions = this.abilities.reactions; - // const mostRecentEffect = this.#mostRecentEffect((effect) => effect.type === EffectName.CopyCharacter); - // if (mostRecentEffect) { - // reactions = mostRecentEffect.value.getReactions(this); - // } - // const effectReactions = this.getEffects(EffectName.GainAbility).filter((ability) => - // TriggeredAbilityTypes.includes(ability.abilityType) - // ); - // for (const effect of this.getRawEffects()) { - // if (effect.type === EffectName.GainAllAbilities) { - // reactions = reactions.concat((effect.value as GainAllAbilities).getReactions(this)); - // } - // } - // if (!ignoreDynamicGains) { - // if (this.anyEffect(EffectName.GainAllAbilitiesDynamic)) { - // const effects = this.getRawEffects().filter( - // (effect) => effect.type === EffectName.GainAllAbilitiesDynamic - // ); - // const context = this.game.getFrameworkContext(this.controller); - // effects.forEach((effect) => { - // effect.value.calculate(this, context); //fetch new abilities - // reactions = reactions.concat(effect.value.getReactions(this)); - // }); - // } - // } - - // const lostAllNonKeywordsAbilities = this.anyEffect(EffectName.LoseAllNonKeywordAbilities); - // let allAbilities = reactions.concat(effectReactions); - // if (lostAllNonKeywordsAbilities) { - // allAbilities = allAbilities.filter((a) => a.isKeywordAbility()); - // } - // return allAbilities; - // } + /** + * SWU 6.1: Triggered abilities have bold text indicating their triggering condition, starting with the word + * “When” or “On”, followed by a colon and an effect. Examples of triggered abilities are “When Played,” + * “When Defeated,” and “On Attack” abilities + */ + get triggeredAbilities(): TriggeredAbility[] { + return this.getTriggeredAbilities(); + } - // get reactions(): TriggeredAbility[] { - // return this._getReactions(); - // } + private getTriggeredAbilities(): TriggeredAbility[] { + const TriggeredAbilityTypes = [ + AbilityType.ForcedReaction, + AbilityType.Reaction, + ]; + const triggeredAbilities = this.abilities.triggered; - // _getPersistentEffects(ignoreDynamicGains = false): any[] { - // let gainedPersistentEffects = this.getEffects(EffectName.GainAbility).filter( - // (ability) => ability.abilityType === AbilityType.Persistent - // ); + // const lostAllNonKeywordsAbilities = this.anyEffect(EffectName.LoseAllNonKeywordAbilities); + // if (lostAllNonKeywordsAbilities) { + // allAbilities = allAbilities.filter((a) => a.isKeywordAbility()); + // } - // const mostRecentEffect = this.#mostRecentEffect((effect) => effect.type === EffectName.CopyCharacter); - // if (mostRecentEffect) { - // return gainedPersistentEffects.concat(mostRecentEffect.value.getPersistentEffects()); - // } - // for (const effect of this.getRawEffects()) { - // if (effect.type === EffectName.GainAllAbilities) { - // gainedPersistentEffects = gainedPersistentEffects.concat( - // (effect.value as GainAllAbilities).getPersistentEffects() - // ); - // } - // } - // if (!ignoreDynamicGains) { - // // This is needed even though there are no dynamic persistent effects - // // Because the effect itself is persistent and to ensure we pick up all reactions/interrupts, we need this check to happen - // // As the game state is applying the effect - // if (this.anyEffect(EffectName.GainAllAbilitiesDynamic)) { - // const effects = this.getRawEffects().filter( - // (effect) => effect.type === EffectName.GainAllAbilitiesDynamic - // ); - // const context = this.game.getFrameworkContext(this.controller); - // effects.forEach((effect) => { - // effect.value.calculate(this, context); //fetch new abilities - // gainedPersistentEffects = gainedPersistentEffects.concat(effect.value.getPersistentEffects()); - // }); - // } - // } + return triggeredAbilities; + } - // const lostAllNonKeywordsAbilities = this.anyEffect(EffectName.LoseAllNonKeywordAbilities); - // if (lostAllNonKeywordsAbilities) { - // let allAbilities = this.abilities.persistentEffects.concat(gainedPersistentEffects); - // allAbilities = allAbilities.filter((a) => a.isKeywordEffect || a.type === EffectName.AddKeyword); - // return allAbilities; - // } - // return this.isBlank() - // ? gainedPersistentEffects - // : this.abilities.persistentEffects.concat(gainedPersistentEffects); - // } + /** + * "Constant abilities" are any non-triggered passive ongoing abilities (SWU 3.1) + */ + get constantAbilities(): IConstantAbility[] { + return this.getConstantAbilities(); + } - // get persistentEffects(): any[] { - // return this._getPersistentEffects(); - // } + private getConstantAbilities(): any[] { + // const lostAllNonKeywordsAbilities = this.anyEffect(EffectName.LoseAllNonKeywordAbilities); + // if (lostAllNonKeywordsAbilities) { + // let allAbilities = this.abilities.constant.concat(gainedPersistentEffects); + // allAbilities = allAbilities.filter((a) => a.isKeywordEffect || a.type === EffectName.AddKeyword); + // return allAbilities; + // } + return this.isBlank() ? [] + : this.abilities.constant; + } - // TODO: should this class be abstract? + // TODO: make this abstract eventually /** * Create card abilities by calling subsequent methods with appropriate properties * @param {Object} ability - AbilityDsl object containing limits, costs, effects, and game actions */ // eslint-disable-next-line @typescript-eslint/no-empty-function - setupCardAbilities(ability) { + protected setupCardAbilities(ability) { } - action(properties: IActionProps): void { - this.abilities.actions.push(this.createAction(properties)); + protected setupPlayAbilities() { + if (this.type === CardType.Unit) { + this.abilities.playCardAction.push(new PlayUnitAction(this)); + } } - createAction(properties: IActionProps): CardActionAbility { - return new CardActionAbility(this.game, this, properties); + protected actionAbility(properties: IActionProps): void { + this.abilities.action.push(this.createActionAbility(properties)); } - // triggeredAbility(abilityType: AbilityType, properties: TriggeredAbilityProps): void { - // this.abilities.reactions.push(this.createTriggeredAbility(abilityType, properties)); - // } - - // createTriggeredAbility(abilityType: AbilityType, properties: TriggeredAbilityProps): TriggeredAbility { - // return new TriggeredAbility(this.game, this, abilityType, properties); - // } - - // reaction(properties: TriggeredAbilityProps): void { - // this.triggeredAbility(AbilityType.Reaction, properties); - // } + private createActionAbility(properties: IActionProps): CardActionAbility { + return new CardActionAbility(this.game, this, properties); + } - // forcedReaction(properties: TriggeredAbilityProps): void { - // this.triggeredAbility(AbilityType.ForcedReaction, properties); - // } + triggeredAbility(properties: ITriggeredAbilityProps): void { + this.abilities.triggered.push(this.createTriggeredAbility(properties)); + } - // wouldInterrupt(properties: TriggeredAbilityProps): void { - // this.triggeredAbility(AbilityType.WouldInterrupt, properties); - // } + createTriggeredAbility(properties: ITriggeredAbilityProps): TriggeredAbility { + return new TriggeredAbility(this.game, this, AbilityType.ForcedReaction, properties); + } - // interrupt(properties: TriggeredAbilityProps): void { - // this.triggeredAbility(AbilityType.Interrupt, properties); - // } + whenPlayedAbility(properties: Omit): void { + const triggeredProperties = Object.assign(properties, { when: { onUnitEntersPlay: (event) => event.card === this } }); + this.abilities.triggered.push(this.createTriggeredAbility(triggeredProperties)); + } - // forcedInterrupt(properties: TriggeredAbilityProps): void { - // this.triggeredAbility(AbilityType.ForcedInterrupt, properties); - // } - // /** - // * Applies an effect that continues as long as the card providing the effect - // * is both in play and not blank. - // */ - // persistentEffect(properties: PersistentEffectProps): void { - // const allowedLocations = [ - // Location.Any, - // Location.ConflictDiscardPile, - // Location.PlayArea, - // Location.Provinces - // ]; - // const defaultLocationForType = { - // province: Location.Provinces, - // holding: Location.Provinces, - // stronghold: Location.Provinces - // }; + /** + * Applies an effect that continues as long as the card providing the effect + * is both in play and not blank. + */ + protected constantAbility(properties: IConstantAbilityProps): void { + const allowedLocations = [ + WildcardLocation.Any, + Location.Discard, + WildcardLocation.AnyArena, + Location.Leader, + Location.Base, + ]; + const defaultLocationForType = { + leader: Location.Leader, + base: Location.Base, + }; - // let location = properties.location || defaultLocationForType[this.getType()] || isArena(properties.location); - // if (!allowedLocations.includes(location)) { - // throw new Error(`'${location}' is not a supported effect location.`); - // } - // this.abilities.persistentEffects.push({ duration: Duration.Persistent, location, ...properties }); - // } + const locationFilter = properties.locationFilter || defaultLocationForType[this.getType()] || WildcardLocation.AnyArena; + if (!allowedLocations.includes(locationFilter)) { + throw new Error(`'${locationFilter}' is not a supported effect location.`); + } + this.abilities.constant.push({ duration: Duration.Persistent, locationFilter, ...properties }); + } // attachmentConditions(properties: AttachmentConditionProps): void { // const effects = []; @@ -556,13 +487,10 @@ class Card extends EffectSource { } getTraitSet(): Set { - const copyEffect = this.mostRecentEffect(EffectName.CopyCharacter); const set = new Set( - copyEffect - ? (copyEffect.traits as string[]) - : this.getEffects(EffectName.Blank).some((blankTraits: boolean) => blankTraits) - ? [] - : this.traits + this.getEffects(EffectName.Blank).some((blankTraits: boolean) => blankTraits) + ? [] + : this.traits ); for (const gainedTrait of this.getEffects(EffectName.AddTrait)) { @@ -597,72 +525,82 @@ class Card extends EffectSource { // applyAnyLocationPersistentEffects(): void { // for (const effect of this.persistentEffects) { // if (effect.location === Location.Any) { - // effect.ref = this.addEffectToEngine(effect); + // effect.registeredEffects = this.addEffectToEngine(effect); // } // } // } - // updateAbilityEvents(from: Location, to: Location, reset: boolean = true) { - // if (reset) { - // this.#resetLimits(); - // } - // for (const reaction of this.reactions) { - // if (this.type === CardType.Event) { - // if ( - // to === Location.ConflictDeck || - // this.controller.isCardInPlayableLocation(this) || - // (this.controller.opponent && this.controller.opponent.isCardInPlayableLocation(this)) - // ) { - // reaction.registerEvents(); - // } else { - // reaction.unregisterEvents(); - // } - // } else if (reaction.location.includes(to) && !reaction.location.includes(from)) { - // reaction.registerEvents(); - // } else if (!reaction.location.includes(to) && reaction.location.includes(from)) { - // reaction.unregisterEvents(); - // } - // } - // } + private resetLimits() { + for (const action of this.abilities.action) { + action.limit.reset(); + } + for (const triggeredAbility of this.abilities.triggered) { + triggeredAbility.limit.reset(); + } + } - // updateEffects(from: Location, to: Location) { - // const activeLocations = { - // 'conflict discard pile': [Location.ConflictDiscardPile], - // 'play area': [Location.PlayArea], - // province: this.game.getProvinceArray() - // }; - // if ( - // !activeLocations[Location.Provinces].includes(from) || - // !activeLocations[Location.Provinces].includes(to) - // ) { - // this.removeLastingEffects(); - // } - // this.updateStatusTokenEffects(); - // for (const effect of this.persistentEffects) { - // if (effect.location === Location.Any) { - // continue; - // } - // if (activeLocations[effect.location].includes(to) && !activeLocations[effect.location].includes(from)) { - // effect.ref = this.addEffectToEngine(effect); - // } else if ( - // !activeLocations[effect.location].includes(to) && - // activeLocations[effect.location].includes(from) - // ) { - // this.removeEffectFromEngine(effect.ref); - // effect.ref = []; - // } - // } - // } + private updateAbilityEvents(from: Location, to: Location, reset: boolean = true) { + if (reset) { + this.resetLimits(); + } + for (const triggeredAbility of this.triggeredAbilities) { + if (this.type === CardType.Event) { + // TODO: this is block is here because the only reaction to register on an event was the bluff window 'reaction', we have real ones now + if ( + to === Location.Deck || + this.controller.isCardInPlayableLocation(this) || + (this.controller.opponent && this.controller.opponent.isCardInPlayableLocation(this)) + ) { + triggeredAbility.registerEvents(); + } else { + triggeredAbility.unregisterEvents(); + } + } else if (cardLocationMatches(to, triggeredAbility.location) && !cardLocationMatches(from, triggeredAbility.location)) { + triggeredAbility.registerEvents(); + } else if (!cardLocationMatches(to, triggeredAbility.location) && cardLocationMatches(from, triggeredAbility.location)) { + triggeredAbility.unregisterEvents(); + } + } + } - // updateEffectContexts() { - // for (const effect of this.persistentEffects) { - // if (effect.ref) { - // for (const e of effect.ref) { - // e.refreshContext(); - // } - // } - // } - // } + private updateEffects(from: Location, to: Location) { + // removing any lasting effects from ourself + if (!isArena(from) && !isArena(to)) { + this.removeLastingEffects(); + } + + // TODO: is this needed for upgrades? + // this.updateStatusTokenEffects(); + + // check to register / unregister any effects that we are the source of + for (const constantAbility of this.constantAbilities) { + if (constantAbility.locationFilter === WildcardLocation.Any) { + continue; + } + if ( + !cardLocationMatches(from, constantAbility.locationFilter) && + cardLocationMatches(to, constantAbility.locationFilter) + ) { + constantAbility.registeredEffects = this.addEffectToEngine(constantAbility); + } else if ( + cardLocationMatches(from, constantAbility.locationFilter) && + !cardLocationMatches(to, constantAbility.locationFilter) + ) { + this.removeEffectFromEngine(constantAbility.registeredEffects); + constantAbility.registeredEffects = []; + } + } + } + + updateConstantAbilityContexts() { + for (const constantAbility of this.constantAbilities) { + if (constantAbility.registeredEffects) { + for (const effect of constantAbility.registeredEffects) { + effect.refreshContext(); + } + } + } + } moveTo(targetLocation: Location) { const originalLocation = this.location; @@ -675,8 +613,8 @@ class Card extends EffectSource { this.setDefaultStatusForLocation(targetLocation); if (originalLocation !== targetLocation) { - // this.updateAbilityEvents(originalLocation, targetLocation, !sameLocation); - // this.updateEffects(originalLocation, targetLocation); + this.updateAbilityEvents(originalLocation, targetLocation); + this.updateEffects(originalLocation, targetLocation); this.game.emitEvent(EventName.OnCardMoved, { card: this, originalLocation: originalLocation, @@ -748,7 +686,7 @@ class Card extends EffectSource { } // getReactions(): any[] { - // return this.reactions.slice(); + // return this.triggeredAbilities.slice(); // } readiesDuringReadyPhase(): boolean { @@ -1016,10 +954,10 @@ class Card extends EffectSource { if (this.type === CardType.Event) { return this.getActions(); } - const actions = this.abilities.playActions.slice(); - if (this.type === CardType.Unit) { - actions.push(new PlayUnitAction(this)); - } + const actions = this.abilities.playCardAction.slice(); + // if (this.type === CardType.Unit) { + // actions.push(new PlayUnitAction(this)); + // } // else if (this.type === CardType.Upgrade) { // actions.push(new PlayAttachmentAction(this)); // } @@ -1157,7 +1095,15 @@ class Card extends EffectSource { } addDamage(amount: number) { - if (isNaN(this.hp) || amount === 0) { + if ( + !Contract.assertNotNullLikeOrNan(this.damage) || + !Contract.assertNotNullLikeOrNan(this.hp) || + !Contract.assertNonNegative(amount) + ) { + return; + } + + if (amount === 0) { return; } @@ -1172,7 +1118,25 @@ class Card extends EffectSource { } } - // TODO: type annotations for all of the hp stuff + /** @returns True if any damage was healed, false otherwise */ + removeDamage(amount: number): boolean { + if ( + !Contract.assertNotNullLikeOrNan(this.damage) || + !Contract.assertNotNullLikeOrNan(this.hp) || + !Contract.assertNonNegative(amount) + ) { + return false; + } + + if (amount === 0 || this.damage === 0) { + return false; + } + + this.damage -= Math.min(amount, this.damage); + return true; + } + + // TODO: type annotations for all of the hp methods get hp(): number | null { return this.getHp(); } @@ -1338,7 +1302,6 @@ class Card extends EffectSource { */ getBaseStatModifiers() { const baseModifierEffects = [ - EffectName.CopyCharacter, EffectName.CalculatePrintedPower, EffectName.SetBasePower, ]; @@ -1369,35 +1332,6 @@ class Card extends EffectSource { ); break; } - case EffectName.CopyCharacter: { - const copiedCard = effect.getValue(this); - basePower = copiedCard.getPrintedStat(StatType.Power); - baseHp = copiedCard.getPrintedStat(StatType.Hp); - // replace existing base or copied modifier - basePowerModifiers = basePowerModifiers.filter( - (mod) => !mod.name.startsWith('Printed stat') - ); - baseHpModifiers = baseHpModifiers.filter( - (mod) => !mod.name.startsWith('Printed stat') - ); - basePowerModifiers.push( - StatModifier.fromEffect( - basePower, - effect, - false, - `Printed skill from ${copiedCard.name} due to ${StatModifier.getEffectName(effect)}` - ) - ); - baseHpModifiers.push( - StatModifier.fromEffect( - baseHp, - effect, - false, - `Printed skill from ${copiedCard.name} due to ${StatModifier.getEffectName(effect)}` - ) - ); - break; - } case EffectName.SetBasePower: basePower = effect.getValue(this); basePowerModifiers.push( @@ -1438,9 +1372,8 @@ class Card extends EffectSource { // ************************************** DECKCARD.JS **************************************************** // ******************************************************************************************************* - getCost() { - const copyEffect = this.mostRecentEffect(EffectName.CopyCharacter); - return copyEffect ? copyEffect.printedCost : this.printedCost; + get cost() { + return this.printedCost; } costLessThan(num) { diff --git a/server/game/core/chat/ChatCommands.js b/server/game/core/chat/ChatCommands.js index 432c96f54..6c170aa06 100644 --- a/server/game/core/chat/ChatCommands.js +++ b/server/game/core/chat/ChatCommands.js @@ -7,8 +7,7 @@ class ChatCommands { this.game = game; this.commands = { '/draw': this.draw, - '/discard': this.discard, - // '/token': this.setToken, + // '/discard': this.discard, // '/reveal': this.reveal, '/move-to-bottom-deck': this.moveCardToDeckBottom, '/stop-clocks': this.stopClocks, @@ -59,13 +58,13 @@ class ChatCommands { player.drawCardsToHand(num); } - discard(player, args) { - var num = this.getNumberOrDefault(args[1], 1); + // 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' : ''); + // 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()); - } + // GameSystems.discardAtRandom({ amount: num }).resolve(player, this.game.getFrameworkContext()); + // } moveCardToDeckBottom(player) { this.game.promptForSelect(player, { diff --git a/server/game/core/cost/MetaActionCost.ts b/server/game/core/cost/MetaActionCost.ts index 157b00990..afcdecb2c 100644 --- a/server/game/core/cost/MetaActionCost.ts +++ b/server/game/core/cost/MetaActionCost.ts @@ -15,7 +15,7 @@ export class MetaActionCost extends GameActionCost implements ICost { } override getActionName(context: AbilityContext): string { - const { gameSystem } = this.gameSystem.generatePropertiesFromContext(context) as ISelectCardProperties; + const { innerSystem: gameSystem } = this.gameSystem.generatePropertiesFromContext(context) as ISelectCardProperties; return gameSystem.name; } @@ -31,13 +31,13 @@ export class MetaActionCost extends GameActionCost implements ICost { override addEventsToArray(events: any[], context: AbilityContext, result: Result): void { const properties = this.gameSystem.generatePropertiesFromContext(context) as ISelectCardProperties; if (properties.targets && context.choosingPlayerOverride) { - context.costs[properties.gameSystem.name] = randomItem( + context.costs[properties.innerSystem.name] = randomItem( properties.selector.getAllLegalTargets(context, context.player) ); - context.costs[properties.gameSystem.name + 'StateWhenChosen'] = - context.costs[properties.gameSystem.name].createSnapshot(); - return properties.gameSystem.addEventsToArray(events, context, { - target: context.costs[properties.gameSystem.name] + context.costs[properties.innerSystem.name + 'StateWhenChosen'] = + context.costs[properties.innerSystem.name].createSnapshot(); + return properties.innerSystem.addEventsToArray(events, context, { + target: context.costs[properties.innerSystem.name] }); } @@ -47,11 +47,11 @@ export class MetaActionCost extends GameActionCost implements ICost { controller: RelativePlayer.Self, cancelHandler: !result.canCancel ? null : () => (result.cancelled = true), subActionProperties: (target: any) => { - context.costs[properties.gameSystem.name] = target; + context.costs[properties.innerSystem.name] = target; if (target.createSnapshot) { - context.costs[properties.gameSystem.name + 'StateWhenChosen'] = target.createSnapshot(); + context.costs[properties.innerSystem.name + 'StateWhenChosen'] = target.createSnapshot(); } - return properties.subActionProperties ? properties.subActionProperties(target) : {}; + return properties.innerSystemProperties ? properties.innerSystemProperties(target) : {}; } }; this.gameSystem.addEventsToArray(events, context, additionalProps); @@ -63,6 +63,6 @@ export class MetaActionCost extends GameActionCost implements ICost { override getCostMessage(context: AbilityContext): [string, any[]] { const properties = this.gameSystem.generatePropertiesFromContext(context) as ISelectCardProperties; - return properties.gameSystem.getCostMessage(context); + return properties.innerSystem.getCostMessage(context); } } diff --git a/server/game/core/effect/Effect.js b/server/game/core/effect/Effect.js index 19e9312e9..173063e41 100644 --- a/server/game/core/effect/Effect.js +++ b/server/game/core/effect/Effect.js @@ -6,16 +6,16 @@ const { isArena } = require('../utils/EnumHelpers'); * Represents a card based effect applied to one or more targets. * * Properties: - * match - function that takes a card/player/ring and context object + * match - function that takes a card/player and context object * and returns a boolean about whether the passed object should - * have the effect applied. Alternatively, a card/player/ring can + * have the effect applied. Alternatively, a card/player can * be passed as the match property to match that single object. - * Doesn't apply to conflict effects. + * Doesn't apply to attack effects. (TODO: still true?) * duration - string representing how long the effect lasts. * condition - function that returns a boolean determining whether the * effect can be applied. Use with cards that have a * condition that must be met before applying a persistent - * effect (e.g. 'during a conflict'). + * effect (e.g. 'when exhausted'). * location - location where the source of this effect needs to be for * the effect to be active. Defaults to 'play area'. * targetController - string that determines which player's cards are targeted. @@ -26,26 +26,27 @@ const { isArena } = require('../utils/EnumHelpers'); * 'province', or a specific location (e.g. 'stronghold province' * or 'hand'). This has no effect if a specific card is passed * to match. Card effects only. - * effectDetails - object with details of effect to be applied. Includes duration - * and the numerical value of the effect, if any. + * impl - object with details of effect to be applied. Includes duration + * and the numerical value of the effect, if any. */ class Effect { - constructor(game, source, properties, effectDetails) { + constructor(game, source, properties, effectImpl) { this.game = game; this.source = source; this.match = properties.match || (() => true); this.duration = properties.duration; this.until = properties.until || {}; this.condition = properties.condition || (() => true); + // UP NEXT: this needs to be changed to match the name locationFilter (or vice versa). so does the caller (can't remember which) this.location = properties.location || isArena(properties.location); this.canChangeZoneOnce = !!properties.canChangeZoneOnce; this.canChangeZoneNTimes = properties.canChangeZoneNTimes || 0; - this.effectDetails = effectDetails; + this.impl = effectImpl; this.ability = properties.ability; this.targets = []; this.refreshContext(); - this.effectDetails.duration = this.duration; - this.effectDetails.isConditional = !!properties.condition; + this.impl.duration = this.duration; + this.impl.isConditional = !!properties.condition; } refreshContext() { @@ -54,14 +55,14 @@ class Effect { if (this.ability) { this.context.ability = this.ability; } - this.effectDetails.setContext(this.context); + this.impl.setContext(this.context); } - isValidTarget(target) { // eslint-disable-line no-unused-vars + isValidTarget(target) { return true; } - getDefaultTarget(context) { // eslint-disable-line no-unused-vars + getDefaultTarget(context) { return null; } @@ -71,7 +72,7 @@ class Effect { addTarget(target) { this.targets.push(target); - this.effectDetails.apply(target); + this.impl.apply(target); } removeTarget(target) { @@ -79,7 +80,7 @@ class Effect { } removeTargets(targets) { - targets.forEach((target) => this.effectDetails.unapply(target)); + targets.forEach((target) => this.impl.unapply(target)); this.targets = _.difference(this.targets, targets); } @@ -88,7 +89,7 @@ class Effect { } cancel() { - _.each(this.targets, (target) => this.effectDetails.unapply(target)); + _.each(this.targets, (target) => this.impl.unapply(target)); this.targets = []; } @@ -96,7 +97,7 @@ class Effect { if (this.duration !== Duration.Persistent) { return true; } - let effectOnSource = this.source.persistentEffects.some((effect) => effect.ref && effect.ref.includes(this)); + let effectOnSource = this.source.constantAbilities.some((effect) => effect.registeredEffects && effect.registeredEffects.includes(this)); return !this.source.facedown && effectOnSource; } @@ -112,7 +113,7 @@ class Effect { this.removeTargets(invalidTargets); stateChanged = stateChanged || invalidTargets.length > 0; // Recalculate the effect for valid targets - _.each(this.targets, (target) => stateChanged = this.effectDetails.recalculate(target) || stateChanged); + _.each(this.targets, (target) => stateChanged = this.impl.recalculate(target) || stateChanged); // Check for new targets let newTargets = _.filter(this.getTargets(), (target) => !this.targets.includes(target) && this.isValidTarget(target)); // Apply the effect to new targets @@ -123,7 +124,7 @@ class Effect { this.cancel(); return true; } - return this.effectDetails.recalculate(this.match) || stateChanged; + return this.impl.recalculate(this.match) || stateChanged; } else if (!this.targets.includes(this.match) && this.isValidTarget(this.match)) { this.addTarget(this.match); return true; @@ -137,7 +138,7 @@ class Effect { targets: _.map(this.targets, (target) => target.name).join(','), active: this.isEffectActive(), condition: this.condition(this.context), - effect: this.effectDetails.getDebugInfo() + effect: this.impl.getDebugInfo() }; } } diff --git a/server/game/core/effect/EffectBuilder.ts b/server/game/core/effect/EffectBuilder.ts index 72353ec5e..643bb7e82 100644 --- a/server/game/core/effect/EffectBuilder.ts +++ b/server/game/core/effect/EffectBuilder.ts @@ -9,10 +9,10 @@ import type Player from '../Player'; // import type { StatusToken } from '../StatusToken'; import CardEffect from './CardEffect'; // import ConflictEffect from './ConflictEffect'; -import DetachedEffectDetails from './effectDetails/DetachedEffectDetails'; -import DynamicEffectDetails from './effectDetails/DynamicEffectDetails'; -import PlayerEffectDetails from './PlayerEffect'; -import StaticEffectDetails from './effectDetails/StaticEffectDetails'; +import DetachedEffectImpl from './effectImpl/DetachedEffectImpl'; +import DynamicEffectImpl from './effectImpl/DynamicEffectImpl'; +import PlayerEffect from './PlayerEffect'; +import StaticEffectImpl from './effectImpl/StaticEffectImpl'; type PlayerOrCard = Player | Card; @@ -30,14 +30,20 @@ interface Props { parentAction?: GameSystem; } +/* Types of effect + 1. Static effects - do something for a period + 2. Dynamic effects - like static, but what they do depends on the game state + 3. Detached effects - do something when applied, and on expiration, but can be ignored in the interim +*/ + export const EffectBuilder = { card: { static: (type: EffectName, value) => (game: Game, source: Card, props: Props) => - new CardEffect(game, source, props, new StaticEffectDetails(type, value)), + new CardEffect(game, source, props, new StaticEffectImpl(type, value)), dynamic: (type: EffectName, value) => (game: Game, source: Card, props: Props) => - new CardEffect(game, source, props, new DynamicEffectDetails(type, value)), + new CardEffect(game, source, props, new DynamicEffectImpl(type, value)), detached: (type: EffectName, value) => (game: Game, source: Card, props: Props) => - new CardEffect(game, source, props, new DetachedEffectDetails(type, value.apply, value.unapply)), + new CardEffect(game, source, props, new DetachedEffectImpl(type, value.apply, value.unapply)), flexible: (type: EffectName, value?: unknown) => (typeof value === 'function' ? EffectBuilder.card.dynamic(type, value) @@ -45,11 +51,11 @@ export const EffectBuilder = { }, player: { static: (type: EffectName, value) => (game: Game, source: Card, props: Props) => - new PlayerEffectDetails(game, source, props, new StaticEffectDetails(type, value)), + new PlayerEffect(game, source, props, new StaticEffectImpl(type, value)), dynamic: (type: EffectName, value) => (game: Game, source: Card, props: Props) => - new PlayerEffectDetails(game, source, props, new DynamicEffectDetails(type, value)), + new PlayerEffect(game, source, props, new DynamicEffectImpl(type, value)), detached: (type: EffectName, value) => (game: Game, source: Card, props: Props) => - new PlayerEffectDetails(game, source, props, new DetachedEffectDetails(type, value.apply, value.unapply)), + new PlayerEffect(game, source, props, new DetachedEffectImpl(type, value.apply, value.unapply)), flexible: (type: EffectName, value) => (typeof value === 'function' ? EffectBuilder.player.dynamic(type, value) diff --git a/server/game/core/effect/EffectEngine.ts b/server/game/core/effect/EffectEngine.ts index 9fc41e5e6..40e9632a4 100644 --- a/server/game/core/effect/EffectEngine.ts +++ b/server/game/core/effect/EffectEngine.ts @@ -11,6 +11,7 @@ interface ICustomDurationEvent { effect: Effect; } +// UP NEXT: rename "Effect" to "OngoingEffect" export class EffectEngine { events: EventRegistrar; effects: Effect[] = []; @@ -39,9 +40,9 @@ export class EffectEngine { const effectsToTrigger: Effect[] = []; const effectsToRemove: Effect[] = []; for (const effect of this.effects.filter( - (effect) => effect.isEffectActive() && effect.effectDetails.type === EffectName.DelayedEffect + (effect) => effect.isEffectActive() && effect.impl.type === EffectName.DelayedEffect )) { - const properties = effect.effectDetails.getValue(); + const properties = effect.impl.getValue(); if (properties.condition) { if (properties.condition(effect.context)) { effectsToTrigger.push(effect); @@ -59,7 +60,7 @@ export class EffectEngine { } } const effectTriggers = effectsToTrigger.map((effect) => { - const properties = effect.effectDetails.getValue(); + const properties = effect.impl.getValue(); const context = effect.context; const targets = effect.targets; return { @@ -106,6 +107,7 @@ export class EffectEngine { } } + // UP NEXT: rename this to something that makes it clearer that it's needed for updating effect status checkEffects(prevStateChanged = false, loops = 0) { if (!prevStateChanged && !this.effectsChangedSinceLastCheck) { return false; diff --git a/server/game/core/effect/EffectSource.js b/server/game/core/effect/EffectSource.js index 04edccaa5..ca8e0b703 100644 --- a/server/game/core/effect/EffectSource.js +++ b/server/game/core/effect/EffectSource.js @@ -4,59 +4,60 @@ const AbilityDsl = require('../../AbilityDsl.js'); const { GameObject } = require('../GameObject.js'); const { Duration, WildcardLocation } = require('../Constants.js'); +const Effect = require('./Effect.js'); -// This class is inherited by Ring and BaseCard and also represents Framework effects +// This class is inherited by Card and also represents Framework effects class EffectSource extends GameObject { constructor(game, name = 'Framework effect') { super(game, name); } - // /** - // * Applies an immediate effect which lasts until the end of the phase. - // */ - // untilEndOfPhase(propertyFactory) { - // var properties = propertyFactory(AbilityDsl); - // this.addEffectToEngine(Object.assign({ duration: Duration.UntilEndOfPhase, location: WildcardLocation.Any }, properties)); - // } - - // /** - // * Applies an immediate effect which lasts until the end of the round. - // */ - // untilEndOfRound(propertyFactory) { - // var properties = propertyFactory(AbilityDsl); - // this.addEffectToEngine(Object.assign({ duration: Duration.UntilEndOfRound, location: WildcardLocation.Any }, properties)); - // } - - // /** - // * Applies a lasting effect which lasts until an event contained in the - // * `until` property for the effect has occurred. - // */ - // lastingEffect(propertyFactory) { - // let properties = propertyFactory(AbilityDsl); - // this.addEffectToEngine(Object.assign({ duration: Duration.Custom, location: WildcardLocation.Any }, properties)); - // } - - // /* - // * Adds a persistent/lasting/delayed effect to the effect engine - // * @param {Object} properties - properties for the effect - see Effects/Effect.js - // */ - // addEffectToEngine(properties) { - // let effect = properties.effect; - // properties = _.omit(properties, 'effect'); - // if(Array.isArray(effect)) { - // return effect.map(factory => this.game.effectEngine.add(factory(this.game, this, properties))); - // } - // return [this.game.effectEngine.add(effect(this.game, this, properties))]; - // } - - // removeEffectFromEngine(effectArray) { - // this.game.effectEngine.unapplyAndRemove(effect => effectArray.includes(effect)); - // } - - // removeLastingEffects() { - // this.game.effectEngine.removeLastingEffects(this); - // } + /** + * Applies an effect which lasts until the end of the phase. + */ + untilEndOfPhase(propertyFactory) { + var properties = propertyFactory(AbilityDsl); + this.addEffectToEngine(Object.assign({ duration: Duration.UntilEndOfPhase, location: WildcardLocation.Any }, properties)); + } + + /** + * Applies an effect which lasts until the end of the round. + */ + untilEndOfRound(propertyFactory) { + var properties = propertyFactory(AbilityDsl); + this.addEffectToEngine(Object.assign({ duration: Duration.UntilEndOfRound, location: WildcardLocation.Any }, properties)); + } + + /** + * Applies a 'lasting effect' (SWU 7.3) which lasts until an event contained in the `until` property for the effect has occurred. + */ + lastingEffect(propertyFactory) { + let properties = propertyFactory(AbilityDsl); + this.addEffectToEngine(Object.assign({ duration: Duration.Custom, location: WildcardLocation.Any }, properties)); + } + + /** + * Adds persistent/lasting/delayed effect(s) to the effect engine + * @param {Object} properties properties for the effect(s), see {@link Effect} + * @returns {Effect[]} the effect(s) that were added to the engine + */ + addEffectToEngine(properties) { + let effect = properties.effect; + properties = _.omit(properties, 'effect'); + if (Array.isArray(effect)) { + return effect.map((factory) => this.game.effectEngine.add(factory(this.game, this, properties))); + } + return [this.game.effectEngine.add(effect(this.game, this, properties))]; + } + + removeEffectFromEngine(effectArray) { + this.game.effectEngine.unapplyAndRemove((effect) => effectArray.includes(effect)); + } + + removeLastingEffects() { + this.game.effectEngine.removeLastingEffects(this); + } } module.exports = EffectSource; diff --git a/server/game/core/effect/IConstantAbility.ts b/server/game/core/effect/IConstantAbility.ts new file mode 100644 index 000000000..b6bc84e3a --- /dev/null +++ b/server/game/core/effect/IConstantAbility.ts @@ -0,0 +1,8 @@ +import type { Duration } from '../Constants'; +import type { IConstantAbilityProps } from '../../Interfaces'; +import Effect from './Effect'; + +export interface IConstantAbility extends IConstantAbilityProps { + duration: Duration; + registeredEffects?: Effect[]; +} diff --git a/server/game/core/effect/effectDetails/DetachedEffectDetails.js b/server/game/core/effect/effectDetails/DetachedEffectDetails.js deleted file mode 100644 index bcaec828c..000000000 --- a/server/game/core/effect/effectDetails/DetachedEffectDetails.js +++ /dev/null @@ -1,35 +0,0 @@ -const StaticEffectDetails = require('./StaticEffectDetails'); - -class DetachedEffectDetails extends StaticEffectDetails { - constructor(type, applyFunc, unapplyFunc) { - super(type); - this.applyFunc = applyFunc; - this.unapplyFunc = unapplyFunc; - this.state = {}; - } - - /** @override */ - apply(target) { - this.state[target.uuid] = this.applyFunc(target, this.context, this.state[target.uuid]); - } - - /** @override */ - unapply(target) { - this.state[target.uuid] = this.unapplyFunc(target, this.context, this.state[target.uuid]); - if (this.state[target.uuid] === undefined) { - delete this.state[target.uuid]; - } - } - - /** @override */ - setContext(context) { - this.context = context; - for (let state of Object.values(this.state)) { - if (state.context) { - state.context = context; - } - } - } -} - -module.exports = DetachedEffectDetails; diff --git a/server/game/core/effect/effectDetails/DynamicEffectDetails.js b/server/game/core/effect/effectDetails/DynamicEffectDetails.js deleted file mode 100644 index 34270eab4..000000000 --- a/server/game/core/effect/effectDetails/DynamicEffectDetails.js +++ /dev/null @@ -1,42 +0,0 @@ -const StaticEffectDetails = require('./StaticEffectDetails'); - -class DynamicEffectDetails extends StaticEffectDetails { - constructor(type, calculate) { - super(type); - this.values = {}; - this.calculate = calculate; - } - - /** @override */ - apply(target) { - super.apply(target); - this.recalculate(target); - } - - /** @override */ - recalculate(target) { - let oldValue = this.getValue(target); - let newValue = this.setValue(target, this.calculate(target, this.context)); - if (typeof oldValue === 'function' && typeof newValue === 'function') { - return oldValue.toString() !== newValue.toString(); - } - if (Array.isArray(oldValue) && Array.isArray(newValue)) { - return JSON.stringify(oldValue) !== JSON.stringify(newValue); - } - return oldValue !== newValue; - } - - /** @override */ - getValue(target) { - if (target) { - return this.values[target.uuid]; - } - } - - setValue(target, value) { - this.values[target.uuid] = value; - return value; - } -} - -module.exports = DynamicEffectDetails; diff --git a/server/game/core/effect/effectDetails/EffectValue.ts b/server/game/core/effect/effectDetails/EffectValue.ts deleted file mode 100644 index 98199da5a..000000000 --- a/server/game/core/effect/effectDetails/EffectValue.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { AbilityContext } from '../../ability/AbilityContext'; - -export class EffectValue { - value: V; - context?: AbilityContext; - - constructor(value: V) { - // @ts-expect-error - this.value = value == null ? true : value; - } - - public setContext(context: AbilityContext): void { - this.context = context; - } - - public setValue(value: V) { - this.value = value; - } - - public getValue(): V { - return this.value; - } - - public recalculate(): boolean { - return false; - } - - // TODO: should probably have a subclass that adds these instead of having them empty here - // eslint-disable-next-line @typescript-eslint/no-empty-function - public reset(): void {} - - // eslint-disable-next-line @typescript-eslint/no-empty-function - public apply(target): void {} - - // eslint-disable-next-line @typescript-eslint/no-empty-function - public unapply(target): void {} -} diff --git a/server/game/core/effect/effectDetails/GainAbility.js b/server/game/core/effect/effectDetails/GainAbility.js deleted file mode 100644 index 1f32a6f37..000000000 --- a/server/game/core/effect/effectDetails/GainAbility.js +++ /dev/null @@ -1,101 +0,0 @@ -const { EffectValue } = require('./EffectValue'); -const { AbilityType, Location, WildcardLocation } = require('../../Constants'); - -class GainAbility extends EffectValue { - constructor(abilityType, ability) { - super(true); - this.abilityType = abilityType; - if (ability.createCopies) { - this.createCopies = true; - this.forCopying = {}; - this.forCopying.abilityType = abilityType; - this.forCopying.ability = ability; - } - this.grantedAbilityLimits = {}; - if (ability.properties) { - let newProps = { - printedAbility: false, - abilityIdentifier: ability.abilityIdentifier, - origin: ability.card - }; - if (ability.properties.limit) { - // If the copied ability has a limit, we need to create a new instantiation of it, with the same max and reset event - newProps.limit = ability.properties.limit.clone(); - } - if (ability.properties.max) { - // Same for max - newProps.max = ability.properties.max.clone(); - } - this.properties = Object.assign({}, ability.properties, newProps); - } else { - this.properties = Object.assign({ printedAbility: false }, ability); - } - if (abilityType === AbilityType.Persistent && !this.properties.location) { - this.properties.location = WildcardLocation.AnyArena; - this.properties.abilityType = AbilityType.Persistent; - } - } - - getCopy() { - if (this.createCopies) { - const ability = new GainAbility(this.forCopying.abilityType, this.forCopying.ability); - ability.context = this.context; - return ability; - } - return this; - } - - /** @override */ - reset() { - this.grantedAbilityLimits = {}; - } - - // apply(target) { - // let properties = Object.assign({ origin: this.context.source }, this.properties); - // if (this.abilityType === AbilityType.Persistent) { - // const activeLocations = { - // 'play area': [Location.PlayArea], - // province: this.context.game.getProvinceArray() - // }; - // this.value = properties; - // if (activeLocations[this.value.location].includes(target.location)) { - // this.value.ref = target.addEffectToEngine(this.value); - // } - // return; - // } else if (this.abilityType === AbilityType.Action) { - // this.value = target.createAction(properties); - // } else { - // this.value = target.createTriggeredAbility(this.abilityType, properties); - // this.value.registerEvents(); - // } - // if (!this.grantedAbilityLimits[target.uuid]) { - // this.grantedAbilityLimits[target.uuid] = this.value.limit; - // } else { - // this.value.limit = this.grantedAbilityLimits[target.uuid]; - // } - // this.grantedAbilityLimits[target.uuid].currentUser = target.uuid; - // } - - /** @override */ - unapply(target) { - if (this.grantedAbilityLimits[target.uuid]) { - this.grantedAbilityLimits[target.uuid].currentUser = null; - } - if ( - [ - AbilityType.ForcedInterrupt, - AbilityType.ForcedReaction, - AbilityType.Interrupt, - AbilityType.Reaction, - AbilityType.WouldInterrupt - ].includes(this.abilityType) - ) { - this.value.unregisterEvents(); - } else if (this.abilityType === AbilityType.Persistent && this.value.ref) { - target.removeEffectFromEngine(this.value.ref); - delete this.value.ref; - } - } -} - -module.exports = GainAbility; diff --git a/server/game/core/effect/effectDetails/StaticEffectDetails.js b/server/game/core/effect/effectDetails/StaticEffectDetails.js deleted file mode 100644 index 0df3f9c40..000000000 --- a/server/game/core/effect/effectDetails/StaticEffectDetails.js +++ /dev/null @@ -1,180 +0,0 @@ -const _ = require('underscore'); -const { EffectValue } = require('./EffectValue'); -const { CardType, EffectName, Duration, AbilityType } = require('../../Constants'); -const GainAbility = require('./GainAbility'); - -const binaryCardEffects = [ - EffectName.Blank, - // EffectName.CanBeSeenWhenFacedown, - // EffectName.CannotParticipateAsAttacker, - // EffectName.CannotParticipateAsDefender, - EffectName.AbilityRestrictions, - // EffectName.DoesNotBow, - EffectName.DoesNotReady, - // EffectName.ShowTopConflictCard, - // EffectName.ShowTopDynastyCard -]; - -// const MilitaryModifiers = [ -// EffectName.ModifyBaseMilitarySkillMultiplier, -// EffectName.ModifyMilitarySkill, -// EffectName.ModifyMilitarySkillMultiplier, -// EffectName.ModifyBothSkills, -// EffectName.AttachmentMilitarySkillModifier -// ]; - -// const PoliticalModifiers = [ -// EffectName.ModifyBasePoliticalSkillMultiplier, -// EffectName.ModifyPoliticalSkill, -// EffectName.ModifyPoliticalSkillMultiplier, -// EffectName.ModifyBothSkills, -// EffectName.AttachmentPoliticalSkillModifier -// ]; - -// const ProvinceStrengthModifiers = [ -// EffectName.ModifyProvinceStrength, -// EffectName.ModifyProvinceStrengthMultiplier, -// EffectName.SetBaseProvinceStrength -// ]; - -// const conflictingEffects = { -// modifyBaseMilitarySkillMultiplier: (card) => -// card.effects.filter((effect) => effect.type === EffectName.SetBaseMilitarySkill), -// modifyBasePoliticalSkillMultiplier: (card) => -// card.effects.filter((effect) => effect.type === EffectName.SetBasePoliticalSkill), -// modifyGlory: (card) => card.effects.filter((effect) => effect.type === EffectName.SetGlory), -// modifyMilitarySkill: (card) => card.effects.filter((effect) => effect.type === EffectName.SetMilitarySkill), -// modifyMilitarySkillMultiplier: (card) => -// card.effects.filter((effect) => effect.type === EffectName.SetMilitarySkill), -// modifyPoliticalSkill: (card) => card.effects.filter((effect) => effect.type === EffectName.SetPoliticalSkill), -// modifyPoliticalSkillMultiplier: (card) => -// card.effects.filter((effect) => effect.type === EffectName.SetPoliticalSkill), -// setBaseMilitarySkill: (card) => card.effects.filter((effect) => effect.type === EffectName.SetMilitarySkill), -// setBasePoliticalSkill: (card) => card.effects.filter((effect) => effect.type === EffectName.SetPoliticalSkill), -// setMaxConflicts: (player, value) => -// player.mostRecentEffect(EffectName.SetMaxConflicts) === value -// ? [_.last(player.effects.filter((effect) => effect.type === EffectName.SetMaxConflicts))] -// : [], -// takeControl: (card, player) => -// card.mostRecentEffect(EffectName.TakeControl) === player -// ? [_.last(card.effects.filter((effect) => effect.type === EffectName.TakeControl))] -// : [] -// }; - -class StaticEffectDetails { - constructor(type, value) { - this.type = type; - if (value instanceof EffectValue) { - this.value = value; - } else { - this.value = new EffectValue(value); - } - this.value.reset(); - this.context = null; - this.duration = null; - this.copies = []; - } - - apply(target) { - target.addEffect(this); - if (this.value instanceof GainAbility && this.value.abilityType === AbilityType.Persistent) { - const copy = this.value.getCopy(); - copy.apply(target); - this.copies.push(copy); - } else { - this.value.apply(target); - } - } - - unapply(target) { - target.removeEffect(this); - this.value.unapply(target); - this.copies.forEach((a) => a.unapply(target)); - this.copies = []; - } - - getValue() { - return this.value.getValue(); - } - - recalculate() { - return this.value.recalculate(); - } - - setContext(context) { - this.context = context; - this.value.setContext(context); - } - - // canBeApplied(target) { - // if (target.facedown && target.type !== CardType.Province) { - // return false; - // } - // return !hasDash[this.type] || !hasDash[this.type](target, this.value); - // } - - // isMilitaryModifier() { - // return MilitaryModifiers.includes(this.type); - // } - - // isPoliticalModifier() { - // return PoliticalModifiers.includes(this.type); - // } - - // isSkillModifier() { - // return this.isMilitaryModifier() || this.isPoliticalModifier(); - // } - - // isProvinceStrengthModifier() { - // return ProvinceStrengthModifiers.includes(this.type); - // } - - // checkConflictingEffects(type, target) { - // if (binaryCardEffects.includes(type)) { - // let matchingEffects = target.effects.filter((effect) => effect.type === type); - // return matchingEffects.every((effect) => this.hasLongerDuration(effect) || effect.isConditional); - // } - // if (conflictingEffects[type]) { - // let matchingEffects = conflictingEffects[type](target, this.getValue()); - // return matchingEffects.every((effect) => this.hasLongerDuration(effect) || effect.isConditional); - // } - // if (type === EffectName.ModifyBothSkills) { - // return ( - // this.checkConflictingEffects(EffectName.ModifyMilitarySkill, target) || - // this.checkConflictingEffects(EffectName.ModifyPoliticalSkill, target) - // ); - // } - // if (type === EffectName.HonorStatusDoesNotModifySkill) { - // return ( - // this.checkConflictingEffects(EffectName.ModifyMilitarySkill, target) || - // this.checkConflictingEffects(EffectName.ModifyPoliticalSkill, target) - // ); - // } - // if (type === EffectName.HonorStatusReverseModifySkill) { - // return ( - // this.checkConflictingEffects(EffectName.ModifyMilitarySkill, target) || - // this.checkConflictingEffects(EffectName.ModifyPoliticalSkill, target) - // ); - // } - // return true; - // } - - // hasLongerDuration(effect) { - // let durations = [ - // Duration.UntilEndOfDuel, - // Duration.UntilEndOfConflict, - // Duration.UntilEndOfPhase, - // Duration.UntilEndOfRound - // ]; - // return durations.indexOf(this.duration) > durations.indexOf(effect.duration); - // } - - getDebugInfo() { - return { - type: this.type, - value: this.value - }; - } -} - -module.exports = StaticEffectDetails; diff --git a/server/game/core/effect/effectImpl/GainAbility.js b/server/game/core/effect/effectImpl/GainAbility.js deleted file mode 100644 index 1f32a6f37..000000000 --- a/server/game/core/effect/effectImpl/GainAbility.js +++ /dev/null @@ -1,101 +0,0 @@ -const { EffectValue } = require('./EffectValue'); -const { AbilityType, Location, WildcardLocation } = require('../../Constants'); - -class GainAbility extends EffectValue { - constructor(abilityType, ability) { - super(true); - this.abilityType = abilityType; - if (ability.createCopies) { - this.createCopies = true; - this.forCopying = {}; - this.forCopying.abilityType = abilityType; - this.forCopying.ability = ability; - } - this.grantedAbilityLimits = {}; - if (ability.properties) { - let newProps = { - printedAbility: false, - abilityIdentifier: ability.abilityIdentifier, - origin: ability.card - }; - if (ability.properties.limit) { - // If the copied ability has a limit, we need to create a new instantiation of it, with the same max and reset event - newProps.limit = ability.properties.limit.clone(); - } - if (ability.properties.max) { - // Same for max - newProps.max = ability.properties.max.clone(); - } - this.properties = Object.assign({}, ability.properties, newProps); - } else { - this.properties = Object.assign({ printedAbility: false }, ability); - } - if (abilityType === AbilityType.Persistent && !this.properties.location) { - this.properties.location = WildcardLocation.AnyArena; - this.properties.abilityType = AbilityType.Persistent; - } - } - - getCopy() { - if (this.createCopies) { - const ability = new GainAbility(this.forCopying.abilityType, this.forCopying.ability); - ability.context = this.context; - return ability; - } - return this; - } - - /** @override */ - reset() { - this.grantedAbilityLimits = {}; - } - - // apply(target) { - // let properties = Object.assign({ origin: this.context.source }, this.properties); - // if (this.abilityType === AbilityType.Persistent) { - // const activeLocations = { - // 'play area': [Location.PlayArea], - // province: this.context.game.getProvinceArray() - // }; - // this.value = properties; - // if (activeLocations[this.value.location].includes(target.location)) { - // this.value.ref = target.addEffectToEngine(this.value); - // } - // return; - // } else if (this.abilityType === AbilityType.Action) { - // this.value = target.createAction(properties); - // } else { - // this.value = target.createTriggeredAbility(this.abilityType, properties); - // this.value.registerEvents(); - // } - // if (!this.grantedAbilityLimits[target.uuid]) { - // this.grantedAbilityLimits[target.uuid] = this.value.limit; - // } else { - // this.value.limit = this.grantedAbilityLimits[target.uuid]; - // } - // this.grantedAbilityLimits[target.uuid].currentUser = target.uuid; - // } - - /** @override */ - unapply(target) { - if (this.grantedAbilityLimits[target.uuid]) { - this.grantedAbilityLimits[target.uuid].currentUser = null; - } - if ( - [ - AbilityType.ForcedInterrupt, - AbilityType.ForcedReaction, - AbilityType.Interrupt, - AbilityType.Reaction, - AbilityType.WouldInterrupt - ].includes(this.abilityType) - ) { - this.value.unregisterEvents(); - } else if (this.abilityType === AbilityType.Persistent && this.value.ref) { - target.removeEffectFromEngine(this.value.ref); - delete this.value.ref; - } - } -} - -module.exports = GainAbility; diff --git a/server/game/core/effect/effectImpl/Restriction.ts b/server/game/core/effect/effectImpl/Restriction.ts new file mode 100644 index 000000000..43962ec23 --- /dev/null +++ b/server/game/core/effect/effectImpl/Restriction.ts @@ -0,0 +1,69 @@ +import { AbilityContext } from '../../ability/AbilityContext'; +import Player from '../../Player'; +import { EffectValue } from './EffectValue'; +import { restrictionDsl } from '../../../effects/RestrictionDsl'; +import type Card from '../../card/Card'; + +const leavePlayTypes = new Set(['discardFromPlay', 'returnToHand', 'returnToDeck', 'removeFromGame']); + +export interface RestrictionProperties { + type: string; + cannot?: string; + applyingPlayer?: Player; + restrictedActionCondition?: (context: AbilityContext) => boolean; + source?: Card; + params?: any; +} + +export class Restriction extends EffectValue { + type: string; + restrictedActionCondition?: (context: AbilityContext) => boolean; + applyingPlayer?: Player; + params?: any; + + constructor(properties: string | RestrictionProperties) { + super(null); + if (typeof properties === 'string') { + this.type = properties; + } else { + this.type = properties.type; + this.restrictedActionCondition = properties.restrictedActionCondition; + this.applyingPlayer = properties.applyingPlayer; + this.params = properties.params; + } + } + + override getValue() { + return this; + } + + isMatch(type, context, card) { + if (this.type === 'leavePlay') { + return leavePlayTypes.has(type) && this.checkCondition(context, card); + } + + return (!this.type || this.type === type) && this.checkCondition(context, card); + } + + checkCondition(context, card) { + if (Array.isArray(this.restrictedActionCondition)) { + const vals = this.restrictedActionCondition.map((a) => this.checkRestriction(a, context, card)); + return vals.every((a) => a); + } + + return this.checkRestriction(this.restrictedActionCondition, context, card); + } + + checkRestriction(restriction, context, card) { + if (!restriction) { + return true; + } else if (!context) { + throw new Error('checkCondition called without a context'); + } else if (typeof restriction === 'function') { + return restriction(context, this, card); + } else if (!restrictionDsl[restriction]) { + return context.source.hasTrait(restriction); + } + return restrictionDsl[restriction](context, this, card); + } +} diff --git a/server/game/core/effect/effectImpl/StaticEffectImpl.ts b/server/game/core/effect/effectImpl/StaticEffectImpl.ts index 71512802b..7587d3783 100644 --- a/server/game/core/effect/effectImpl/StaticEffectImpl.ts +++ b/server/game/core/effect/effectImpl/StaticEffectImpl.ts @@ -1,6 +1,5 @@ import { EffectValue } from './EffectValue'; import { CardType, EffectName, Duration, AbilityType } from '../../Constants'; -import GainAbility from './GainAbility'; import { AbilityContext } from '../../ability/AbilityContext'; import { EffectImpl } from './EffectImpl'; @@ -65,7 +64,6 @@ const binaryCardEffects = [ // TODO: readonly pass on class properties throughout the repo export default class StaticEffectImpl extends EffectImpl { readonly value: EffectValue; - private copies: GainAbility[] = []; // TODO: what exactly is this for? constructor(type: EffectName, value: EffectValue | TValue) { super(type); @@ -80,20 +78,12 @@ export default class StaticEffectImpl extends EffectImpl { apply(target) { target.addEffect(this); - if (this.value instanceof GainAbility && this.value.abilityType === AbilityType.Persistent) { - const copy = this.value.getCopy(); - copy.apply(target); - this.copies.push(copy); - } else { - this.value.apply(target); - } + this.value.apply(target); } unapply(target) { target.removeEffect(this); this.value.unapply(target); - this.copies.forEach((a) => a.unapply(target)); - this.copies = []; } getValue(target) { diff --git a/server/game/core/event/Event.ts b/server/game/core/event/Event.ts index 91e1c01b2..44ebe221e 100644 --- a/server/game/core/event/Event.ts +++ b/server/game/core/event/Event.ts @@ -7,6 +7,7 @@ interface Params { cannotBeCancelled: boolean; } +// TODO: rename to GameEvent to disambiguate from DOM events export class Event { cancelled = false; resolved = false; diff --git a/server/game/core/event/EventWindow.js b/server/game/core/event/EventWindow.js index 4e3ce50f7..8b9450195 100644 --- a/server/game/core/event/EventWindow.js +++ b/server/game/core/event/EventWindow.js @@ -26,10 +26,7 @@ class EventWindow extends BaseStepWithPipeline { this.pipeline.initialise([ new SimpleStep(this.game, () => this.setCurrentEventWindow()), new SimpleStep(this.game, () => this.checkEventCondition()), - new SimpleStep(this.game, () => this.openWindow(AbilityType.WouldInterrupt)), - new SimpleStep(this.game, () => this.createContingentEvents()), - new SimpleStep(this.game, () => this.openWindow(AbilityType.ForcedInterrupt)), - new SimpleStep(this.game, () => this.openWindow(AbilityType.Interrupt)), + // new SimpleStep(this.game, () => this.createContingentEvents()), // new SimpleStep(this.game, () => this.checkKeywordAbilities(AbilityType.KeywordInterrupt)), new SimpleStep(this.game, () => this.checkForOtherEffects()), new SimpleStep(this.game, () => this.preResolutionEffects()), @@ -72,25 +69,26 @@ class EventWindow extends BaseStepWithPipeline { return; } - if ([AbilityType.ForcedReaction, AbilityType.ForcedInterrupt].includes(abilityType)) { + if (abilityType === AbilityType.ForcedReaction) { this.queueStep(new ForcedTriggeredAbilityWindow(this.game, abilityType, this)); } else { this.queueStep(new TriggeredAbilityWindow(this.game, abilityType, this)); } } - // This is primarily for LeavesPlayEvents - createContingentEvents() { - let contingentEvents = []; - _.each(this.events, (event) => { - contingentEvents = contingentEvents.concat(event.createContingentEvents()); - }); - if (contingentEvents.length > 0) { - // Exclude current events from the new window, we just want to give players opportunities to respond to the contingent events - this.queueStep(new TriggeredAbilityWindow(this.game, AbilityType.WouldInterrupt, this, this.events.slice(0))); - _.each(contingentEvents, (event) => this.addEvent(event)); - } - } + // TODO: do we need this? + // // This is primarily for LeavesPlayEvents + // createContingentEvents() { + // let contingentEvents = []; + // _.each(this.events, (event) => { + // contingentEvents = contingentEvents.concat(event.createContingentEvents()); + // }); + // if (contingentEvents.length > 0) { + // // Exclude current events from the new window, we just want to give players opportunities to respond to the contingent events + // this.queueStep(new TriggeredAbilityWindow(this.game, AbilityType.WouldInterrupt, this, this.events.slice(0))); + // _.each(contingentEvents, (event) => this.addEvent(event)); + // } + // } // This catches any persistent/delayed effect cancels checkForOtherEffects() { @@ -115,8 +113,8 @@ class EventWindow extends BaseStepWithPipeline { } // checkGameState() { - // this.eventsToExecute = this.eventsToExecute.filter(event => !event.cancelled); - // this.game.checkGameState(_.any(this.eventsToExecute, event => event.handler), this.eventsToExecute); + // this.eventsToExecute = this.eventsToExecute.filter((event) => !event.cancelled); + // this.game.checkGameState(_.any(this.eventsToExecute, (event) => event.handler), this.eventsToExecute); // } // checkKeywordAbilities(abilityType) { diff --git a/server/game/core/gameSteps/abilityWindow/ForcedTriggeredAbilityWindow.js b/server/game/core/gameSteps/abilityWindow/ForcedTriggeredAbilityWindow.js index 7cda2521b..c5c18c4a4 100644 --- a/server/game/core/gameSteps/abilityWindow/ForcedTriggeredAbilityWindow.js +++ b/server/game/core/gameSteps/abilityWindow/ForcedTriggeredAbilityWindow.js @@ -37,6 +37,7 @@ class ForcedTriggeredAbilityWindow extends BaseStep { } } + // TODO: need to implement the SWU rules for simultaneous reaction effects filterChoices() { if (this.choices.length === 0) { return true; diff --git a/server/game/core/gameSteps/abilityWindow/InitiateAbilityEventWindow.js b/server/game/core/gameSteps/abilityWindow/InitiateAbilityEventWindow.js index db2d42614..762a876ab 100644 --- a/server/game/core/gameSteps/abilityWindow/InitiateAbilityEventWindow.js +++ b/server/game/core/gameSteps/abilityWindow/InitiateAbilityEventWindow.js @@ -48,15 +48,6 @@ class InitiateAbilityInterruptWindow extends TriggeredAbilityWindow { } class InitiateAbilityEventWindow extends EventWindow { - /** @override */ - openWindow(abilityType) { - if (this.events.length && abilityType === AbilityType.Interrupt) { - this.queueStep(new InitiateAbilityInterruptWindow(this.game, abilityType, this)); - } else { - super.openWindow(abilityType); - } - } - /** @override */ executeHandler() { this.eventsToExecute = _.sortBy(this.events, 'order'); diff --git a/server/game/core/gameSteps/abilityWindow/TriggeredAbilityWindow.js b/server/game/core/gameSteps/abilityWindow/TriggeredAbilityWindow.js index cbf2539d6..ffcb1afa7 100644 --- a/server/game/core/gameSteps/abilityWindow/TriggeredAbilityWindow.js +++ b/server/game/core/gameSteps/abilityWindow/TriggeredAbilityWindow.js @@ -18,11 +18,7 @@ class TriggeredAbilityWindow extends ForcedTriggeredAbilityWindow { if (player.timerSettings.eventsInDeck && this.choices.some((context) => context.player === player)) { return true; } - // Show a bluff prompt if we're in Step 6, the player has the approriate setting, and there's an event for the other player - return this.abilityType === AbilityType.WouldInterrupt && player.timerSettings.events && _.any(this.events, (event) => ( - event.name === EventName.OnInitiateAbilityEffects && - event.card.type === CardType.Event && event.context.player !== player - )); + return false; } promptWithBluffPrompt(player) { @@ -63,14 +59,6 @@ class TriggeredAbilityWindow extends ForcedTriggeredAbilityWindow { if (this.complete) { return true; } - // remove any choices which involve the current player canceling their own abilities - if (this.abilityType === AbilityType.WouldInterrupt && !this.activePlayer.optionSettings.cancelOwnAbilities) { - this.choices = this.choices.filter((context) => !( - context.player === this.activePlayer && - context.event.name === EventName.OnInitiateAbilityEffects && - context.event.context.player === this.activePlayer - )); - } // if the current player has no available choices in this window, check to see if they should get a bluff prompt if (!_.any(this.choices, (context) => context.player === this.activePlayer && context.ability.isInValidLocation(context))) { diff --git a/server/game/core/gameSteps/abilityWindow/TriggeredAbilityWindowTitle.ts b/server/game/core/gameSteps/abilityWindow/TriggeredAbilityWindowTitle.ts index 6fb39ea92..003772b7c 100644 --- a/server/game/core/gameSteps/abilityWindow/TriggeredAbilityWindowTitle.ts +++ b/server/game/core/gameSteps/abilityWindow/TriggeredAbilityWindowTitle.ts @@ -64,7 +64,7 @@ export const TriggeredAbilityWindowTitle = { }) .filter(Boolean); - if (abilityType === AbilityType.ForcedReaction || abilityType === AbilityType.ForcedInterrupt) { + if (abilityType === AbilityType.ForcedReaction) { return titles.length > 0 ? `Choose ${abilityWord} order for ${FormatTitles(titles)}` : `Choose ${abilityWord} order`; diff --git a/server/game/core/gameSystem/CardTargetSystem.ts b/server/game/core/gameSystem/CardTargetSystem.ts index 51ad2a996..13846f8c7 100644 --- a/server/game/core/gameSystem/CardTargetSystem.ts +++ b/server/game/core/gameSystem/CardTargetSystem.ts @@ -11,7 +11,9 @@ export interface ICardTargetSystemProperties extends IGameSystemProperties { /** * A {@link GameSystem} which targets a card or cards for its effect */ -export abstract class CardTargetSystem

extends GameSystem

{ +// UP NEXT: mixin for Action types (CardAction, PlayerAction)? +// TODO: could we remove the default generic parameter so that all child classes are forced to declare it +export abstract class CardTargetSystem extends GameSystem { override targetType = [ CardType.Unit, CardType.Upgrade, @@ -132,10 +134,11 @@ export abstract class CardTargetSystem

{ // event.cardStateWhenLeftPlay = event.card.createSnapshot(); // if (event.card.isAncestral() && event.isContingent) { @@ -149,6 +152,8 @@ export abstract class CardTargetSystem

{ const contingentEvents = []; + + // TODO UPGRADES: uncomment below code // Add an imminent triggering condition for all attachments leaving play // for (const attachment of (event.card.attachments ?? []) as BaseCard[]) { @@ -170,7 +175,7 @@ export abstract class CardTargetSystem

; + // TODO: make sure that existing systems handle 'isCost' check correctly + isCost?: boolean; } // TODO: see which base classes can be made abstract @@ -19,12 +21,13 @@ export interface IGameSystemProperties { * Base class for making structured changes to game state. Almost all effects, actions, * costs, etc. should use a {@link GameSystem} object to impact the game state. * - * @template TGameSystemProperties Property class to use for configuring the behavior of the system's execution + * @template TProperties Property class to use for configuring the behavior of the system's execution */ // TODO: convert all template parameter names in the repo to use T prefix -export abstract class GameSystem { - propertyFactory?: (context?: AbilityContext) => TGameSystemProperties; - properties?: TGameSystemProperties; +// TODO: could we remove the default generic parameter so that all child classes are forced to declare it +export abstract class GameSystem { + propertyFactory?: (context?: AbilityContext) => TProperties; + properties?: TProperties; targetType: string[] = []; eventName = EventName.Unnamed; name = ''; // TODO: should these be abstract? @@ -35,12 +38,12 @@ export abstract class GameSystem TGameSystemProperties)) { + constructor(propertiesOrPropertyFactory: TProperties | ((context?: AbilityContext) => TProperties)) { if (typeof propertiesOrPropertyFactory === 'function') { this.propertyFactory = propertiesOrPropertyFactory; } else { @@ -76,7 +79,7 @@ export abstract class GameSystem, ICardTargetSystemProperties { + targetLocation?: Location | Location[]; + canChangeZoneOnce?: boolean; + canChangeZoneNTimes?: number; +} + +// TODO: how is this related to LastingEffectSystem? +/** + * For a definition, see SWU 7.3 'Lasting Effects': "A lasting effect is a part of an ability that affects the game for a specified duration of time. + * Most lasting effects include the phrase 'for this phase' or 'for this attack.'" + */ +export class LastingEffectCardSystem extends CardTargetSystem { + override name = 'applyLastingEffect'; + override eventName = EventName.OnEffectApplied; + override effectDescription = 'apply a lasting effect to {0}'; + override defaultProperties: ILastingEffectCardProperties = { + duration: Duration.UntilEndOfAttack, + canChangeZoneOnce: false, + canChangeZoneNTimes: 0, + effect: [], + ability: null + }; + + override generatePropertiesFromContext(context: AbilityContext, additionalProperties = {}): ILastingEffectCardProperties { + const properties = super.generatePropertiesFromContext(context, additionalProperties) as ILastingEffectCardProperties; + if (!Array.isArray(properties.effect)) { + properties.effect = [properties.effect]; + } + return properties; + } + + override canAffect(card: Card, context: AbilityContext, additionalProperties = {}): boolean { + const properties = this.generatePropertiesFromContext(context, additionalProperties); + properties.effect = properties.effect.map((factory) => factory(context.game, context.source, properties)); + const lastingEffectRestrictions = card.getEffects(EffectName.CannotApplyLastingEffects); + return ( + super.canAffect(card, context) && + properties.effect.some( + (props) => + props.effect.canBeApplied(card) && + !lastingEffectRestrictions.some((condition) => condition(props.effect)) + ) + ); + } + + eventHandler(event, additionalProperties): void { + const properties = this.generatePropertiesFromContext(event.context, additionalProperties); + if (!properties.ability) { + properties.ability = event.context.ability; + } + + const lastingEffectRestrictions = event.card.getEffects(EffectName.CannotApplyLastingEffects); + const { effect, ...otherProperties } = properties; + const effectProperties = Object.assign({ match: event.card, location: WildcardLocation.Any }, otherProperties); + let effects = properties.effect.map((factory) => + factory(event.context.game, event.context.source, effectProperties) + ); + effects = effects.filter( + (props) => + props.effect.canBeApplied(event.card) && + !lastingEffectRestrictions.some((condition) => condition(props.effect)) + ); + for (const effect of effects) { + event.context.game.effectEngine.add(effect); + } + } +} diff --git a/server/game/core/gameSystem/LastingEffectSystem.ts b/server/game/core/gameSystem/LastingEffectSystem.ts new file mode 100644 index 000000000..388ce5ee5 --- /dev/null +++ b/server/game/core/gameSystem/LastingEffectSystem.ts @@ -0,0 +1,64 @@ +import type { AbilityContext } from '../ability/AbilityContext'; +import type PlayerOrCardAbility from '../ability/PlayerOrCardAbility'; +import { Duration, EventName, RelativePlayer } from '../Constants'; +import type { WhenType } from '../../Interfaces'; +import type Player from '../Player'; +import { GameSystem, type IGameSystemProperties } from './GameSystem'; +import { Event } from '../event/Event'; +import Effect from '../effect/Effect'; + +export interface ILastingEffectGeneralProperties extends IGameSystemProperties { + duration?: Duration; + condition?: (context: AbilityContext) => boolean; + until?: WhenType; + effect?: any; + ability?: PlayerOrCardAbility; +} + +export interface LastingEffectProperties extends ILastingEffectGeneralProperties { + targetController?: RelativePlayer | Player; +} + +// TODO: how is this related to LastingEffectCardSystem? +export class LastingEffectAction extends GameSystem { + override name = 'applyLastingEffect'; + override eventName = EventName.OnEffectApplied; + override effectDescription = 'apply a lasting effect'; + override defaultProperties: LastingEffectProperties = { + duration: Duration.UntilEndOfAttack, + effect: [], + ability: null + } satisfies LastingEffectProperties; + + override generatePropertiesFromContext( + context: AbilityContext, + additionalProperties = {} + ): LastingEffectProperties & { effect?: Effect[] } { + const properties = super.generatePropertiesFromContext(context, additionalProperties) as LastingEffectProperties & { + effect: any[]; + }; + if (!Array.isArray(properties.effect)) { + properties.effect = [properties.effect]; + } + return properties; + } + + override hasLegalTarget(context: AbilityContext, additionalProperties = {}): boolean { + const properties = this.generatePropertiesFromContext(context, additionalProperties); + return properties.effect.length > 0; + } + + override addEventsToArray(events: Event[], context: AbilityContext, additionalProperties: any): void { + if (this.hasLegalTarget(context, additionalProperties)) { + events.push(this.getEvent(null, context, additionalProperties)); + } + } + + eventHandler(event: Event, additionalProperties: any): void { + const properties = this.generatePropertiesFromContext(event.context, additionalProperties); + if (!properties.ability) { + properties.ability = event.context.ability; + } + event.context.source[properties.duration](() => properties); + } +} \ No newline at end of file diff --git a/server/game/core/gameSystem/PlayerTargetSystem.ts b/server/game/core/gameSystem/PlayerTargetSystem.ts index db319908e..3af7dde9d 100644 --- a/server/game/core/gameSystem/PlayerTargetSystem.ts +++ b/server/game/core/gameSystem/PlayerTargetSystem.ts @@ -8,7 +8,7 @@ export interface IPlayerTargetSystemProperties extends IGameSystemProperties {} /** * A {@link GameSystem} which targets a player for its effect */ -export abstract class PlayerTargetSystem

extends GameSystem

{ +export abstract class PlayerTargetSystem extends GameSystem { override targetType = ['player']; override defaultTargets(context: AbilityContext): Player[] { diff --git a/server/game/core/utils/Contract.ts b/server/game/core/utils/Contract.ts index d487c5acc..81957f834 100644 --- a/server/game/core/utils/Contract.ts +++ b/server/game/core/utils/Contract.ts @@ -16,7 +16,6 @@ class LoggingContractCheckImpl implements IContractCheckImpl { fail(message: string): void { if (this.breakpoint) { - // eslint-disable-next-line no-debugger debugger; } @@ -30,7 +29,6 @@ class AssertContractCheckImpl implements IContractCheckImpl { fail(message: string): void { if (this.breakpoint) { - // eslint-disable-next-line no-debugger debugger; } @@ -102,7 +100,7 @@ export function assertNotNull(val: object, message?: string): boolean { return true; } -export function assertNotNullLike(val: object, message?: string): boolean { +export function assertNotNullLike(val: any, message?: string): boolean { if (val == null) { contractCheckImpl.fail(message ?? `Null-like object value: ${val}`); return false; @@ -110,6 +108,15 @@ export function assertNotNullLike(val: object, message?: string): boolean { return true; } +export function assertNotNullLikeOrNan(val?: number, message?: string): boolean { + assertNotNullLike(val); + if (isNaN(val)) { + contractCheckImpl.fail(message ?? 'NaN value'); + return false; + } + return true; +} + export function assertHasProperty(obj: object, propertyName: string, message?: string): boolean { assertNotNullLike(obj); if (!(propertyName in obj)) { @@ -128,6 +135,22 @@ export function assertArraySize(ara: object[], expectedSize: number, message?: s return true; } +export function assertPositiveNonZero(val: number, message?: string) { + if (val <= 0) { + contractCheckImpl.fail(message ?? `Expected ${val} to be > 0`); + return false; + } + return true; +} + +export function assertNonNegative(val: number, message?: string) { + if (val < 0) { + contractCheckImpl.fail(message ?? `Expected ${val} to be >= 0`); + return false; + } + return true; +} + export function fail(message: string): void { contractCheckImpl.fail(message); } @@ -140,8 +163,11 @@ const Contract = { assertEqual, assertNotNull, assertNotNullLike, + assertNotNullLikeOrNan, assertHasProperty, assertArraySize, + assertPositiveNonZero, + assertNonNegative, fail }; diff --git a/server/game/core/utils/EnumHelpers.ts b/server/game/core/utils/EnumHelpers.ts index ee4b264ea..351076ba8 100644 --- a/server/game/core/utils/EnumHelpers.ts +++ b/server/game/core/utils/EnumHelpers.ts @@ -1,4 +1,4 @@ -import { Location, TargetableLocation, WildcardLocation } from '../Constants'; +import { Location, LocationFilter, WildcardLocation } from '../Constants'; // convert a set of strings to map to an enum type, throw if any of them is not a legal value export function checkConvertToEnum(values: string[], enumObj: T): T[keyof T][] { @@ -15,7 +15,7 @@ export function checkConvertToEnum(values: string[], enumObj: T): T[keyof T][ return result; } -export const isArena = (location: TargetableLocation) => { +export const isArena = (location: LocationFilter) => { switch (location) { case Location.GroundArena: case Location.SpaceArena: @@ -26,7 +26,7 @@ export const isArena = (location: TargetableLocation) => { } }; -export const isAttackableLocation = (location: TargetableLocation) => { +export const isAttackableLocation = (location: LocationFilter) => { switch (location) { case Location.GroundArena: case Location.SpaceArena: @@ -39,12 +39,12 @@ export const isAttackableLocation = (location: TargetableLocation) => { }; // return true if the card location matches one of the allowed location filters -export const cardLocationMatches = (cardLocation: Location, allowedLocations: TargetableLocation | TargetableLocation[]) => { - if (!Array.isArray(allowedLocations)) { - allowedLocations = [allowedLocations]; +export const cardLocationMatches = (cardLocation: Location, locationFilter: LocationFilter | LocationFilter[]) => { + if (!Array.isArray(locationFilter)) { + locationFilter = [locationFilter]; } - return allowedLocations.some((allowedLocation) => { + return locationFilter.some((allowedLocation) => { switch (allowedLocation) { case WildcardLocation.Any: return true; diff --git a/server/game/core/utils/Helpers.ts b/server/game/core/utils/Helpers.ts index e426be9a5..3572c0489 100644 --- a/server/game/core/utils/Helpers.ts +++ b/server/game/core/utils/Helpers.ts @@ -1,3 +1,6 @@ +import Card from '../card/Card'; +import { Aspect } from '../Constants'; + /* Randomize array in-place using Durstenfeld shuffle algorithm */ export function shuffleArray(array: T[]): void { for (let i = array.length - 1; i > 0; i--) { @@ -18,3 +21,12 @@ export type Derivable = T | ((co export function derive(input: Derivable, context: C): T { return typeof input === 'function' ? input(context) : input; } + +export function countUniqueAspects(cards: Card | Card[]): number { + const aspects = new Set(); + const cardsArray = Array.isArray(cards) ? cards : [cards]; + cardsArray.forEach((card) => { + card.aspects.forEach((aspect) => aspects.add(aspect)); + }); + return aspects.size; +} \ No newline at end of file diff --git a/server/game/costs/CostLibrary.ts b/server/game/costs/CostLibrary.ts index 7370b2cd0..6701cd840 100644 --- a/server/game/costs/CostLibrary.ts +++ b/server/game/costs/CostLibrary.ts @@ -5,7 +5,6 @@ import { CardTargetSystem } from '../core/gameSystem/CardTargetSystem'; import { GameSystem } from '../core/gameSystem/GameSystem'; import * as GameSystems from '../gameSystems/GameSystemLibrary'; import { ExecuteHandlerSystem } from '../gameSystems/ExecuteHandlerSystem'; -import { IReturnToDeckProperties } from '../gameSystems/ReturnToDeckSystem'; import { ISelectCardProperties } from '../gameSystems/SelectCardSystem'; import { TriggeredAbilityContext } from '../core/ability/TriggeredAbilityContext'; import { Derivable, derive } from '../core/utils/Helpers'; @@ -14,6 +13,7 @@ import { ICost } from '../core/cost/ICost'; import { GameActionCost } from '../core/cost/GameActionCost'; import { MetaActionCost } from '../core/cost/MetaActionCost'; import { AdjustableResourceCost } from './AdjustableResourceCost'; +import { ReturnToHandFromPlaySystem } from '../gameSystems/ReturnToHandFromPlaySystem'; // import { TargetDependentFateCost } from './costs/TargetDependentFateCost'; import Player from '../core/Player'; @@ -52,13 +52,29 @@ export function exhaustSelf(): ICost { // return getSelectCost(GameSystems.sacrifice(), properties, 'Select card to sacrifice'); // } -// /** -// * Cost that will return a selected card to hand which matches the passed -// * condition. -// */ -// export function returnToHand(properties: SelectCostProperties): Cost { -// return getSelectCost(GameSystems.returnToHand(), properties, 'Select card to return to hand'); -// } +/** + * Cost that will return to hand from the play area the card that initiated the ability + */ +export function returnSelfToHandFromPlay(): ICost { + return new GameActionCost(GameSystems.returnToHandFromPlay({ isCost: true })); +} + +/** + * Cost that will return a selected card to hand from any area which matches the passed condition + * @deprecated This has not yet been tested + */ +export function returnToHand(properties: SelectCostProperties): ICost { + return getSelectCost(GameSystems.returnToHand(), properties, 'Select card to return to hand'); +} + +/** + * Simplified version of {@link returnToHand} that will return a selected card to hand from the + * play area which matches the passed condition + * @deprecated This has not yet been tested + */ +export function returnToHandFromPlay(properties: SelectCostProperties): ICost { + return getSelectCost(GameSystems.returnToHandFromPlay(), properties, 'Select card to return to hand'); +} // /** // * Cost that will return a selected card to the appropriate deck which matches the passed @@ -78,6 +94,7 @@ export function exhaustSelf(): ICost { /** * Cost that will shuffle a selected card into the relevant deck which matches the passed * condition. + * @deprecated This has not yet been tested */ export function shuffleIntoDeck(properties: SelectCostProperties): ICost { return getSelectCost( @@ -112,6 +129,7 @@ export function shuffleIntoDeck(properties: SelectCostProperties): ICost { // ); // } +/** @deprecated This has not yet been tested */ export function discardTopCardsFromDeck(properties: { amount: number; }): ICost { return { getActionName: (context) => 'discardTopCardsFromDeck', @@ -192,14 +210,14 @@ export function putSelfIntoPlay(): ICost { // return { // canIgnoreForTargeting: true, // canPay(context: TriggeredAbilityContext) { -// const amount = context.source.getCost(); +// const amount = context.source.cost; // return ( // context.player.fate >= amount && // (amount === 0 || context.player.checkRestrictions('spendFate', context)) // ); // }, // payEvent(context: TriggeredAbilityContext) { -// const amount = context.source.getCost(); +// const amount = context.source.cost; // return new Event( // EventName.OnSpendFate, // { amount, context }, diff --git a/server/game/effects/CardCannot.ts b/server/game/effects/CardCannot.ts new file mode 100644 index 000000000..7a031045d --- /dev/null +++ b/server/game/effects/CardCannot.ts @@ -0,0 +1,26 @@ +import type Card from '../core/card/Card'; +import { EffectName } from '../core/Constants'; +import type Player from '../core/Player'; +import { EffectBuilder } from '../core/effect/EffectBuilder'; +import { Restriction } from '../core/effect/effectImpl/Restriction'; +import type { AbilityContext } from '../core/ability/AbilityContext'; + +type CardCannotProperties = + | string + | { + cannot: string; + applyingPlayer?: Player; + restrictedActionCondition?: (context: AbilityContext) => boolean; + source?: Card; + }; + +export function cardCannot(properties: CardCannotProperties) { + return EffectBuilder.card.static( + EffectName.AbilityRestrictions, + new Restriction( + typeof properties === 'string' + ? { type: properties } + : Object.assign({ type: properties.cannot }, properties) + ) + ); +} \ No newline at end of file diff --git a/server/game/effects/EffectLibrary.js b/server/game/effects/EffectLibrary.js index 51e49b56f..5c8881668 100644 --- a/server/game/effects/EffectLibrary.js +++ b/server/game/effects/EffectLibrary.js @@ -8,9 +8,9 @@ const { EffectBuilder } = require('../core/effect/EffectBuilder.js'); // const { attachmentMilitarySkillModifier } = require('./Effects/Library/attachmentMilitarySkillModifier'); // const { attachmentPoliticalSkillModifier } = require('./Effects/Library/attachmentPoliticalSkillModifier'); // const { canPlayFromOwn } = require('./Effects/Library/canPlayFromOwn'); -// const { cardCannot } = require('./Effects/Library/cardCannot'); +const { cardCannot } = require('./CardCannot'); // const { copyCard } = require('./Effects/Library/copyCard'); -// const { gainAllAbilities } = require('./Effects/Library/gainAllAbilities'); +// const { gainAllAbilities } = require('./Effects/Library/GainAllAbilities'); // const { gainAbility } = require('./Effects/Library/gainAbility'); // const { mustBeDeclaredAsAttacker } = require('./Effects/Library/mustBeDeclaredAsAttacker'); const { modifyCost } = require('./ModifyCost.js'); @@ -49,7 +49,7 @@ const Effects = { // registerToPlayFromOutOfPlay: () => // EffectBuilder.card.detached(EffectName.CanPlayFromOutOfPlay, { // apply: (card) => { - // for (const reaction of card.reactions) { + // for (const reaction of card.triggeredAbilities) { // reaction.registerEvents(); // } // }, @@ -61,14 +61,13 @@ const Effects = { // EffectBuilder.card.flexible(EffectName.CanOnlyBeDeclaredAsAttackerWithElement, element), // cannotApplyLastingEffects: (condition) => // EffectBuilder.card.static(EffectName.CannotApplyLastingEffects, condition), - // cannotBeAttacked: () => EffectBuilder.card.static(EffectName.CannotBeAttacked), // cannotHaveOtherRestrictedAttachments: (card) => // EffectBuilder.card.static(EffectName.CannotHaveOtherRestrictedAttachments, card), // cannotParticipateAsAttacker: (type = 'both') => // EffectBuilder.card.static(EffectName.CannotParticipateAsAttacker, type), // cannotParticipateAsDefender: (type = 'both') => // EffectBuilder.card.static(EffectName.CannotParticipateAsDefender, type), - // cardCannot, + cardCannot, // changeContributionFunction: (func) => EffectBuilder.card.static(EffectName.ChangeContributionFunction, func), // changeType: (type) => EffectBuilder.card.static(EffectName.ChangeType, type), // characterProvidesAdditionalConflict: (type) => @@ -174,7 +173,7 @@ const Effects = { // for (const card of cards.filter( // (card) => card.type === CardType.Event && card.location === location // )) { - // for (const reaction of card.reactions) { + // for (const reaction of card.triggeredAbilities) { // reaction.registerEvents(); // } // } @@ -205,7 +204,7 @@ const Effects = { // apply: (player) => (player.actionPhasePriority = true), // unapply: (player) => (player.actionPhasePriority = false) // }), - increaseCost: (properties) => Effects.modifyCost(_.extend(properties, { amount: -properties.amount })), + increaseCost: (properties) => Effects.modifyCost(Object.assign({}, properties, { amount: -properties.amount })), // modifyCardsDrawnInDrawPhase: (amount) => // EffectBuilder.player.flexible(EffectName.ModifyCardsDrawnInDrawPhase, amount), // playerCannot: (properties) => diff --git a/server/game/effects/RestrictionDsl.ts b/server/game/effects/RestrictionDsl.ts new file mode 100644 index 000000000..68c2d4891 --- /dev/null +++ b/server/game/effects/RestrictionDsl.ts @@ -0,0 +1,124 @@ +import { MoveCardSystem } from '../gameSystems/MoveCardSystem'; +import { EffectValue } from '../core/effect/effectImpl/EffectValue'; +import { Location } from '../core/Constants'; + +const getApplyingPlayer = (effect) => { + return effect.applyingPlayer || effect.context.player; +}; + +// const isMoveToHandAction = (gameAction) => +// gameAction instanceof MoveCardAction && gameAction.properties.destination === Location.Hand; + +export const restrictionDsl = { + // abilitiesTriggeredByOpponents: (context, effect) => + // context.player === getApplyingPlayer(effect).opponent && + // context.ability.isTriggeredAbility() && + // context.ability.abilityType !== AbilityTypes.ForcedReaction && + // context.ability.abilityType !== AbilityTypes.ForcedInterrupt, + // adjacentCharacters: (context, effect) => + // context.source.type === CardTypes.Character && + // context.player.areLocationsAdjacent(context.source.location, effect.context.source.location), + // attachmentsWithSameClan: (context, effect, card) => + // context.source.type === CardTypes.Attachment && + // context.source.getPrintedFaction() !== 'neutral' && + // card.isFaction(context.source.getPrintedFaction()), + // attackedProvince: (context) => + // context.game.currentConflict && context.game.currentConflict.getConflictProvinces().includes(context.source), + // attackedProvinceNonForced: (context) => + // context.game.currentConflict && + // context.game.currentConflict.getConflictProvinces().includes(context.source) && + // context.ability.isTriggeredAbility() && + // context.ability.abilityType !== AbilityTypes.ForcedReaction && + // context.ability.abilityType !== AbilityTypes.ForcedInterrupt, + // attackingCharacters: (context) => + // context.game.currentConflict && context.source.type === CardTypes.Character && context.source.isAttacking(), + // cardEffects: (context) => + // (context.ability.isCardAbility() || !context.ability.isCardPlayed()) && + // context.stage !== Stages.Cost && + // [ + // CardTypes.Event, + // CardTypes.Character, + // CardTypes.Holding, + // CardTypes.Attachment, + // CardTypes.Stronghold, + // CardTypes.Province, + // CardTypes.Role + // ].includes(context.source.type), + // characters: (context) => context.source.type === CardTypes.Character, + // charactersWithNoFate: (context) => context.source.type === CardTypes.Character && context.source.getFate() === 0, + // copiesOfDiscardEvents: (context) => + // context.source.type === CardTypes.Event && + // context.player.conflictDiscardPile.any((card) => card.name === context.source.name), + // copiesOfX: (context, effect) => context.source.name === effect.params, + // events: (context) => context.source.type === CardTypes.Event, + // eventsWithSameClan: (context, effect, card) => + // context.source.type === CardTypes.Event && + // context.source.getPrintedFaction() !== 'neutral' && + // card.isFaction(context.source.getPrintedFaction()), + // nonMonstrousEvents: (context) => context.source.type === CardTypes.Event && !context.source.hasTrait('monstrous'), + // nonDynastyPhase: (context) => context.game.phase !== Phases.Dynasty, + // nonSpellEvents: (context) => context.source.type === CardTypes.Event && !context.source.hasTrait('spell'), + // opponentsAttachments: (context, effect) => + // context.player && + // context.player === getApplyingPlayer(effect).opponent && + // context.source.type === CardTypes.Attachment, + // opponentsCardEffects: (context, effect) => + // context.player === getApplyingPlayer(effect).opponent && + // (context.ability.isCardAbility() || !context.ability.isCardPlayed()) && + // [ + // CardTypes.Event, + // CardTypes.Character, + // CardTypes.Holding, + // CardTypes.Attachment, + // CardTypes.Stronghold, + // CardTypes.Province, + // CardTypes.Role + // ].includes(context.source.type), + // opponentsProvinceEffects: (context, effect) => + // context.player === getApplyingPlayer(effect).opponent && + // (context.ability.isCardAbility() || !context.ability.isCardPlayed()) && + // [CardTypes.Province].includes(context.source.type), + // opponentsEvents: (context, effect) => + // context.player && + // context.player === getApplyingPlayer(effect).opponent && + // context.source.type === CardTypes.Event, + // opponentsTriggeredAbilities: (context, effect) => + // context.player === getApplyingPlayer(effect).opponent && context.ability.isTriggeredAbility(), + // opponentsCardAbilities: (context, effect) => + // context.player === getApplyingPlayer(effect).opponent && context.ability.isCardAbility(), + // opponentsCharacters: (context, effect) => + // context.source.type === CardTypes.Character && context.source.controller === getApplyingPlayer(effect).opponent, + // opponentsCharacterAbilitiesWithLowerGlory: (context, effect) => + // context.source.type === CardTypes.Character && + // context.source.controller === getApplyingPlayer(effect).opponent && + // context.source.glory < effect.context.source.parent.glory, + // reactions: (context) => context.ability.abilityType === AbilityTypes.Reaction, + // actionEvents: (context) => + // context.ability.card.type === CardTypes.Event && context.ability.abilityType === AbilityTypes.Action, + // source: (context, effect) => context.source === effect.context.source, + // keywordAbilities: (context) => context.ability.isKeywordAbility(), + // nonKeywordAbilities: (context) => !context.ability.isKeywordAbility(), + // nonForcedAbilities: (context) => + // context.ability.isTriggeredAbility() && + // context.ability.abilityType !== AbilityTypes.ForcedReaction && + // context.ability.abilityType !== AbilityTypes.ForcedInterrupt, + // equalOrMoreExpensiveCharacterTriggeredAbilities: (context, effect, card) => + // context.source.type === CardTypes.Character && + // !context.ability.isKeywordAbility && + // context.source.printedCost >= card.printedCost, + // equalOrMoreExpensiveCharacterKeywords: (context, effect, card) => + // context.source.type === CardTypes.Character && + // context.ability.isKeywordAbility && + // context.source.printedCost >= card.printedCost, + // eventPlayedByHigherBidPlayer: (context, effect, card) => + // context.source.type === CardTypes.Event && context.player.showBid > card.controller.showBid, + // toHand: (context) => { + // const targetActions = context.ability.properties.target ? context.ability.properties.target.gameAction : []; + // const nestedActions = context.ability.gameAction + // ? context.ability.gameAction.map((topAction) => topAction.properties.gameAction) + // : []; + + // return targetActions.some(isMoveToHandAction) || nestedActions.some(isMoveToHandAction); + // }, + // loseHonorAsCost: (context) => context.stage === Stages.Cost, +}; diff --git a/server/game/gameSystems/DamageSystem.ts b/server/game/gameSystems/DamageSystem.ts index 9cc2e55b1..376854fd9 100644 --- a/server/game/gameSystems/DamageSystem.ts +++ b/server/game/gameSystems/DamageSystem.ts @@ -15,7 +15,7 @@ export class DamageSystem extends CardTargetSystem { override targetType = [CardType.Unit, CardType.Base]; override getEffectMessage(context: AbilityContext): [string, any[]] { - const { amount, target, isCombatDamage } = this.generatePropertiesFromContext(context) as IDamageProperties; + const { amount, target, isCombatDamage } = this.generatePropertiesFromContext(context); if (isCombatDamage) { return ['deal {1} combat damage to {0}', [amount, target]]; diff --git a/server/game/gameSystems/DefeatCardSystem.ts b/server/game/gameSystems/DefeatCardSystem.ts index 3db4d856a..2b347ae06 100644 --- a/server/game/gameSystems/DefeatCardSystem.ts +++ b/server/game/gameSystems/DefeatCardSystem.ts @@ -33,7 +33,7 @@ export class DefeatCardSystem extends CardTargetSystem { this.updateLeavesPlayEvent(event, card, context, additionalProperties); } - override eventHandler(event, additionalProperties = {}): void { + eventHandler(event, additionalProperties = {}): void { this.leavesPlayEventHandler(event, additionalProperties); } } diff --git a/server/game/gameSystems/ExecuteHandlerSystem.ts b/server/game/gameSystems/ExecuteHandlerSystem.ts index 7e44bd119..ee9af625d 100644 --- a/server/game/gameSystems/ExecuteHandlerSystem.ts +++ b/server/game/gameSystems/ExecuteHandlerSystem.ts @@ -10,6 +10,7 @@ export interface IExecuteHandlerSystemProperties extends IGameSystemProperties { // TODO: this is sometimes getting used as a no-op, see if we can add an explicit implementation for that /** * A {@link GameSystem} which executes a handler function + * @override This was copied from L5R but has not been tested yet */ export class ExecuteHandlerSystem extends GameSystem { override defaultProperties: IExecuteHandlerSystemProperties = { diff --git a/server/game/gameSystems/ExhaustSystem.ts b/server/game/gameSystems/ExhaustSystem.ts index b397f3af2..1cc99da6c 100644 --- a/server/game/gameSystems/ExhaustSystem.ts +++ b/server/game/gameSystems/ExhaustSystem.ts @@ -4,10 +4,8 @@ import { CardType, EventName } from '../core/Constants'; import { isArena } from '../core/utils/EnumHelpers'; import { type ICardTargetSystemProperties, CardTargetSystem } from '../core/gameSystem/CardTargetSystem'; - -export interface IExhaustSystemProperties extends ICardTargetSystemProperties { - isCost?: boolean; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface IExhaustSystemProperties extends ICardTargetSystemProperties {} export class ExhaustSystem extends CardTargetSystem { override name = 'exhaust'; diff --git a/server/game/gameSystems/GameSystemLibrary.ts b/server/game/gameSystems/GameSystemLibrary.ts index 2bbd9d667..85b50c168 100644 --- a/server/game/gameSystems/GameSystemLibrary.ts +++ b/server/game/gameSystems/GameSystemLibrary.ts @@ -1,4 +1,6 @@ +import { GameSystem } from '../core/gameSystem/GameSystem'; import { AbilityContext } from '../core/ability/AbilityContext'; + // import { AddTokenAction, AddTokenProperties } from './AddTokenAction'; // import { AttachAction, AttachActionProperties } from './AttachAction'; import { AttackSystem, IAttackProperties } from './AttackSystem'; @@ -20,9 +22,9 @@ import { DefeatCardSystem, IDefeatCardProperties } from './DefeatCardSystem'; // import { DrawAction, DrawProperties } from './DrawAction'; import { ExhaustSystem, IExhaustSystemProperties } from './ExhaustSystem'; // import { GainStatusTokenAction, GainStatusTokenProperties } from './GainStatusTokenAction'; -import { GameSystem } from '../core/gameSystem/GameSystem'; import { ExecuteHandlerSystem, IExecuteHandlerSystemProperties } from './ExecuteHandlerSystem'; // import { IfAbleAction, IfAbleActionProperties } from './IfAbleAction'; +import { HealSystem, IHealProperties } from './HealSystem'; // import { JointGameAction } from './JointGameAction'; // import { LastingEffectAction, LastingEffectProperties } from './LastingEffectAction'; // import { LastingEffectCardAction, LastingEffectCardProperties } from './LastingEffectCardAction'; @@ -38,12 +40,12 @@ import { MoveCardSystem, IMoveCardProperties } from './MoveCardSystem'; // import { PlaceCardUnderneathAction, PlaceCardUnderneathProperties } from './PlaceCardUnderneathAction'; // import { PlayCardAction, PlayCardProperties } from './PlayCardAction'; import { PutIntoPlaySystem, IPutIntoPlayProperties } from './PutIntoPlaySystem'; -import { RandomDiscardSystem, IRandomDiscardProperties } from './RandomDiscardSystem'; // import { ReadyAction, ReadyProperties } from './ReadyAction'; // import { RemoveFromGameAction, RemoveFromGameProperties } from './RemoveFromGameAction'; // import { ResolveAbilityAction, ResolveAbilityProperties } from './ResolveAbilityAction'; // import { ReturnToDeckSystem, IReturnToDeckProperties } from './ReturnToDeckSystem'; -// import { ReturnToHandAction, ReturnToHandProperties } from './ReturnToHandAction'; +import { ReturnToHandSystem, IReturnToHandProperties } from './ReturnToHandSystem'; +import { ReturnToHandFromPlaySystem, IReturnToHandFromPlayProperties } from './ReturnToHandFromPlaySystem'; // import { RevealAction, RevealProperties } from './RevealAction'; import { SelectCardSystem, ISelectCardProperties } from './SelectCardSystem'; // import { SelectTokenAction, SelectTokenProperties } from './SelectTokenAction'; @@ -92,6 +94,9 @@ export function defeat(propertyFactory: PropsFactory = {} export function exhaust(propertyFactory: PropsFactory = {}): CardTargetSystem { return new ExhaustSystem(propertyFactory); } +export function heal(propertyFactory: PropsFactory): GameSystem { + return new HealSystem(propertyFactory); +} // export function lookAt(propertyFactory: PropsFactory = {}): GameSystem { // return new LookAtAction(propertyFactory); // } @@ -138,9 +143,13 @@ export function putIntoPlay(propertyFactory: PropsFactory = {}): CardGameAction { // return new ReturnToDeckAction(propertyFactory); // } -// export function returnToHand(propertyFactory: PropsFactory = {}): CardGameAction { -// return new ReturnToHandAction(propertyFactory); -// } +export function returnToHand(propertyFactory: PropsFactory = {}): CardTargetSystem { + return new ReturnToHandSystem(propertyFactory); +} + +export function returnToHandFromPlay(propertyFactory: PropsFactory = {}): CardTargetSystem { + return new ReturnToHandFromPlaySystem(propertyFactory); +} // /** // * default chatMessage = false // */ @@ -195,9 +204,9 @@ export function putIntoPlay(propertyFactory: PropsFactory = {}): GameSystem { - return new RandomDiscardSystem(propertyFactory); -} +// export function discardAtRandom(propertyFactory: PropsFactory = {}): GameSystem { +// return new RandomDiscardSystem(propertyFactory); +// } // /** // * default amount = 1 // */ diff --git a/server/game/gameSystems/HealSystem.ts b/server/game/gameSystems/HealSystem.ts new file mode 100644 index 000000000..0a18b58fe --- /dev/null +++ b/server/game/gameSystems/HealSystem.ts @@ -0,0 +1,51 @@ +import type { AbilityContext } from '../core/ability/AbilityContext'; +import type Card from '../core/card/Card'; +import { CardType, EventName } from '../core/Constants'; +import { isArena, isAttackableLocation } from '../core/utils/EnumHelpers'; +import { type ICardTargetSystemProperties, CardTargetSystem } from '../core/gameSystem/CardTargetSystem'; +import Contract from '../core/utils/Contract'; + +export interface IHealProperties extends ICardTargetSystemProperties { + amount: number; +} + +export class HealSystem extends CardTargetSystem { + override name = 'heal'; + override eventName = EventName.OnDamageDealt; + override targetType = [CardType.Unit, CardType.Base]; + + override getEffectMessage(context: AbilityContext): [string, any[]] { + const { amount, target } = this.generatePropertiesFromContext(context); + + return ['heal {1} damage from {0}', [amount, target]]; + } + + override canAffect(card: Card, context: AbilityContext): boolean { + const properties = this.generatePropertiesFromContext(context); + if (!isAttackableLocation(card.location)) { + return false; + } + if (properties.isCost && (properties.amount === 0 || card.hp === 0 || card.hp === null)) { + return false; + } + // UP NEXT: rename 'checkRestrictions' to 'hasRestriction' and invert the logic + { + if (!card.checkRestrictions('beHealed', context)) { + return false; + } + } + return super.canAffect(card, context); + } + + override addPropertiesToEvent(event, card: Card, context: AbilityContext, additionalProperties): void { + const { amount } = this.generatePropertiesFromContext(context, additionalProperties); + super.addPropertiesToEvent(event, card, context, additionalProperties); + event.healAmount = amount; + event.context = context; + event.recipient = card; + } + + eventHandler(event): void { + event.card.removeDamage(event.healAmount); + } +} diff --git a/server/game/gameSystems/MoveCardSystem.ts b/server/game/gameSystems/MoveCardSystem.ts index 5c568000c..8d9b5250b 100644 --- a/server/game/gameSystems/MoveCardSystem.ts +++ b/server/game/gameSystems/MoveCardSystem.ts @@ -17,7 +17,7 @@ export interface IMoveCardProperties extends ICardTargetSystemProperties { } // TODO: this system has not been used or tested -export class MoveCardSystem extends CardTargetSystem { +export class MoveCardSystem extends CardTargetSystem { override name = 'move'; override targetType = [CardType.Unit, CardType.Upgrade, CardType.Event]; override defaultProperties: IMoveCardProperties = { @@ -30,10 +30,6 @@ export class MoveCardSystem extends CardTargetSystem { changePlayer: false, }; - public constructor(properties: IMoveCardProperties | ((context: AbilityContext) => IMoveCardProperties)) { - super(properties); - } - override getCostMessage(context: AbilityContext): [string, any[]] { const properties = this.generatePropertiesFromContext(context) as IMoveCardProperties; return ['shuffling {0} into their deck', [properties.target]]; diff --git a/server/game/gameSystems/PutIntoPlaySystem.ts b/server/game/gameSystems/PutIntoPlaySystem.ts index 640dc5ef4..14209ec78 100644 --- a/server/game/gameSystems/PutIntoPlaySystem.ts +++ b/server/game/gameSystems/PutIntoPlaySystem.ts @@ -7,31 +7,19 @@ import Card from '../core/card/Card'; export interface IPutIntoPlayProperties extends ICardTargetSystemProperties { controller?: RelativePlayer; - side?: RelativePlayer; overrideLocation?: Location; } -export class PutIntoPlaySystem extends CardTargetSystem { +export class PutIntoPlaySystem extends CardTargetSystem { override name = 'putIntoPlay'; override eventName = EventName.OnUnitEntersPlay; override costDescription = 'putting {0} into play'; override targetType = [CardType.Unit]; override defaultProperties: IPutIntoPlayProperties = { controller: RelativePlayer.Self, - side: null, overrideLocation: null }; - public constructor( - properties: ((context: AbilityContext) => IPutIntoPlayProperties) | IPutIntoPlayProperties - ) { - super(properties); - } - - getDefaultSide(context: AbilityContext) { - return context.player; - } - getPutIntoPlayPlayer(context: AbilityContext) { return context.player; } @@ -42,10 +30,8 @@ export class PutIntoPlaySystem extends CardTargetSystem { } override canAffect(card: Card, context: AbilityContext): boolean { - const properties = this.generatePropertiesFromContext(context) as IPutIntoPlayProperties; const contextCopy = context.copy({ source: card }); const player = this.getPutIntoPlayPlayer(contextCopy); - const targetSide = properties.side || this.getDefaultSide(contextCopy); if (!context || !super.canAffect(card, context)) { return false; @@ -62,22 +48,18 @@ export class PutIntoPlaySystem extends CardTargetSystem { } override addPropertiesToEvent(event, card: Card, context: AbilityContext, additionalProperties): void { - const { controller, side, overrideLocation } = this.generatePropertiesFromContext( + const { controller, overrideLocation } = this.generatePropertiesFromContext( context, additionalProperties ) as IPutIntoPlayProperties; super.addPropertiesToEvent(event, card, context, additionalProperties); event.controller = controller; event.originalLocation = overrideLocation || card.location; - event.side = side || this.getDefaultSide(context); } eventHandler(event, additionalProperties = {}): void { const player = this.getPutIntoPlayPlayer(event.context); event.card.new = true; - if (event.fate) { - event.card.fate = event.fate; - } let finalController = event.context.player; if (event.controller === RelativePlayer.Opponent) { diff --git a/server/game/gameSystems/RandomDiscardSystem.ts b/server/game/gameSystems/RandomDiscardSystem.ts deleted file mode 100644 index 4c22571dc..000000000 --- a/server/game/gameSystems/RandomDiscardSystem.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { AbilityContext } from '../core/ability/AbilityContext'; -import { EventName, Location } from '../core/Constants'; -import type Player from '../core/Player'; -import { PlayerTargetSystem, type IPlayerTargetSystemProperties } from '../core/gameSystem/PlayerTargetSystem'; - -export interface IRandomDiscardProperties extends IPlayerTargetSystemProperties { - amount?: number; -} - -// TODO: this system has not been used or tested -export class RandomDiscardSystem extends PlayerTargetSystem { - override defaultProperties: IRandomDiscardProperties = { amount: 1 }; - - override name = 'discard'; - override eventName = EventName.OnCardsDiscardedFromHand; - public constructor(propertyFactory: IRandomDiscardProperties | ((context: AbilityContext) => IRandomDiscardProperties)) { - super(propertyFactory); - } - - override getEffectMessage(context: AbilityContext): [string, any[]] { - const properties: IRandomDiscardProperties = this.generatePropertiesFromContext(context); - return [ - 'make {0} discard {1} {2} at random', - [properties.target, properties.amount, properties.amount > 1 ? 'cards' : 'card'] - ]; - } - - override canAffect(player: Player, context: AbilityContext, additionalProperties = {}): boolean { - const properties: IRandomDiscardProperties = this.generatePropertiesFromContext(context, additionalProperties); - return properties.amount > 0 && player.hand.size() > 0 && super.canAffect(player, context); - } - - override addPropertiesToEvent(event, player: Player, context: AbilityContext, additionalProperties): void { - const { amount } = this.generatePropertiesFromContext(context, additionalProperties) as IRandomDiscardProperties; - super.addPropertiesToEvent(event, player, context, additionalProperties); - event.amount = amount; - event.discardedAtRandom = true; - } - - eventHandler(event): void { - const player = event.player; - const amount = Math.min(event.amount, player.hand.size()); - if (amount === 0) { - return; - } - const cardsToDiscard = player.hand.shuffle().slice(0, amount); - event.cards = cardsToDiscard; - event.discardedCards = cardsToDiscard; - player.game.addMessage('{0} discards {1} at random', player, cardsToDiscard); - - for (const card of cardsToDiscard) { - player.moveCard(card, Location.Discard); - } - } -} diff --git a/server/game/gameSystems/ReturnToDeckSystem.ts b/server/game/gameSystems/ReturnToDeckSystem.ts deleted file mode 100644 index c45563c24..000000000 --- a/server/game/gameSystems/ReturnToDeckSystem.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { AbilityContext } from '../core/ability/AbilityContext'; -import { CardType, EventName, TargetableLocation, Location, WildcardLocation } from '../core/Constants'; -import { cardLocationMatches } from '../core/utils/EnumHelpers'; -import { type ICardTargetSystemProperties, CardTargetSystem } from '../core/gameSystem/CardTargetSystem'; -import Card from '../core/card/Card'; - -export interface IReturnToDeckProperties extends ICardTargetSystemProperties { - bottom?: boolean; - shuffle?: boolean; - location?: TargetableLocation | TargetableLocation[]; -} - -// TODO: this system has not been used or tested -export class ReturnToDeckSystem extends CardTargetSystem { - override name = 'returnToDeck'; - override eventName = EventName.OnCardDefeated; - override targetType = [CardType.Unit, CardType.Upgrade, CardType.Event]; - override defaultProperties: IReturnToDeckProperties = { - bottom: false, - shuffle: false, - location: WildcardLocation.AnyArena - }; - - public constructor(properties: ((context: AbilityContext) => IReturnToDeckProperties) | IReturnToDeckProperties) { - super(properties); - } - - override getCostMessage(context: AbilityContext): [string, any[]] { - const properties = this.generatePropertiesFromContext(context) as IReturnToDeckProperties; - return [ - properties.shuffle - ? 'shuffling {0} into their deck' - : 'returning {0} to the ' + (properties.bottom ? 'bottom' : 'top') + ' of their deck', - [properties.target] - ]; - } - - override getEffectMessage(context: AbilityContext): [string, any[]] { - const properties = this.generatePropertiesFromContext(context) as IReturnToDeckProperties; - if (properties.shuffle) { - return ['shuffle {0} into its owner\'s deck', [properties.target]]; - } - return [ - 'return {0} to the ' + (properties.bottom ? 'bottom' : 'top') + ' of its owner\'s deck', - [properties.target] - ]; - } - - override canAffect(card: Card, context: AbilityContext, additionalProperties = {}): boolean { - const properties = this.generatePropertiesFromContext(context) as IReturnToDeckProperties; - let location: TargetableLocation[]; - if (!Array.isArray(properties.location)) { - location = [properties.location]; - } else { - location = properties.location; - } - - return ( - location.some((permittedLocation) => cardLocationMatches(card.location, permittedLocation)) && - super.canAffect(card, context, additionalProperties) - ); - } - - // updateEvent(event, card: BaseCard, context: AbilityContext, additionalProperties): void { - // let { shuffle, target, bottom } = this.generatePropertiesFromContext(context, additionalProperties) as ReturnToDeckProperties; - // this.updateLeavesPlayEvent(event, card, context, additionalProperties); - // event.destination = Location.Deck; - // event.options = { bottom }; - // if (shuffle && (target.length === 0 || card === target[target.length - 1])) { - // event.shuffle = true; - // } - // } - - eventHandler(event, additionalProperties = {}): void { - this.leavesPlayEventHandler(event, additionalProperties); - if (event.shuffle) { - event.card.owner.shuffleDeck(); - } - } -} diff --git a/server/game/gameSystems/ReturnToHandFromPlaySystem.ts b/server/game/gameSystems/ReturnToHandFromPlaySystem.ts new file mode 100644 index 000000000..d1c6cf12d --- /dev/null +++ b/server/game/gameSystems/ReturnToHandFromPlaySystem.ts @@ -0,0 +1,15 @@ +import { CardType, WildcardLocation } from '../core/Constants'; +import { ReturnToHandSystem, IReturnToHandProperties } from './ReturnToHandSystem'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface IReturnToHandFromPlayProperties extends IReturnToHandProperties {} + +/** + * Subclass of {@link ReturnToHandSystem} with specific configuration for returning to hand from play area only + */ +export class ReturnToHandFromPlaySystem extends ReturnToHandSystem { + override targetType = [CardType.Unit, CardType.Upgrade]; + override defaultProperties: IReturnToHandFromPlayProperties = { + locationFilter: WildcardLocation.AnyArena + }; +} \ No newline at end of file diff --git a/server/game/gameSystems/ReturnToHandSystem.ts b/server/game/gameSystems/ReturnToHandSystem.ts new file mode 100644 index 000000000..a72853f2d --- /dev/null +++ b/server/game/gameSystems/ReturnToHandSystem.ts @@ -0,0 +1,41 @@ +import type { AbilityContext } from '../core/ability/AbilityContext'; +import { CardType, EventName, Location, WildcardLocation } from '../core/Constants'; +import { CardTargetSystem, ICardTargetSystemProperties } from '../core/gameSystem/CardTargetSystem'; +import Card from '../core/card/Card'; +import { cardLocationMatches } from '../core/utils/EnumHelpers'; +import type { ReturnToHandFromPlaySystem } from './ReturnToHandFromPlaySystem'; + +type ReturnableLocation = Location.Discard | WildcardLocation.AnyArena; + +export interface IReturnToHandProperties extends ICardTargetSystemProperties { + locationFilter?: ReturnableLocation | ReturnableLocation[]; +} + +/** + * Configurable class for a return to hand from X zone effect. For return to hand from play area specifically, + * see {@link ReturnToHandFromPlaySystem} + */ +export class ReturnToHandSystem extends CardTargetSystem { + override name = 'returnToHand'; + override eventName = EventName.OnCardReturnedToHand; + override effectDescription = 'return {0} to their hand'; + override costDescription = 'returning {0} to their hand'; + override targetType = [CardType.Unit, CardType.Upgrade, CardType.Event]; + override defaultProperties: IReturnToHandProperties = { + locationFilter: [WildcardLocation.AnyArena, Location.Discard] + }; + + override canAffect(card: Card, context: AbilityContext, additionalProperties = {}): boolean { + const properties = super.generatePropertiesFromContext(context); + return cardLocationMatches(card.location, properties.locationFilter) && super.canAffect(card, context, additionalProperties); + } + + override updateEvent(event, card: Card, context: AbilityContext, additionalProperties): void { + this.updateLeavesPlayEvent(event, card, context, additionalProperties); + event.destination = Location.Hand; + } + + eventHandler(event, additionalProperties = {}): void { + this.leavesPlayEventHandler(event, additionalProperties); + } +} \ No newline at end of file diff --git a/server/game/gameSystems/SelectCardSystem.ts b/server/game/gameSystems/SelectCardSystem.ts index 1a1fb9cd2..9cd921136 100644 --- a/server/game/gameSystems/SelectCardSystem.ts +++ b/server/game/gameSystems/SelectCardSystem.ts @@ -18,24 +18,27 @@ export interface ISelectCardProperties extends ICardTargetSystemProperties { message?: string; manuallyRaiseEvent?: boolean; messageArgs?: (card: Card, player: RelativePlayer, properties: ISelectCardProperties) => any[]; - gameSystem: GameSystem; + innerSystem: GameSystem; selector?: BaseCardSelector; mode?: TargetMode; numCards?: number; hidePromptIfSingleCard?: boolean; - subActionProperties?: (card: Card) => any; + innerSystemProperties?: (card: Card) => any; cancelHandler?: () => void; effect?: string; effectArgs?: (context) => string[]; } -// TODO: figure out how this is supposed to work since it has an empty event handler method -// TODO: this system has not been used or tested +/** + * A wrapper system for adding a target selection prompt around the execution the wrapped system. + * Only used for adding a selection effect to a system that is part of a cost. + */ +// TODO: why is this class needed for costs when systems already have target evaluation and selection built in? export class SelectCardSystem extends CardTargetSystem { override defaultProperties: ISelectCardProperties = { cardCondition: () => true, - gameSystem: null, - subActionProperties: (card) => ({ target: card }), + innerSystem: null, + innerSystemProperties: (card) => ({ target: card }), targets: false, hidePromptIfSingleCard: false, manuallyRaiseEvent: false @@ -55,12 +58,12 @@ export class SelectCardSystem extends CardTargetSystem { override generatePropertiesFromContext(context: AbilityContext, additionalProperties = {}): ISelectCardProperties { const properties = super.generatePropertiesFromContext(context, additionalProperties) as ISelectCardProperties; - properties.gameSystem.setDefaultTargetFn(() => properties.target); + properties.innerSystem.setDefaultTargetFn(() => properties.target); if (!properties.selector) { const cardCondition = (card, context) => - properties.gameSystem.allTargetsLegal( + properties.innerSystem.allTargetsLegal( context, - Object.assign({}, additionalProperties, properties.subActionProperties(card)) + Object.assign({}, additionalProperties, properties.innerSystemProperties(card)) ) && properties.cardCondition(card, context); properties.selector = CardSelector.for(Object.assign({}, properties, { cardCondition })); } @@ -115,10 +118,10 @@ export class SelectCardSystem extends CardTargetSystem { if (properties.message) { context.game.addMessage(properties.message, ...properties.messageArgs(cards, player, properties)); } - properties.gameSystem.addEventsToArray( + properties.innerSystem.addEventsToArray( events, context, - Object.assign({ parentAction: this }, additionalProperties, properties.subActionProperties(cards)) + Object.assign({ parentAction: this }, additionalProperties, properties.innerSystemProperties(cards)) ); if (properties.manuallyRaiseEvent) { context.game.openEventWindow(events); @@ -143,5 +146,5 @@ export class SelectCardSystem extends CardTargetSystem { } // eslint-disable-next-line @typescript-eslint/no-empty-function - override eventHandler(event): void { } + eventHandler(event): void { } } diff --git a/test/helpers/IntegrationHelper.js b/test/helpers/IntegrationHelper.js index 4981be767..defbe765b 100644 --- a/test/helpers/IntegrationHelper.js +++ b/test/helpers/IntegrationHelper.js @@ -112,6 +112,30 @@ var customMatchers = { result.message = `Expected ${card.name} to be selectable by ${player.name} but it wasn't.`; } + return result; + } + }; + }, + toHaveAvailableActionWhenClickedInActionPhaseBy: function () { + return { + compare: function (card, player) { + if (_.isString(card)) { + card = player.findCardByName(card); + } + let result = {}; + + player.clickCard(card); + + // this is the default action window prompt (meaning no action was available) + result.pass = !player.hasPrompt('Action Window'); + var currentPrompt = player.currentPrompt(); + + if (result.pass) { + result.message = `Expected ${card.name} not to have an action available when clicked by ${player.name} but it has ability prompt with menuTitle '${currentPrompt.menuTitle}' and promptTitle '${currentPrompt.promptTitle}'.`; + } else { + result.message = `Expected ${card.name} to have an action available when clicked by ${player.name} but it did not.`; + } + return result; } }; diff --git a/test/helpers/PlayerInteractionWrapper.js b/test/helpers/PlayerInteractionWrapper.js index 1ea3fb368..a758aa415 100644 --- a/test/helpers/PlayerInteractionWrapper.js +++ b/test/helpers/PlayerInteractionWrapper.js @@ -135,6 +135,9 @@ class PlayerInteractionWrapper { }); } }); + + // UP NEXT: make this part of the normal process of playing a card + this.game.checkGameState(true); } get deck() { @@ -541,6 +544,16 @@ class PlayerInteractionWrapper { this.clickPrompt('Pass'); } + /** + * + */ + setActivePlayer() { + this.game.actionPhaseActivePlayer = this.player; + if (this.game.currentActionWindow) { + this.game.currentActionWindow.activePlayer = this.player; + } + } + /** * Player's action of passing a conflict */ @@ -554,22 +567,6 @@ class PlayerInteractionWrapper { this.clickPrompt('Yes'); } - /** - * Selects a stronghold province at the beginning of the game - * @param {!String} card - the province to select - */ - selectStrongholdProvince(card) { - if (this.game.gameMode === GameMode.Skirmish) { - return; - } - if (!this.hasPrompt('Select stronghold province')) { - throw new Error(`${this.name} is not prompted to select a province`); - } - card = this.findCardByName(card, 'province deck'); - this.clickCard(card); - this.clickPrompt('Done'); - } - playAttachment(attachment, target) { let card = this.clickCard(attachment, 'hand'); if (this.currentButtons.includes('Play ' + card.name + ' as an attachment')) { diff --git a/test/server/cardImplementations/01_SOR/SabineWrenExplosivesArtist.spec.js b/test/server/cardImplementations/01_SOR/SabineWrenExplosivesArtist.spec.js new file mode 100644 index 000000000..2223c623a --- /dev/null +++ b/test/server/cardImplementations/01_SOR/SabineWrenExplosivesArtist.spec.js @@ -0,0 +1,50 @@ +describe('Sabine Wren, Explosives Artist', function() { + integration(function() { + describe('Sabine', function() { + beforeEach(function () { + this.setupTest({ + phase: 'action', + player1: { + groundArena: ['sabine-wren#explosives-artist', 'battlefield-marine'], + spaceArena: ['cartel-spacer'] + }, + player2: { + groundArena: ['wampa'], + } + }); + + this.sabine = this.player1.findCardByName('sabine-wren#explosives-artist'); + this.marine = this.player1.findCardByName('battlefield-marine'); + this.wampa = this.player2.findCardByName('wampa'); + + this.p1Base = this.player1.base; + this.p2Base = this.player2.base; + + this.noMoreActions(); + + // sabine is only partially implemented, still need to handle: + // - the effect override if she gains sentinel + // - her active ability + }); + + it('should not be targetable when 3 friendly aspects are in play', function () { + this.player2.setActivePlayer(); + this.player2.clickCard(this.wampa); + + expect(this.player2).toBeAbleToSelect(this.marine); + expect(this.player2).toBeAbleToSelect(this.p1Base); + expect(this.player2).not.toBeAbleToSelect(this.sabine); + }); + + it('should be targetable when less than 3 friendly aspects are in play', function () { + this.player1.spaceArena = []; + this.player2.setActivePlayer(); + this.player2.clickCard(this.wampa); + + expect(this.player2).toBeAbleToSelect(this.marine); + expect(this.player2).toBeAbleToSelect(this.p1Base); + expect(this.player2).toBeAbleToSelect(this.sabine); + }); + }); + }); +}); diff --git a/test/server/cardImplementations/02_SHD/GroguIrresistible.spec.js b/test/server/cardImplementations/02_SHD/GroguIrresistible.spec.js index 4915974b2..81e980151 100644 --- a/test/server/cardImplementations/02_SHD/GroguIrresistible.spec.js +++ b/test/server/cardImplementations/02_SHD/GroguIrresistible.spec.js @@ -47,10 +47,7 @@ describe('Grogu, Irresistible', function() { // this is a general test of the exhaustSelf cost mechanic, don't need to repeat it for other cards that have an exhaustSelf cost it('should not be available if Grogu is exhausted', function () { this.grogu.exhausted = true; - this.player1.clickCard(this.grogu); - - // this is the default action window prompt (meaning no action was available) - expect(this.player1).toHavePrompt('Action Window'); + expect(this.grogu).not.toHaveAvailableActionWhenClickedInActionPhaseBy(this.player1); }); }); }); diff --git a/test/server/cardImplementations/02_SHD/SalaciousCrumbObnoxiousPet.spec.js b/test/server/cardImplementations/02_SHD/SalaciousCrumbObnoxiousPet.spec.js new file mode 100644 index 000000000..b5798b296 --- /dev/null +++ b/test/server/cardImplementations/02_SHD/SalaciousCrumbObnoxiousPet.spec.js @@ -0,0 +1,81 @@ +describe('Salacious Crumb, Obnoxious Pet', function() { + integration(function() { + describe('Crumb\'s when played ability', function() { + beforeEach(function () { + this.setupTest({ + phase: 'action', + player1: { + hand: ['salacious-crumb#obnoxious-pet'], + leader: ['jabba-the-hutt#his-high-exaltedness'], + resources: ['atst', 'atst', 'atst', 'atst', 'atst', 'atst'], + } + }); + + this.crumb = this.player1.findCardByName('salacious-crumb#obnoxious-pet'); + this.p1Base = this.player1.base; + + this.noMoreActions(); + }); + + it('should heal 1 from friendly base', function () { + this.p1Base.damage = 5; + this.player1.clickCard(this.crumb); + expect(this.crumb.location).toBe('ground arena'); + + expect(this.p1Base.damage).toBe(4); + }); + + it('should heal 0 from base if base has no damage', function () { + this.p1Base.damage = 0; + this.player1.clickCard(this.crumb); + expect(this.crumb.location).toBe('ground arena'); + + expect(this.p1Base.damage).toBe(0); + }); + }); + + describe('Crumb\'s action ability', function() { + beforeEach(function () { + this.setupTest({ + phase: 'action', + player1: { + groundArena: ['salacious-crumb#obnoxious-pet', 'wampa'], + }, + player2: { + groundArena: ['frontier-atrt'], + spaceArena: ['cartel-spacer'] + } + }); + + this.crumb = this.player1.findCardByName('salacious-crumb#obnoxious-pet'); + this.wampa = this.player1.findCardByName('wampa'); + this.atrt = this.player2.findCardByName('frontier-atrt'); + this.cartelSpacer = this.player2.findCardByName('cartel-spacer'); + + this.noMoreActions(); + }); + + it('should deal 1 damage to any selected ground unit', function () { + this.player1.clickCard(this.crumb); + this.player1.clickPrompt('Deal 1 damage to a ground unit'); + + // can target any ground unit + expect(this.player1).toBeAbleToSelect(this.atrt); + expect(this.player1).toBeAbleToSelect(this.wampa); + expect(this.player1).not.toBeAbleToSelect(this.player1.base); + expect(this.player1).not.toBeAbleToSelect(this.player2.base); + expect(this.player1).not.toBeAbleToSelect(this.cartelSpacer); + + this.player1.clickCard(this.atrt); + expect(this.atrt.damage).toBe(1); + expect(this.crumb.exhausted).toBe(null); // since card is no longer in play + expect(this.crumb.location).toBe('hand'); + }); + + it('should not be available if Crumb is exhausted', function () { + this.crumb.exhausted = true; + expect(this.crumb).not.toHaveAvailableActionWhenClickedInActionPhaseBy(this.player1); + }); + }); + }); +});