Add a New Card to TFM
Workflow
Adding a card requires exactly 4 steps:
- Register the card name in
src/common/cards/CardName.ts
- Implement the card in
src/server/cards/commission/<CardName>.ts
- Register in manifest in
src/server/cards/commission/CommissionCardManifest.ts
- Write unit tests in
tests/cards/commission/<CardName>.spec.ts
Before implementing, confirm with the user:
- Card name and display name
- Card type:
CorporationCard or IProjectCard (and sub-type: ACTIVE, AUTOMATED, EVENT. Somethings User may ask for "蓝卡", "绿卡", which means: Blue -> Active, Green -> Automated, Red -> Event)
- Tags, cost, requirements, victory points
- Card effect/ability (exact mechanics and numerical values)
- Card number (XB series)
If any detail is unclear or ambiguous, ask the user to clarify before proceeding.
Step 1: Register Card Name
Add an entry to the CardName enum in src/common/cards/CardName.ts.
Commission cards use 🌸 prefix/suffix in the display string. Find the commission card section (near line 626+) and append:
typescript
1// In the commission cards section of CardName enum:
2MY_NEW_CARD = '🌸My New Card🌸',
Naming conventions:
- Enum key:
UPPER_SNAKE_CASE
- Value:
'🌸Display Name🌸' (Chinese names are also acceptable, e.g. '🌸城电转能🌸')
Step 2: Implement the Card
Create a new file in src/server/cards/commission/. File name uses UpperCamelCase (e.g. MyNewCard.ts).
Determine Card Type
Read the card details to determine which base class to use:
| Type | Base Class | Implements | When to Use |
|---|
| Corporation | CorporationCard | ICorporationCard | Cards with startingMegaCredits, corp-level effects |
| Active Corp | ActiveCorporationCard | ICorporationCard + IActionCard | Corp cards with per-turn action() |
| Project (Active) | Card | IProjectCard | Blue cards with ongoing effects or actions |
| Project (Automated) | Card | IProjectCard | Green cards with one-time effects |
| Project (Event) | Card | IProjectCard | Red cards with one-time effects |
| Project w/ Action | ActionCard | IProjectCard + has action behavior | Blue cards with data-driven action |
Template: Corporation Card
typescript
1import {Tag} from '../../../common/cards/Tag';
2import {IPlayer} from '../../IPlayer';
3import {CardName} from '../../../common/cards/CardName';
4import {CardRenderer} from '../render/CardRenderer';
5import {CorporationCard} from '../corporation/CorporationCard';
6
7export class MyCorpCard extends CorporationCard {
8 constructor() {
9 super({
10 name: CardName.MY_CORP_CARD,
11 tags: [Tag.SCIENCE],
12 startingMegaCredits: 50,
13 // Optional: initialActionText for first action description
14 // initialActionText: 'Draw 1 card with a science tag',
15
16 metadata: {
17 cardNumber: 'XB??',
18 description: 'You start with 50 M€.',
19 renderData: CardRenderer.builder((b) => {
20 b.megacredits(50);
21 b.corpBox('effect', (ce) => {
22 ce.effect('description of effect', (eb) => {
23 eb.cards(1).startEffect.megacredits(1);
24 });
25 });
26 }),
27 },
28 });
29 }
30
31 // Override for first-action corps
32 // public override initialAction(player: IPlayer) { ... }
33
34 // For effects triggered when any player plays a card:
35 // public onCardPlayedByAnyPlayer(owner: IPlayer, card: ICard, currentPlayer: IPlayer) { ... }
36
37 // For effects triggered when this corp's owner plays a card:
38 // public onCardPlayedForCorps(player: IPlayer, card: ICard) { ... }
39}
Template: Project Card (IProjectCard)
typescript
1import {CardName} from '../../../common/cards/CardName';
2import {CardType} from '../../../common/cards/CardType';
3import {Tag} from '../../../common/cards/Tag';
4import {CardRenderer} from '../render/CardRenderer';
5import {Card} from '../Card';
6import {IProjectCard} from '../IProjectCard';
7
8export class MyProjectCard extends Card implements IProjectCard {
9 constructor() {
10 super({
11 name: CardName.MY_PROJECT_CARD,
12 type: CardType.ACTIVE, // or AUTOMATED, EVENT
13 tags: [Tag.BUILDING],
14 cost: 15,
15 // victoryPoints: 1, // static VP
16 // requirements: {tag: Tag.SCIENCE, count: 2}, // requirements
17
18 // For simple effects, use behavior instead of bespokePlay:
19 // behavior: {
20 // production: {energy: 1},
21 // stock: {megacredits: 3},
22 // global: {temperature: 1},
23 // drawCard: 1,
24 // },
25
26 metadata: {
27 cardNumber: 'XB??',
28 // description: 'Requires 2 science tags. ...',
29 renderData: CardRenderer.builder((b) => {
30 b.effect('When you play a building tag, gain 2 M€.', (eb) => {
31 eb.tag(Tag.BUILDING).startEffect.megacredits(2);
32 });
33 }),
34 },
35 });
36 }
37
38 // --- Callback methods (choose what applies) ---
39
40 // For ACTIVE cards with a player action:
41 // public canAct(player: IPlayer): boolean { return true; }
42 // public action(player: IPlayer) { return undefined; }
43
44 // For triggered effects when this card's owner plays a card:
45 // public onCardPlayed(player: IPlayer, card: IProjectCard) { ... }
46
47 // For triggered effects when any tile is placed:
48 // public onTilePlaced(cardOwner: IPlayer, activePlayer: IPlayer, space: Space) { ... }
49
50 // For card cost discounts:
51 // public override getCardDiscount(player: IPlayer, card: ICard): number { return 0; }
52
53 // For custom VP calculation:
54 // public override getVictoryPoints(player: IPlayer): number { return 0; }
55}
Common Patterns Reference
See references/card-patterns.md for detailed examples of common card patterns including:
- Cards with
behavior (declarative effects)
- Cards with
onCardPlayed / onCardPlayedByAnyPlayer callbacks
- Cards with
onTilePlaced callbacks
- Cards with
canAct() / action() (blue card actions)
- Cards with
getCardDiscount()
- Cards with requirements
- Corporation cards with
initialAction and corpBox
Step 3: Register in Manifest
Edit src/server/cards/commission/CommissionCardManifest.ts:
- Add import at top:
typescript
1import {MyNewCard} from './MyNewCard';
- Add entry in the appropriate section of
COMMISSION_CARD_MANIFEST:
For corporation cards:
typescript
1corporationCards: {
2 // ... existing entries
3 [CardName.MY_NEW_CARD]: {Factory: MyNewCard}, // XB??
4},
For project cards:
typescript
1projectCards: {
2 // ... existing entries
3 [CardName.MY_NEW_CARD]: {Factory: MyNewCard}, // XB??
4},
Optional: add compatibility for expansion-dependent cards:
typescript
1[CardName.MY_NEW_CARD]: {Factory: MyNewCard, compatibility: 'turmoil'},
Step 4: Write Unit Tests
Create a test file at tests/cards/commission/<CardName>.spec.ts.
Test Command
Run tests for a single card:
bash
1npx ts-mocha -p tests/tsconfig.json --reporter-option maxDiffSize=256 -r tests/testing/setup.ts tests/cards/commission/<CardName>.spec.ts
Run all commission card tests:
bash
1npx ts-mocha -p tests/tsconfig.json --reporter-option maxDiffSize=256 -r tests/testing/setup.ts 'tests/cards/commission/**/*.spec.ts'
Test Template
typescript
1import {expect} from 'chai';
2import {MyCard} from '../../../src/server/cards/commission/MyCard';
3import {testGame} from '../../TestGame';
4import {TestPlayer} from '../../TestPlayer';
5import {runAllActions, cast} from '../../TestingUtils';
6import {IGame} from '../../../src/server/IGame';
7import {Tag} from '../../../src/common/cards/Tag';
8import {CardType} from '../../../src/common/cards/CardType';
9import {Resource} from '../../../src/common/Resource';
10
11describe('MyCard', () => {
12 let card: MyCard;
13 let player: TestPlayer;
14 let game: IGame;
15
16 beforeEach(() => {
17 card = new MyCard();
18 [game, player] = testGame(2, {skipInitialShuffling: true});
19 player.megaCredits = 100;
20 });
21
22 it('basic properties', () => {
23 expect(card.cost).to.eq(15);
24 expect(card.type).to.eq(CardType.ACTIVE);
25 expect(card.tags).to.deep.eq([Tag.BUILDING]);
26 });
27
28 it('should apply initial behavior on play', () => {
29 player.playCard(card);
30 runAllActions(game);
31 // Assert production, stock, or other changes
32 });
33
34 // Add more tests for each card effect/ability
35});
Testing Tips
- Use
testGame(n, {skipInitialShuffling: true}) for deterministic setup
- Use
runAllActions(game) after play/actions to resolve deferred actions
- Use
cast(input, OrOptions) to type-check player input responses
- Use
player.popWaitingFor() to get pending player input
- When setting
card.resourceCount manually, do so after runAllActions(game) to avoid other card triggers (e.g. Decomposers) interfering
- Known engine limitation:
play() fires before the card is added to playedCards, so onProductionGain / other tableau callbacks won't self-trigger during initial play (the "including this" pattern)
- For corporation card tests, use
player.playCorporationCard(card) instead of player.playCard(card)
- For testing
canAct() / action(), manually set up the required state (resources, cards, etc.) then call the methods directly
Key Imports Quick Reference
typescript
1// Common imports for card implementation
2import {CardName} from '../../../common/cards/CardName';
3import {CardType} from '../../../common/cards/CardType';
4import {Tag} from '../../../common/cards/Tag';
5import {Resource} from '../../../common/Resource';
6import {CardResource} from '../../../common/CardResource';
7import {CardRenderer} from '../render/CardRenderer';
8import {Size} from '../../../common/cards/render/Size';
9import {AltSecondaryTag} from '../../../common/cards/render/AltSecondaryTag';
10import {Card} from '../Card';
11import {IProjectCard} from '../IProjectCard';
12import {ICard} from '../ICard';
13import {IPlayer} from '../../IPlayer';
14import {CorporationCard} from '../corporation/CorporationCard';
15import {Board} from '../../boards/Board';
16import {Space} from '../../boards/Space';
CardRenderer DSL Quick Reference
Resources: megacredits(n), steel(n), titanium(n), plants(n), energy(n), heat(n), cards(n), tr(n)
Global params: temperature(n), oxygen(n), oceans(n), venus(n)
Tiles: city(), greenery(), emptyTile(), specialTile()
Tags: tag(Tag.XXX), wild(n), noTags()
Production: production((pb) => { pb.energy(1); })
Layout: .br (line break), .nbsp, vSpace(size?), text(str), vpText(str)
Effect box: effect(desc, (eb) => { eb.cause.startEffect.result })
Action box: action(desc, (eb) => { eb.cost.startAction.result })
Corp box: corpBox('effect'|'action', (ce) => { ce.effect(...) })
Options: {all: true} any player, {secondaryTag: Tag.XXX}, {size: Size.SMALL}