Adopt the supplied Advanced specific-effect wording, preserving Basic and legacy phase-wide semantics. Bind signed semantic purposes through ordinary/special activations, counters, feasibility and settlement; retain unrelated human controls and minimal bot actions. Phase expiry remains provisional; prior counter, BG and deferred-overbid choices stay. Validation: frozen12-file111/111, types/lint/build and two original-action/rendered-table programmes. Explicit unopened deal/auction/spice order supports real paid Baliset and shipments; Advanced retains Karama for Baliset cancellation while Basic stays blocked. Four minimal policies remain legal. Correct stale exchange/phase-market/Fremen fixture assumptions. Recovery schema expectations maintained without running deferred SQL assurance. No full modes, browser/deployed acceptance, saved-game/reset/restart or deployment operation.
95 lines
5.2 KiB
TypeScript
95 lines
5.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
|
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
|
|
|
|
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
|
function allowKull(offer: Game): Game {
|
|
let g = applyAction(offer, 'c', {type:'kullDecision', event:offer.pendingKull!.event,
|
|
source:'printed', card:'ix-kull-wahad'});
|
|
for (let step = 0; g.pendingKull && step < 16; step++) {
|
|
const responder = g.players.find(p => {
|
|
const controls = viewGame(g, p.id).responseControls;
|
|
return controls && !controls.hasPassed && controls.cancelCards.length;
|
|
});
|
|
g = responder ? applyAction(g, responder.id, {type:'passResponse'}) : normalizeAutomaticGame(g);
|
|
}
|
|
assert.equal(g.pendingKull, null);
|
|
return g;
|
|
}
|
|
function normalLot(g: Game) {
|
|
g.phase = 3; g.movementRemaining = [];
|
|
g.auction = {cards:[g.deck.shift()!], index:0, bid:0, bidder:null,
|
|
active:'e', passed:[], opener:0};
|
|
}
|
|
|
|
void test('Advanced Emperor force and leader specials share one effect while ordinary cancellation remains legal', () => {
|
|
const initial = choamKullGame(); initial.phase = 4;
|
|
const emperor = player(initial, 'e'); emperor.tanks = 2; emperor.reserves -= 2;
|
|
const leader = emperor.leaders[0]; leader.dead = true;
|
|
const la = takeKullCard(initial, 'La La La'); player(initial, 'c').hand.push(la);
|
|
const card = emperor.hand[0].id;
|
|
const offer = applyAction(initial, 'e', {type:'card', mode:'special', card, amount:1});
|
|
assert.equal(offer.pendingKull?.purpose, 'special:emperor');
|
|
const stopped = allowKull(offer);
|
|
assert.equal(player(stopped, 'e').specialKaramaUsed, undefined);
|
|
assert.deepEqual(stopped.kullRestrictions, [{player:'e',turn:2,phase:4,purpose:'special:emperor'}]);
|
|
for (const change of [{amount:2}, {leader:leader.id}]) {
|
|
const before = JSON.stringify(stopped);
|
|
assert.throws(() => applyAction(stopped, 'e', {type:'card',mode:'special',card,...change}), /Kull Wahad/);
|
|
assert.equal(JSON.stringify(stopped), before);
|
|
}
|
|
const parent = applyAction(stopped, 'c', {type:'card',mode:'choam',card:la.id,target:'e'});
|
|
assert.ok(viewGame(parent, 'e').responseControls?.cancelCards.includes(card));
|
|
const cancelOffer = applyAction(parent, 'e', {type:'card',mode:'cancel',card});
|
|
assert.equal(cancelOffer.pendingKull?.purpose, 'cancel:choamWorthless:laLaLa');
|
|
const canceled = applyAction(cancelOffer, 'c', {type:'kullDecision',event:cancelOffer.pendingKull!.event,decline:true});
|
|
assert.ok(player(canceled, 'c').hand.some(c => c.id === la.id));
|
|
assert.ok(canceled.choamWorthlessBlocked?.cards.includes(la.id));
|
|
assert.equal(canceled.discard.filter(c => c.id === card).length, 1);
|
|
});
|
|
|
|
void test('Advanced free purchase and winning Karama payment share one effect and do not block a faction special', () => {
|
|
const initial = choamKullGame({opponents:['harkonnen','beneGesserit','emperor']});
|
|
normalLot(initial);
|
|
const card = player(initial, 'e').hand[0].id;
|
|
const offer = applyAction(initial, 'e', {type:'card',mode:'purchase',card});
|
|
assert.equal(offer.pendingKull?.purpose, 'auction-free-purchase');
|
|
const stopped = allowKull(offer);
|
|
assert.throws(() => applyAction(stopped, 'e', {type:'card',mode:'purchase',card}), /Kull Wahad/);
|
|
// Controlled winning-payment state on the same original unresolved physical lot.
|
|
const payment = structuredClone(stopped);
|
|
payment.auction!.bid = 1; payment.auction!.bidder = 'e';
|
|
payment.decision = {kind:'auctionPayment',player:'e'};
|
|
assert.throws(() => applyAction(payment, 'e', {type:'decision',karama:true,card}), /Kull Wahad/);
|
|
assert.ok(player(payment, 'e').hand.some(c => c.id === card));
|
|
const special = applyAction(stopped, 'e', {type:'card',mode:'special',card,target:'b',amount:1});
|
|
assert.equal(special.pendingKull?.purpose, 'special:harkonnen');
|
|
const continued = applyAction(special, 'c', {type:'kullDecision',event:special.pendingKull!.event,decline:true});
|
|
assert.equal(player(continued, 'e').specialKaramaUsed, true);
|
|
assert.equal(continued.discard.filter(c => c.id === card).length, 1);
|
|
// Spending the only original card leaves a forced one-card return; it completes automatically.
|
|
assert.equal(continued.pendingExchange ?? null, null);
|
|
assert.equal(player(continued, 'b').hand.length, 1);
|
|
});
|
|
|
|
void test('Basic attempts and unstamped Advanced attempts retain the original all-purpose phase restriction', () => {
|
|
for (const advanced of [false, true]) {
|
|
const initial = choamKullGame({advanced});
|
|
let offer = applyAction(initial, 'e', kullShipmentAttempt(initial));
|
|
if (advanced) {
|
|
// Exact legacy protocol fixture: old signed JSON omitted this new field.
|
|
offer = structuredClone(offer);
|
|
delete offer.pendingKull!.purpose;
|
|
const signature = JSON.parse(offer.pendingKull!.signature);
|
|
delete signature.purpose;
|
|
offer.pendingKull!.signature = JSON.stringify(signature);
|
|
}
|
|
assert.equal(viewGame(offer, 'c').kullReaction?.scope, 'phase');
|
|
const stopped = allowKull(offer);
|
|
assert.deepEqual(stopped.kullRestrictions, [{player:'e',turn:2,phase:5}]);
|
|
assert.ok(viewGame(stopped, 'e').karamaBlocked);
|
|
assert.deepEqual(viewGame(stopped, 'e').karamaPurposeBlocks, []);
|
|
}
|
|
});
|