190 lines
11 KiB
TypeScript
190 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { createGame, newPlayer, type Decision, type Game } from '../game/engine';
|
|
import { richeseRevealInterruptSupported as supported } from '../game/richese-reveal-interruption';
|
|
|
|
/** Staged parent records test only the admission quote; native parent and
|
|
* physical token transitions are exercised by the separate runtime suite. */
|
|
function source(): Game {
|
|
const g = createGame('REVEALQUOTE', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ix']);
|
|
g.players.push(newPlayer('c', 'CHOAM', 'choam'), newPlayer('e', 'Emperor', 'emperor'));
|
|
g.status = 'playing'; g.phase = 3; g.turn = 2;
|
|
return g;
|
|
}
|
|
function parent(kind: Decision['kind']): Game {
|
|
const g = source();
|
|
if (kind === 'auctionPayment') {
|
|
g.decision = {kind, player:'e'};
|
|
g.auction = {cards:[], index:0, bid:3, bidder:'e', active:'e', passed:[], opener:0};
|
|
} else if (kind === 'richeseDeclaration' || kind === 'richeseCacheTerms' || kind === 'richeseCache' || kind === 'richeseUnbid') {
|
|
g.decision = {kind, player:'r'};
|
|
g.richeseBidding = {owner:'r', event:'round', turn:g.turn,
|
|
stage:kind === 'richeseDeclaration' ? 'declaration' : kind === 'richeseUnbid' ? 'lot' : 'cacheOffer',
|
|
position:'first', normalCount:1, blackMarketSold:false, cacheCanceled:false};
|
|
if (kind === 'richeseUnbid') g.richeseAuction = {
|
|
event:'lot', cardId:'card', source:'cache', owner:'r', method:'onceAround', eligible:['r','e'],
|
|
order:['e','r'], tieOrder:['r','e'], active:null, bid:0, bidder:null, passed:['r','e'], acted:['r','e'], sealed:{}, outcome:{kind:'unbid'},
|
|
};
|
|
} else if (kind === 'choamMarket' || kind === 'choamTradeReply' || kind === 'choamTradeConfirm') {
|
|
g.decision = {kind, player:kind === 'choamTradeReply' ? 'r' : 'c'};
|
|
g.choamMarket = {owner:'c', resume:'phase', blocked:[],
|
|
...(kind === 'choamMarket' ? {} : {trade:{ally:'r', offered:'offered',
|
|
...(kind === 'choamTradeConfirm' ? {returned:'returned'} : {})}})};
|
|
} else if (kind === 'richeseAllyOpportunity') {
|
|
g.phase = 5;
|
|
g.decision = {kind, player:'r', recipient:'e'};
|
|
g.richeseAllyOpportunity = {turn:g.turn, recipient:'e'};
|
|
} else throw Error(`Unrecognized staged parent ${kind}`);
|
|
return g;
|
|
}
|
|
|
|
void test('each supported economic parent is admitted without modifying its frame or inspecting marker custody', () => {
|
|
for (const kind of ['auctionPayment','richeseDeclaration','richeseCacheTerms','richeseCache','richeseUnbid',
|
|
'choamMarket','choamTradeReply','choamTradeConfirm','richeseAllyOpportunity'] as const) {
|
|
const game = parent(kind), before = structuredClone(game);
|
|
assert.equal(supported(game), true, kind);
|
|
assert.deepEqual(game, before);
|
|
assert.equal(game.players[0].noField?.deployed ?? null, null, 'Seat/marker authorization belongs to the caller');
|
|
}
|
|
});
|
|
|
|
void test('a clean Ix phase opening remains admitted before or after its owner passes', () => {
|
|
const game = source(); game.phase = 0; game.phaseOpening = {passed:[], initialize:true}; game.amalWindowOpen = true;
|
|
assert.equal(supported(game), true);
|
|
game.phaseOpening.passed.push('r');
|
|
assert.equal(supported(game), true);
|
|
const before = structuredClone(game); assert.equal(supported(game), true); assert.deepEqual(game, before);
|
|
game.decision = {kind:'auctionPayment', player:'e'}; assert.equal(supported(game), false);
|
|
game.decision = null; game.amalWindowOpen = false; assert.equal(supported(game), false);
|
|
});
|
|
|
|
void test('supported native responses admit the reveal without changing their original source or passes', () => {
|
|
const charity = source(); charity.phase = 2;
|
|
charity.response = {kind:'choamCharity', owner:'c', passed:['e']};
|
|
const auction = source(); auction.players.push(newPlayer('a','Atreides','atreides'));
|
|
auction.auction = {cards:[{id:'lot', name:'Shield', kind:'shield'}], index:0, bid:0,
|
|
bidder:null, active:'a', passed:[], opener:0};
|
|
auction.response = {kind:'atreidesAuction', owner:'a', passed:['e']};
|
|
const declaration = source();
|
|
declaration.richeseCache = [];
|
|
declaration.richeseBidding = {owner:'r', event:'round', turn:declaration.turn,
|
|
stage:'declaration', position:'first', normalCount:1, normalCountBeforeCache:1,
|
|
blackMarketSold:false, cacheCanceled:false};
|
|
declaration.response = {kind:'richeseAuction',owner:'r',passed:['e']};
|
|
const grant = source(); grant.phase = 4;
|
|
grant.players.push(newPlayer('f','Fremen','fremen'));
|
|
grant.players.find(p => p.id === 'f')!.ally = 'r'; grant.players.find(p => p.id === 'r')!.ally = 'f';
|
|
grant.freeRevival = [];
|
|
grant.response = {kind:'fremenRevivalGrant', owner:'f', recipient:'r', passed:['e'],
|
|
fremenGrant:{turn:grant.turn, owner:'f', recipient:'r'}};
|
|
for (const game of [charity, auction, declaration, grant]) {
|
|
const before = structuredClone(game);
|
|
assert.equal(supported(game), true, game.response!.kind);
|
|
assert.deepEqual(game, before);
|
|
}
|
|
});
|
|
|
|
void test('response admission requires the original phase, owner, source and no nested interaction', () => {
|
|
const charity = source(); charity.phase = 2; charity.response = {kind:'choamCharity',owner:'c',passed:[]};
|
|
for (const patch of [
|
|
{phase:3}, {decision:{kind:'auctionPayment',player:'e'}}, {phaseOpening:{passed:[],initialize:true}},
|
|
{pendingShipment:{}}, {response:{kind:'choamCharity',owner:'e',passed:[]}},
|
|
{response:{kind:'choamCharity',owner:'c',passed:['unknown']}},
|
|
{response:{kind:'choamCharity',owner:'c',passed:['e','e']}},
|
|
{response:{kind:'choamCharity',owner:'c',passed:[],intent:'extra'}},
|
|
{choamCharity:{turn:2,canceled:false}},
|
|
]) assert.equal(supported(Object.assign(structuredClone(charity), patch)), false, JSON.stringify(patch));
|
|
|
|
const auction = source(); auction.players.push(newPlayer('a','Atreides','atreides'));
|
|
auction.auction = {cards:[{id:'lot',name:'Shield',kind:'shield'}], index:0, bid:0,
|
|
bidder:null, active:'a', passed:[], opener:0};
|
|
auction.response = {kind:'atreidesAuction',owner:'a',passed:[]};
|
|
const missingLot = structuredClone(auction); missingLot.auction!.index = 1;
|
|
assert.equal(supported(missingLot), false);
|
|
const wrongOwner = structuredClone(auction); wrongOwner.response!.owner = 'e';
|
|
assert.equal(supported(wrongOwner), false);
|
|
const unsupportedLot = structuredClone(auction);
|
|
unsupportedLot.richeseAuction = {} as NonNullable<Game['richeseAuction']>;
|
|
assert.equal(supported(unsupportedLot), false);
|
|
const otherResponse = structuredClone(auction); otherResponse.response!.kind = 'richeseAuction';
|
|
assert.equal(supported(otherResponse), false);
|
|
|
|
const declaration = source(); declaration.richeseCache = [];
|
|
declaration.richeseBidding = {owner:'r',event:'round',turn:declaration.turn,stage:'declaration',
|
|
position:'first',normalCount:1,normalCountBeforeCache:1,blackMarketSold:false,cacheCanceled:false};
|
|
declaration.response = {kind:'richeseAuction',owner:'r',passed:[]};
|
|
for (const patch of [
|
|
{phase:4}, {richeseCache:undefined}, {auction:auction.auction},
|
|
{richeseBidding:{...declaration.richeseBidding,turn:1}},
|
|
{richeseBidding:{...declaration.richeseBidding,owner:'e'}},
|
|
{richeseBidding:{...declaration.richeseBidding,stage:'lot'}},
|
|
{richeseBidding:{...declaration.richeseBidding,position:undefined}},
|
|
{richeseBidding:{...declaration.richeseBidding,normalCount:-1}},
|
|
{richeseBidding:{...declaration.richeseBidding,normalCountBeforeCache:3}},
|
|
{richeseBidding:{...declaration.richeseBidding,cacheCanceled:true}},
|
|
{pendingRevival:{}},
|
|
]) assert.equal(supported(Object.assign(structuredClone(declaration), patch)), false, JSON.stringify(patch));
|
|
const grant = source(); grant.phase = 4; grant.players.push(newPlayer('f','Fremen','fremen'));
|
|
grant.players.find(p => p.id === 'f')!.ally = 'r'; grant.players.find(p => p.id === 'r')!.ally = 'f';
|
|
grant.freeRevival = [];
|
|
grant.response = {kind:'fremenRevivalGrant',owner:'f',recipient:'r',passed:[],
|
|
fremenGrant:{turn:grant.turn,owner:'f',recipient:'r'}};
|
|
const stale = structuredClone(grant); stale.response!.fremenGrant!.turn--;
|
|
assert.equal(supported(stale), false);
|
|
const noAlly = structuredClone(grant); noAlly.players.find(p => p.id === 'r')!.ally = null;
|
|
assert.equal(supported(noAlly), false);
|
|
const wrongRecipient = structuredClone(grant); wrongRecipient.response!.recipient = 'e';
|
|
assert.equal(supported(wrongRecipient), false);
|
|
const unrelatedChild = structuredClone(grant); unrelatedChild.pendingRevival = {} as NonNullable<Game['pendingRevival']>;
|
|
assert.equal(supported(unrelatedChild), false);
|
|
});
|
|
|
|
void test('a legacy table without public Truthtrance history keeps its earlier interruption behavior', () => {
|
|
const game = parent('auctionPayment');
|
|
delete game.truthHistory;
|
|
assert.equal(supported(game), false);
|
|
});
|
|
|
|
void test('profile and nested-interaction boundaries do not admit another parent or module', () => {
|
|
const patches: Array<Partial<Game>> = [
|
|
{advanced:true}, {status:'setup'}, {phase:6}, {phase:-1}, {turn:0},
|
|
{expansions:['choam','unknown']}, {expansions:['ix']},
|
|
{response:{kind:'richeseAuction', owner:'r', passed:[]}},
|
|
{pendingNullentropy:{} as NonNullable<Game['pendingNullentropy']>},
|
|
{pendingTreacheryDiscard:{} as NonNullable<Game['pendingTreacheryDiscard']>},
|
|
{pendingShipment:{} as NonNullable<Game['pendingShipment']>},
|
|
{pendingKull:{} as NonNullable<Game['pendingKull']>},
|
|
{biddingEnd:{} as NonNullable<Game['biddingEnd']>},
|
|
{stormResolution:{} as NonNullable<Game['stormResolution']>},
|
|
{techTokens:{} as NonNullable<Game['techTokens']>},
|
|
{leaderSkills:{} as NonNullable<Game['leaderSkills']>},
|
|
{discoveryEnabled:true}, {ecazTreachery:true},
|
|
];
|
|
for (const patch of patches) {
|
|
const game = Object.assign(parent('auctionPayment'), patch), before = structuredClone(game);
|
|
assert.equal(supported(game), false, JSON.stringify(patch)); assert.deepEqual(game, before);
|
|
}
|
|
const wrong = parent('auctionPayment'); wrong.decision = {kind:'revivalStop', player:'e', recipient:'r', revival:'forces'};
|
|
assert.equal(supported(wrong), false);
|
|
const missing = parent('auctionPayment'); missing.auction = null; assert.equal(supported(missing), false);
|
|
const wrongSeat = parent('auctionPayment'); wrongSeat.auction!.bidder = 'r'; assert.equal(supported(wrongSeat), false);
|
|
const native = parent('auctionPayment'); native.players.push(newPlayer('i','Ixians','ixians'));
|
|
native.expansions = ['choam']; assert.equal(supported(native), false);
|
|
const e3 = parent('auctionPayment'); e3.players.push(newPlayer('z','Ecaz','ecaz'));
|
|
e3.expansions = ['choam']; assert.equal(supported(e3), false);
|
|
e3.expansions = ['choam','ecaz']; assert.equal(supported(e3), true);
|
|
});
|
|
|
|
void test('live current-turn commitments block the interruption; settled and previous-turn records do not', () => {
|
|
const game = parent('auctionPayment');
|
|
game.shipmentPromises = [{turn:game.turn, player:'e', asker:'r', answer:true, territory:'carthag', minimum:3}];
|
|
assert.equal(supported(game), false);
|
|
game.shipmentPromises[0].fulfilled = true; assert.equal(supported(game), true);
|
|
game.shipmentPromises[0].fulfilled = false; game.shipmentPromises[0].turn--; assert.equal(supported(game), true);
|
|
game.shipmentPromises = [];
|
|
game.futureBattleLeaderPromises = [{turn:game.turn, asker:'r', player:'e', leader:'leader', answer:true, status:'waiting'}];
|
|
assert.equal(supported(game), false);
|
|
game.futureBattleLeaderPromises[0].status = 'settled'; assert.equal(supported(game), true);
|
|
game.deals = [{status:'active'}] as unknown as Game['deals']; assert.equal(supported(game), false);
|
|
});
|