Compose Harkonnen Ixian replacement before native purchase bonus

This commit is contained in:
Quintine committed 2026-10-16 03:19:26 +11:00
1 parent f268a36a11
commit 2c6bb02ccb
12 files changed
+229 -55

No files matched your search

@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action } from '../game/engine';
import { createIxianNexusReplacementFixture, settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
/** Original four-seat setup, qualifying alliance/Nexus draw and normal paid
* purchases. Atreides retains its original starting Karama as a real counter.
* Only undealt deck/Nexus order is selected; no played state is staged. */
export function createOriginalHarkonnenReplacement(advanced = false) {
const lobby = createGame('HARKREPLACE', newPlayer('a', 'Atreides', 'atreides'), advanced, []);
joinGame(lobby, newPlayer('e', 'Emperor', 'emperor'));
joinGame(lobby, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(lobby, newPlayer('g', 'Guild', 'guild'));
const first = createIxianNexusReplacementFixture({ initial: lobby, face: 'richese',
otherDraw: { player: 'h', face: 'ixians' } });
let game = settleIxianReplacementFixture(applyAction(first.game, first.buyer,
{ type: 'nexusIxianReplacementPass', event: first.event }));
for (let n = 0; game.auction!.active !== 'h' && n < 20; n++)
game = applyAction(game, game.auction!.active, { type: 'passBid' });
const lot = game.auction!.cards[game.auction!.index];
const beforeBid = structuredClone(game);
game = applyAction(game, 'h', { type: 'bid', amount: 2 });
let beforePurchase = structuredClone(game);
let purchaseAction: Action = { type: 'passBid' }, actor = '';
for (let n = 0; !game.currentAuctionSale && n < 30; n++) {
beforePurchase = structuredClone(game);
actor = game.decision?.player ?? game.auction!.active;
purchaseAction = game.decision?.kind === 'auctionPayment'
? { type: 'decision', karama: false } : { type: 'passBid' };
game = applyAction(game, actor, purchaseAction);
}
assert.ok(game.currentAuctionSale);
assert.equal(game.currentAuctionSale.winner, 'h');
return { game, beforeBid, beforePurchase, purchaseAction, actor, lot,
buyer: 'h', counter: 'a', next: game.deck[0], afterNext: game.deck[1],
provenance: 'Original four-seat setup, native worm/alliance and two natural Nexus draws, actual Atreides first purchase/Keep then paid Harkonnen second lot; no played-state staging.' };
}
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { createOriginalHarkonnenReplacement } from './fixture-harkonnen-ixian-replacement';
import { settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
function passIncome(state: Game): Game {
let game = state;
for (let n = 0; game.response?.kind === 'emperorIncome' && n < 10; n++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id))!;
game = applyAction(game, actor.id, { type: 'passResponse' });
}
return game;
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} Harkonnen replacement precedes one original cancelable purchase bonus`, () => {
for (const replace of [false, true]) for (const cancelBonus of [false, true]) {
const f = createOriginalHarkonnenReplacement(advanced);
const source = f.game.pendingNexusIxianReplacement!;
assert.equal(source.card.id, f.lot.id);
assert.equal(player(f.game, 'h').hand.length, 3, 'No bonus is drawn before the replacement choice');
assert.ok(viewGame(f.game, 'h').nexusIxianReplacement!.canUse);
assert.equal(f.game.response, null);
let game = applyAction(f.game, 'h', { type: replace ? 'nexusIxianReplacementUse' : 'nexusIxianReplacementPass', event: source.event });
assert.equal(player(game, 'h').hand.some(c => c.id === f.lot.id), !replace);
assert.equal(player(game, 'h').hand.some(c => c.id === f.next.id), replace);
game = passIncome(game);
assert.equal(game.response?.kind, 'harkonnenBonus');
assert.equal(player(game, 'h').hand.length, 3);
const bonus = replace ? f.afterNext : f.next;
assert.ok(bonus);
if (cancelBonus) {
const counter = player(game, f.counter).hand.find(c => c.effect === 'karama')!;
game = applyAction(game, f.counter, { type: 'card', card: counter.id, mode: 'cancel' });
}
game = settleIxianReplacementFixture(game);
assert.equal(player(game, 'h').hand.length, cancelBonus ? 3 : 4);
assert.equal(player(game, 'h').hand.some(c => c.id === bonus.id), !cancelBonus);
assert.equal(player(game, 'h').spice, player(f.beforeBid, 'h').spice - 2);
assert.equal(player(game, 'e').spice, player(f.beforeBid, 'e').spice + 2);
assert.equal(game.nexusCards!.cards!.hands.h, replace ? null : 'ixians');
assert.equal(game.nexusIxianReplacementHistory!.length, 2);
assert.equal(game.nexusIxianReplacementHistory!.at(-1)!.source.card.id, f.lot.id);
assert.equal(game.pendingNexusIxianReplacement, null);
}
});
}
void test('Harkonnen replacement at eight cards cannot manufacture a purchase-bonus slot', () => {
for (const advanced of [false, true]) for (const replace of [false, true]) {
const f = createOriginalHarkonnenReplacement(advanced);
const parent = structuredClone(f.beforePurchase), owner = player(parent, 'h');
// CONTROLLED capacity boundary: conserve original undealt cards into
// Harkonnen custody without changing the actual auction, money or Nexus.
while (owner.hand.length < 7) {
const index = parent.deck.findIndex(c => c.effect !== 'karama'); assert.ok(index >= 0);
owner.hand.push(parent.deck.splice(index, 1)[0]);
}
let game = applyAction(parent, f.actor, f.purchaseAction);
assert.equal(player(game, 'h').hand.length, 8);
const count = game.deck.length;
const source = game.pendingNexusIxianReplacement!; assert.equal(source.card.id, f.lot.id);
game = settleIxianReplacementFixture(applyAction(game, 'h',
{ type: replace ? 'nexusIxianReplacementUse' : 'nexusIxianReplacementPass', event: source.event }));
assert.equal(player(game, 'h').hand.length, 8);
assert.equal(game.deck.length, count - Number(replace));
assert.equal(player(game, 'h').hand.some(c => c.id === f.lot.id), !replace);
assert.equal(player(game, 'h').spice, owner.spice - 2);
assert.equal(game.nexusIxianReplacementHistory!.length, 2);
}
});
@@ -270,36 +270,6 @@ void test('a later genuine purchase creates a new source while the closed prior
inventory(use);
});
void test('a genuine Harkonnen buyer holding Ixian Nexus gets native purchase/bonus only, never an unresolved prototype offer',() => {
const lobby = createGame('HARKBUYER',newPlayer('a','Atreides','atreides'),false,[]);
joinGame(lobby,newPlayer('e','Emperor','emperor'));
joinGame(lobby,newPlayer('h','Harkonnen','harkonnen'));
joinGame(lobby,newPlayer('g','Guild','guild'));
const f = createIxianNexusReplacementFixture({initial:lobby,face:'richese',otherDraw:{player:'h',face:'ixians'}});
let game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementPass',event:f.event}));
assert.equal(game.nexusCards!.cards!.hands.h,'ixians');
for (let step = 0; game.auction!.active !== 'h' && step < 20; step++)
game = applyAction(game,game.auction!.active,{type:'passBid'});
const balance = owner(game,'h').spice;
const purchased = game.auction!.cards[game.auction!.index].id;
game = applyAction(game,'h',{type:'bid',amount:2});
for (let step = 0; game.auction?.bidder === 'h' && !game.currentAuctionSale &&
game.auction.index === f.auctionIndex + 1 && step < 30; step++) {
if (game.decision?.kind === 'auctionPayment')
game = applyAction(game,'h',{type:'decision',karama:false});
else game = applyAction(game,game.auction.active,{type:'passBid'});
}
assert.equal(game.pendingNexusIxianReplacement,null);
assert.equal(viewGame(game,'a').nexusIxianReplacement,null);
assert.equal(viewGame(game,'h').nexusIxianReplacement,null);
game = settleIxianReplacementFixture(game);
assert.equal(owner(game,'h').spice,balance - 2);
assert.ok(owner(game,'h').hand.some(c => c.id === purchased));
assert.equal(owner(game,'h').hand.length,4,'The actual native Harkonnen bonus remains active.');
assert.equal(game.nexusCards!.cards!.hands.h,'ixians');
assert.equal(game.nexusIxianReplacementHistory!.length,1);
inventory(game);
});
void test('direct printed Karama acquisition preserves the original zero bid and native free continuation',() => {
const f = createIxianNexusReplacementFixture();