Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chirrut, First Light, Falcon2 #427

Merged
merged 13 commits into from
Jan 10, 2025
35 changes: 35 additions & 0 deletions server/game/cards/01_SOR/leaders/ChirrutImweOneWithTheForce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import AbilityHelper from '../../../AbilityHelper';
import { LeaderUnitCard } from '../../../core/card/LeaderUnitCard';
import { PhaseName, WildcardCardType } from '../../../core/Constants';

export default class ChirrutImweOneWithTheForce extends LeaderUnitCard {
protected override getImplementationId() {
return {
id: '4263394087',
internalName: 'chirrut-imwe#one-with-the-force',
};
}

protected override setupLeaderSideAbilities() {
this.addActionAbility({
title: 'Give a unit +0/+2 for this phase',
cost: AbilityHelper.costs.exhaustSelf(),
targetResolver: {
cardTypeFilter: WildcardCardType.Unit,
immediateEffect: AbilityHelper.immediateEffects.forThisPhaseCardEffect({
effect: AbilityHelper.ongoingEffects.modifyStats({ power: 0, hp: 2 })
})
}
});
}

protected override setupLeaderUnitSideAbilities() {
this.addConstantAbility({
title: 'During the action phase, this unit isn\'t defeated by having no remaining HP',
ongoingEffect: AbilityHelper.ongoingEffects.cannotBeDefeatedByDamage(),
condition: (context) => context.game.currentPhase === PhaseName.Action
});
}
}

ChirrutImweOneWithTheForce.implemented = true;
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default class RelentlessKonstantinesFolly extends NonLeaderUnitCard {
});
}

