Implement five CHOAM Nexus Cunning effects with private costs and recovery
This commit is contained in:
1 parent
a6d9235099
commit
290259bac2
24 files changed
+1935
-131
No files matched your search
@@ -0,0 +1,228 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import { gameDistance, splitLocation, TERRITORIES } from '../game/board';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
} from './fixture-nexus-traitors';
|
||||
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
|
||||
|
||||
export type NexusChoamEffect =
|
||||
| 'kulon'
|
||||
| 'laLaLa'
|
||||
| 'gamont'
|
||||
| 'baliset'
|
||||
| 'jubba';
|
||||
export const NEXUS_CHOAM_EFFECTS: NexusChoamEffect[] = [
|
||||
'kulon',
|
||||
'laLaLa',
|
||||
'gamont',
|
||||
'baliset',
|
||||
'jubba',
|
||||
];
|
||||
export type NexusChoamFixture = {
|
||||
g: Game;
|
||||
owner: string;
|
||||
target: string;
|
||||
observer: string;
|
||||
cost: Card;
|
||||
karama?: string;
|
||||
effect: NexusChoamEffect;
|
||||
selection: Record<string, unknown>;
|
||||
};
|
||||
export function holdNexusChoamCard(g: Game, owner: string, kind: string): Card {
|
||||
const index = g.deck.findIndex(
|
||||
(c) => c.kind === kind || c.effect === kind || c.name === kind,
|
||||
);
|
||||
assert.ok(index >= 0, `Missing physical ${kind}`);
|
||||
const card = g.deck.splice(index, 1)[0];
|
||||
g.players.find((p) => p.id === owner)!.hand.push(card);
|
||||
return card;
|
||||
}
|
||||
/** Final identities, faction roster, Advanced mode and Nexus components precede
|
||||
* genuine setup. Only conserved later board positions/phase boundaries are
|
||||
* staged; the public revival, move, storm and Mentat choices use real actions. */
|
||||
export function nexusChoamFixture(
|
||||
effect: NexusChoamEffect,
|
||||
options: {
|
||||
seatIds?: [string, string, string];
|
||||
advanced?: boolean;
|
||||
opponentFaction?: 'guild' | 'emperor' | 'richese';
|
||||
costKind?: string;
|
||||
karama?: boolean;
|
||||
} = {},
|
||||
): NexusChoamFixture {
|
||||
const [owner, target, observer] = options.seatIds ?? ['p', 'q', 'r'];
|
||||
const phase =
|
||||
effect === 'laLaLa'
|
||||
? 4
|
||||
: effect === 'gamont'
|
||||
? 7
|
||||
: effect === 'jubba'
|
||||
? 0
|
||||
: 5;
|
||||
let g = nexusTraitorFixture({
|
||||
ownerFaction: 'choam',
|
||||
opponentFaction: options.opponentFaction,
|
||||
advanced: options.advanced ?? true,
|
||||
seatIds: [owner, target, observer],
|
||||
phase,
|
||||
});
|
||||
const cards = g.nexusCards!.cards!,
|
||||
index = cards.deck.indexOf('choam');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck[index] = cards.hands[owner]!;
|
||||
cards.hands[owner] = 'choam';
|
||||
for (const p of g.players) {
|
||||
Object.assign(p, {
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
spice: 20,
|
||||
moved: 0,
|
||||
shipped: true,
|
||||
revived: 0,
|
||||
});
|
||||
if (p.elites)
|
||||
Object.assign(p.elites, {
|
||||
forces: {},
|
||||
reserves: p.faction === 'emperor' ? 5 : 3,
|
||||
tanks: 0,
|
||||
revived: 0,
|
||||
});
|
||||
}
|
||||
Object.assign(g, {
|
||||
active: owner,
|
||||
order: [owner, target, observer],
|
||||
ready: [],
|
||||
phaseOpening: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
pendingRevival: null,
|
||||
choamMarket: null,
|
||||
movementRemaining: [owner, target, observer],
|
||||
spice: {},
|
||||
});
|
||||
const cost = holdNexusChoamCard(g, owner, options.costKind ?? 'projectile');
|
||||
const karama =
|
||||
options.karama === false
|
||||
? undefined
|
||||
: holdNexusChoamCard(g, target, 'karama').id;
|
||||
const p = g.players[0],
|
||||
q = g.players[1];
|
||||
let selection: Record<string, unknown> = {};
|
||||
if (effect === 'laLaLa') {
|
||||
g.revivalRules = newRevivalRules();
|
||||
q.tanks = 4;
|
||||
q.reserves = 16;
|
||||
g = applyAction(g, target, { type: 'revive', amount: 3 });
|
||||
assert.equal(g.decision?.kind, 'choamFreeRevival');
|
||||
selection = { target };
|
||||
} else if (effect === 'jubba') {
|
||||
g.storm = 5;
|
||||
g.stormPending = 3;
|
||||
p.forces = { 'red_chasm:7': 4 };
|
||||
p.reserves = 16;
|
||||
q.forces = { 'red_chasm:7': 3 };
|
||||
q.reserves = 17;
|
||||
g.spice = { 'red_chasm:7': 8 };
|
||||
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'choamStorm');
|
||||
selection = { territory: 'red_chasm' };
|
||||
} else if (effect === 'baliset') {
|
||||
const source = 'red_chasm:7';
|
||||
const to = TERRITORIES.flatMap((t) =>
|
||||
t.sectors.map((s) => `${t.id}:${s}`),
|
||||
).find((key) => {
|
||||
const loc = splitLocation(key);
|
||||
return (
|
||||
loc.territory !== 'red_chasm' &&
|
||||
loc.sector !== g.storm &&
|
||||
gameDistance(
|
||||
g,
|
||||
source,
|
||||
key,
|
||||
(k) => splitLocation(k).sector === g.storm,
|
||||
) === 1
|
||||
);
|
||||
});
|
||||
assert.ok(to);
|
||||
p.forces = { [to]: 1 };
|
||||
p.reserves = 19;
|
||||
q.forces = { [source]: 3 };
|
||||
q.reserves = 17;
|
||||
g.active = target;
|
||||
g = applyAction(g, target, {
|
||||
type: 'move',
|
||||
from: source,
|
||||
amount: 2,
|
||||
...splitLocation(to),
|
||||
});
|
||||
assert.equal(g.decision?.kind, 'choamMovement');
|
||||
selection = { target, territory: splitLocation(to).territory };
|
||||
} else if (effect === 'gamont') {
|
||||
q.forces = { 'arrakeen:10': 2 };
|
||||
q.reserves = 18;
|
||||
for (const id of g.order)
|
||||
if (g.phase === 7) g = applyAction(g, id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'choamMarket');
|
||||
g = applyAction(g, owner, { type: 'decision', done: true });
|
||||
for (const id of g.order)
|
||||
if (!g.ready.includes(id)) g = applyAction(g, id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'choamMarket');
|
||||
g = applyAction(g, owner, { type: 'decision', done: true });
|
||||
assert.equal(g.decision?.kind, 'choamMentat');
|
||||
selection = { target, from: 'arrakeen:10', elite: 0 };
|
||||
} else {
|
||||
p.forces = { 'red_chasm:7': 3 };
|
||||
p.reserves = 17;
|
||||
}
|
||||
nexusChoamInventory(g);
|
||||
return { g, owner, target, observer, cost, karama, effect, selection };
|
||||
}
|
||||
export function nexusChoamRequest(f: NexusChoamFixture, g = f.g): Action {
|
||||
return {
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: f.cost.id,
|
||||
effect: f.effect,
|
||||
nexus: JSON.stringify([
|
||||
'nexusChoam',
|
||||
g.turn,
|
||||
g.phase,
|
||||
f.owner,
|
||||
f.cost.id,
|
||||
f.effect,
|
||||
]),
|
||||
...f.selection,
|
||||
};
|
||||
}
|
||||
export function nexusChoamInventory(g: Game) {
|
||||
if (!g.richeseCache) return nexusTraitorInventory(g);
|
||||
const extra = richeseCards(),
|
||||
ids = new Set(extra.map((card) => card.id));
|
||||
assert.deepEqual(
|
||||
[
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...g.richeseCache,
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort(),
|
||||
[...treacheryDeck(g.expansions), ...extra].map((card) => card.id).sort(),
|
||||
);
|
||||
// The shared traitor fixture checks the ordinary deck. Above we independently
|
||||
// include every native Richese component, then reuse its other physical checks.
|
||||
const ordinary = structuredClone(g);
|
||||
ordinary.deck = ordinary.deck.filter((card) => !ids.has(card.id));
|
||||
ordinary.discard = ordinary.discard.filter((card) => !ids.has(card.id));
|
||||
for (const p of ordinary.players)
|
||||
p.hand = p.hand.filter((card) => !ids.has(card.id));
|
||||
nexusTraitorInventory(ordinary);
|
||||
}
|
||||
export const nexusChoamAllow = nexusAllow;
|
||||
export const nexusChoamReload = nexusReload;
|
||||
Reference in new issue
Block a user