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:
CorporationCardorIProjectCard(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:
typescript1// 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
typescript1import {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)
typescript1import {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/onCardPlayedByAnyPlayercallbacks - Cards with
onTilePlacedcallbacks - Cards with
canAct()/action()(blue card actions) - Cards with
getCardDiscount() - Cards with requirements
- Corporation cards with
initialActionandcorpBox
Step 3: Register in Manifest
Edit src/server/cards/commission/CommissionCardManifest.ts:
- Add import at top:
typescript1import {MyNewCard} from './MyNewCard';
- Add entry in the appropriate section of
COMMISSION_CARD_MANIFEST:
For corporation cards:
typescript1corporationCards: { 2 // ... existing entries 3 [CardName.MY_NEW_CARD]: {Factory: MyNewCard}, // XB?? 4},
For project cards:
typescript1projectCards: { 2 // ... existing entries 3 [CardName.MY_NEW_CARD]: {Factory: MyNewCard}, // XB?? 4},
Optional: add compatibility for expansion-dependent cards:
typescript1[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:
bash1npx 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:
bash1npx ts-mocha -p tests/tsconfig.json --reporter-option maxDiffSize=256 -r tests/testing/setup.ts 'tests/cards/commission/**/*.spec.ts'
Test Template
typescript1import {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.resourceCountmanually, do so afterrunAllActions(game)to avoid other card triggers (e.g. Decomposers) interfering - Known engine limitation:
play()fires before the card is added toplayedCards, soonProductionGain/ other tableau callbacks won't self-trigger during initial play (the "including this" pattern) - For corporation card tests, use
player.playCorporationCard(card)instead ofplayer.playCard(card) - For testing
canAct()/action(), manually set up the required state (resources, cards, etc.) then call the methods directly
Key Imports Quick Reference
typescript1// 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}