// TODO: fix this to use "context.source" instead of "this"
private isFirstEventPlayedByThisOpponentThisPhase(card) {
return card.controller !== this.controller && card.type === CardType.Event && !this.cardsPlayedThisPhaseWatcher.someCardPlayed((playedCardEntry) =>
playedCardEntry.playedBy === card.controller &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import AbilityHelper from '../../../AbilityHelper';
import { PlayUnitAction } from '../../../actions/PlayUnitAction';
import type { IPlayCardActionProperties } from '../../../core/ability/PlayCardAction';
import type { IPlayCardActionOverrides } from '../../../core/card/baseClasses/PlayableOrDeployableCard';
import { NonLeaderUnitCard } from '../../../core/card/NonLeaderUnitCard';
import { Aspect, KeywordName, PlayType, RelativePlayer, WildcardCardType } from '../../../core/Constants';

export default class FirstLightHeadquartersOfTheCrimsonDawn extends NonLeaderUnitCard {
protected override getImplementationId() {
return {
id: '4783554451',
internalName: 'first-light#headquarters-of-the-crimson-dawn',
};
}

protected override buildPlayCardActions(playType: PlayType = PlayType.PlayFromHand, propertyOverrides: IPlayCardActionOverrides = null) {
const firstLightSmuggleAction = playType === PlayType.Smuggle
? [new FirstLightSmuggleAction(this)]
: [];

return super.buildPlayCardActions(playType, propertyOverrides).concat(firstLightSmuggleAction);
}

public override setupCardAbilities() {
this.addConstantAbility({
title: 'Each other friendly non-leader unit gains Grit',
targetController: RelativePlayer.Self,
targetCardTypeFilter: WildcardCardType.NonLeaderUnit,
matchTarget: (card, context) => card !== context.source,
ongoingEffect: AbilityHelper.ongoingEffects.gainKeyword(KeywordName.Grit)
});
}
}

class FirstLightSmuggleAction extends PlayUnitAction {
private static generateProperties(properties: IPlayCardActionOverrides = {}): IPlayCardActionProperties {
const damageCost = AbilityHelper.costs.dealDamage(4, {
controller: RelativePlayer.Self,
cardTypeFilter: WildcardCardType.Unit
});

return {
title: 'Play First Light with Smuggle by dealing 4 damage to a friendly unit',
...properties,
playType: PlayType.Smuggle,
smuggleAspects: [Aspect.Vigilance, Aspect.Villainy],
smuggleResourceCost: 7,
additionalCosts: [damageCost],
appendSmuggleToTitle: false
};
}

public constructor(card: FirstLightHeadquartersOfTheCrimsonDawn, propertyOverrides: IPlayCardActionOverrides = {}) {
super(card, FirstLightSmuggleAction.generateProperties(propertyOverrides));
}

public override clone(overrideProperties: Partial<Omit<IPlayCardActionProperties, 'playType'>>) {
return new FirstLightSmuggleAction(
this.card,
FirstLightSmuggleAction.generateProperties({
...this.createdWithProperties,
...overrideProperties
})
);
}
}

FirstLightHeadquartersOfTheCrimsonDawn.implemented = true;
30 changes: 30 additions & 0 deletions server/game/cards/02_SHD/units/MillenniumFalconLandosPride.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import AbilityHelper from '../../../AbilityHelper';
import { NonLeaderUnitCard } from '../../../core/card/NonLeaderUnitCard';
import { EventName, KeywordName, PlayType } from '../../../core/Constants';

export default class MillenniumFalconLandosPride extends NonLeaderUnitCard {
protected override getImplementationId() {
return {
id: '5752414373',
internalName: 'millennium-falcon#landos-pride',
};
}

public override setupCardAbilities(sourceCard: this) {
let lastPlayedFromHandId: number | null = null;

this.game.on(EventName.OnCardPlayed, (event) => {
if (event.card === sourceCard && event.playType === PlayType.PlayFromHand) {
lastPlayedFromHandId = event.card.inPlayId;
}
});

this.addConstantAbility({
title: 'This unit gains Ambush if it was played from hand',
condition: (context) => context.source.isInPlay() && lastPlayedFromHandId === context.source.inPlayId,
ongoingEffect: AbilityHelper.ongoingEffects.gainKeyword(KeywordName.Ambush)
});
}
}

MillenniumFalconLandosPride.implemented = true;
1 change: 1 addition & 0 deletions server/game/core/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export enum EffectName {
CanAttackGroundArenaFromSpaceArena = 'canAttackGroundArenaFromSpaceArena',
CanAttackSpaceArenaFromGroundArena = 'canAttackSpaceArenaFromGroundArena',
CanBeTriggeredByOpponent = 'canBeTriggeredByOpponent',
CannotBeDefeatedByDamage = 'cannotBeDefeatedByDamage',
CanPlayFromDiscard = 'canPlayFromDiscard',
ChangeType = 'changeType',
CostAdjuster = 'costAdjuster',
Expand Down
2 changes: 1 addition & 1 deletion server/game/core/ability/KeywordInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class KeywordWithCostValues extends KeywordInstance {
name: KeywordName,
public readonly cost: number,
public readonly aspects: Aspect[],
public readonly additionalCosts: boolean // TODO: implement additional costs (First Light)
public readonly additionalSmuggleCosts: boolean
) {
super(name);
}
Expand Down
9 changes: 6 additions & 3 deletions server/game/core/ability/PlayCardAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface ISmuggleCardActionProperties extends IPlayCardActionPropertiesB
playType: PlayType.Smuggle;
smuggleResourceCost: number;
smuggleAspects: Aspect[];
appendSmuggleToTitle?: boolean;
}

export type IPlayCardActionProperties = IStandardPlayActionProperties | ISmuggleCardActionProperties;
Expand Down Expand Up @@ -61,9 +62,11 @@ export abstract class PlayCardAction extends PlayerAction {

let cost: number;
let aspects: Aspect[];
let appendSmuggleToTitle: boolean = null;
if (properties.playType === PlayType.Smuggle) {
cost = properties.smuggleResourceCost;
aspects = properties.smuggleAspects;
appendSmuggleToTitle = properties.appendSmuggleToTitle;
} else {
cost = card.cost;
aspects = card.aspects;
Expand All @@ -75,7 +78,7 @@ export abstract class PlayCardAction extends PlayerAction {

super(
card,
PlayCardAction.getTitle(propertiesWithDefaults.title, propertiesWithDefaults.playType, usesExploit),
PlayCardAction.getTitle(propertiesWithDefaults.title, propertiesWithDefaults.playType, usesExploit, appendSmuggleToTitle),
propertiesWithDefaults.additionalCosts.concat(playCost),
propertiesWithDefaults.targetResolver,
propertiesWithDefaults.triggerHandlingMode
Expand All @@ -88,12 +91,12 @@ export abstract class PlayCardAction extends PlayerAction {
this.createdWithProperties = { ...properties };
}

private static getTitle(title: string, playType: PlayType, withExploit: boolean = false): string {
private static getTitle(title: string, playType: PlayType, withExploit: boolean = false, appendToTitle: boolean = true): string {
let updatedTitle = title;

switch (playType) {
case PlayType.Smuggle:
updatedTitle += ' with Smuggle';
updatedTitle += appendToTitle ? ' with Smuggle' : '';
break;
case PlayType.PlayFromHand:
case PlayType.PlayFromOutOfPlay:
Expand Down
4 changes: 2 additions & 2 deletions server/game/core/card/LeaderCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class LeaderCard extends InPlayCard {

this.hasImplementationFile = true;
this.setupLeaderUnitSide = false;
this.setupLeaderSideAbilities();
this.setupLeaderSideAbilities(this);
}

public override isLeader(): this is LeaderCard {
Expand All @@ -33,7 +33,7 @@ export class LeaderCard extends InPlayCard {
/**
* Create card abilities for the leader (non-unit) side by calling subsequent methods with appropriate properties
*/
protected setupLeaderSideAbilities() {
protected setupLeaderSideAbilities(sourceCard: this) {
this.hasImplementationFile = false;
}

Expand Down
4 changes: 2 additions & 2 deletions server/game/core/card/LeaderUnitCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class LeaderUnitCard extends LeaderUnitCardParent {
super(owner, cardData);

this.setupLeaderUnitSide = true;
this.setupLeaderUnitSideAbilities();
this.setupLeaderUnitSideAbilities(this);

// add deploy leader action
this.addActionAbility({
Expand Down Expand Up @@ -71,7 +71,7 @@ export class LeaderUnitCard extends LeaderUnitCardParent {
* Create card abilities for the leader unit side by calling subsequent methods with appropriate properties
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
protected setupLeaderUnitSideAbilities() {
protected setupLeaderUnitSideAbilities(sourceCard: this) {
}

protected override addActionAbility(properties: IActionAbilityProps<this>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ export class PlayableOrDeployableCard extends Card {
protected buildCheapestSmuggleAction(propertyOverrides: IPlayCardActionOverrides = null) {
Contract.assertTrue(this.hasSomeKeyword(KeywordName.Smuggle));

const smuggleKeywords = this.getKeywordsWithCostValues(KeywordName.Smuggle);
// find all Smuggle keywords, filtering out any with additional ability costs as those will be implemented manually (e.g. First Light)
const smuggleKeywords = this.getKeywordsWithCostValues(KeywordName.Smuggle)
.filter((keyword) => !keyword.additionalSmuggleCosts);

const smuggleActions = smuggleKeywords.map((smuggleKeyword) => {
const smuggleActionProps: ISmuggleCardActionProperties = {
...propertyOverrides,
Expand Down
7 changes: 6 additions & 1 deletion server/game/core/card/propertyMixins/Damage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type Player from '../../Player';
import type { CardWithDamageProperty } from '../CardTypes';
import { WithPrintedHp } from './PrintedHp';
import type { IDamageSource } from '../../../IDamageOrDefeatSource';
import { EffectName } from '../../Constants';

/**
* Mixin function that adds the `damage` property and corresponding methods to a base class.
Expand Down Expand Up @@ -74,8 +75,12 @@ export function WithDamage<TBaseClass extends CardConstructor>(BaseClass: TBaseC
if (amount === 0) {
return 0;
}
// if a card can't be defeated by damage (e.g. Chirrut) we consider all damage to have been
// applied to the card, even if it goes above max hp (important for overwhelm calculation)
const damageToAdd = this.hasOngoingEffect(EffectName.CannotBeDefeatedByDamage)
? amount
: Math.min(amount, this.remainingHp);

const damageToAdd = Math.min(amount, this.remainingHp);
this.damage += damageToAdd;

return damageToAdd;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function WithStandardAbilitySetup<TBaseClass extends CardConstructor>(Bas
super(...args);

this.hasImplementationFile = true;
this.setupCardAbilities();
this.setupCardAbilities(this);

// if an implementation file is provided, enforce that all keywords requiring explicit setup have been set up
if (this.hasImplementationFile) {
Expand All @@ -25,7 +25,7 @@ export function WithStandardAbilitySetup<TBaseClass extends CardConstructor>(Bas
/**
* Create card abilities by calling subsequent methods with appropriate properties
*/
protected setupCardAbilities() {
protected setupCardAbilities(sourceCard: this) {
this.hasImplementationFile = false;
}
};
Expand Down
7 changes: 6 additions & 1 deletion server/game/core/card/propertyMixins/UnitProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,12 @@ export function WithUnitProperties<TBaseClass extends InPlayCardConstructor>(Bas
this.checkDefeated(DefeatSourceType.FrameworkEffect);
}

private checkDefeated(source: IDamageSource | DefeatSourceType.FrameworkEffect) {
protected checkDefeated(source: IDamageSource | DefeatSourceType.FrameworkEffect) {
// if this card can't be defeated by damage (e.g. Chirrut), skip the check
if (this.hasOngoingEffect(EffectName.CannotBeDefeatedByDamage)) {
return;
}

if (this.damage >= this.getHp() && !this._pendingDefeat) {
// add defeat event to window
this.game.addSubwindowEvents(
Expand Down
1 change: 1 addition & 0 deletions server/game/ongoingEffects/OngoingEffectLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export = {
// mustBeDeclaredAsAttackerIfType: (type = 'both') =>
// OngoingEffectBuilder.card.static(EffectName.MustBeDeclaredAsAttackerIfType, type),
// mustBeDeclaredAsDefender: (type = 'both') => OngoingEffectBuilder.card.static(EffectName.MustBeDeclaredAsDefender, type),
cannotBeDefeatedByDamage: () => OngoingEffectBuilder.card.static(EffectName.CannotBeDefeatedByDamage),
// setBaseDash: (type) => OngoingEffectBuilder.card.static(EffectName.SetBaseDash, type),
// setBaseMilitarySkill: (value) => OngoingEffectBuilder.card.static(EffectName.SetBaseMilitarySkill, value),
// setBasePoliticalSkill: (value) => OngoingEffectBuilder.card.static(EffectName.SetBasePoliticalSkill, value),
Expand Down
4 changes: 3 additions & 1 deletion test/helpers/PlayerInteractionWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ class PlayerInteractionWrapper {
card = this.findCardByName(options.card, prevZones, opponentControlled ? 'opponent' : null);
}

if (card.isUnit() && card.defaultArena !== arenaName) {
if (!card.isUnit()) {
throw new TestSetupError(`Attempting to add non-unit card ${card.internalName} to ${arenaName}`);
} else if (card.defaultArena !== arenaName) {
throw new TestSetupError(`Attempting to place ${card.internalName} in invalid arena '${arenaName}'`);
}

Expand Down
12 changes: 12 additions & 0 deletions test/server/cards/01_SOR/events/Bamboozle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ describe('Bamboozle', function () {

// no resource exhausted since the last action
expect(context.player1.exhaustedResourceCount).toBe(2);

reset();

// alternate play mode should no longer be available since no cunning card in hand
context.player1.clickCard(context.bamboozle);
expect(context.player1).toBeAbleToSelectExactly([context.battlefieldMarine, context.greenSquadronAwing, context.sawGerrera]);
context.player1.clickCard(context.greenSquadronAwing);

expect(context.bamboozle).toBeInZone('discard');
expect(context.player2).toBeActivePlayer();
expect(context.p1Base.damage).toBe(2);
expect(context.player1.exhaustedResourceCount).toBe(4);
});

it('Bamboozle\'s play modes should be available even if it is played by another card\'s effect', function () {
Expand Down
Loading
Loading