Honor Caladan auction knowledge in Truthtrance
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 19:03:34 +11:00
1 parent 34f533ae8d
commit 4ec4f843a7
13 files changed
+241 -25

No files matched your search

@@ -17,7 +17,7 @@ void test('the editor previews No-Field and auction knowledge without revealing
assert.match(noField, /you currently have a concealed No-Field with printed value 3/);
const auction = preview('Is the current auction card you inspected a Shield?');
assert.match(auction, /Recognized freeform question/);
assert.match(auction, /the current auction card you inspected as Atreides is Shield/);
assert.match(auction, /the current auction card you inspected is Shield/);
assert.doesNotMatch(auction, /Your current game information establishes/);
});
@@ -25,7 +25,7 @@ void test('the editor previews a mixed auction and force aggregate', () => {
const text = 'Is the current auction card you inspected a Shield and do you have at least 3 forces in your reserves?';
const html = preview(text);
assert.match(html, /Recognized freeform question/);
assert.match(html, /the current auction card you inspected as Atreides is Shield/);
assert.match(html, /the current auction card you inspected is Shield/);
assert.match(html, /AND/);
assert.match(html, /currently have at least 3 physical forces in your reserves/);
assert.doesNotMatch(html, /Your current game information establishes/);
@@ -62,11 +62,11 @@ void test('auction peek selector is available in single and compound current-fac
const single = renderToStaticMarkup(createElement(TruthFactFields, { value: fact, game: view, target: 'b', onChange() {} }));
assert.match(single, /value="auctionPeek" selected/);
assert.match(single, /value="Shield" selected/);
assert.match(single, /current ordinary auction card/);
assert.match(single, /current ordinary auction or Advanced Black Market card/);
assert.match(single, /If no current card is known/);
const compound: TruthFact = { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] };
const grouped = renderToStaticMarkup(createElement(TruthFactExpressionFields, { value: compound, game: view, target: 'b', disabled: false, onChange() {} }));
assert.match(grouped, /Inspected ordinary auction card/);
assert.match(grouped, /Inspected auction card/);
assert.match(grouped, /value="Shield" selected/);
});
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
import { knowledgeFactAnswer, truthKnowledgeOf } from '../game/truthtrance-knowledge';
import { freshPrivateLotGame, passPrivateLotWindow, privateLotPhysicalIds, privateLotPlayer,
recyclePrivatePurchaseIntoOriginalNormalLot, sellPrivateBlackMarketLot } from './fixture-homeworld-occupied-private-lots';
/** Stage the questioner's card by exchanging existing physical cards. The
* inspected/seller card remains untouched and no hand size is changed. */
function stageTruth(game: Game, protectedId: string) {
const original = privateLotPhysicalIds(game), asker = privateLotPlayer(game, 'ally');
const held = asker.hand.find(card => card.effect === 'truthtrance');
if (held) return held.id;
const stocks = [game.deck, game.discard, ...game.players.filter(p => p.id !== asker.id).map(p => p.hand)];
const source = stocks.find(cards => cards.some(card => card.effect === 'truthtrance' && card.id !== protectedId));
assert.ok(source, 'A distinct original Truthtrance must be available');
const index = source.findIndex(card => card.effect === 'truthtrance' && card.id !== protectedId);
const truth = source[index];
if (asker.hand.length) [asker.hand[0], source[index]] = [source[index], asker.hand[0]];
else asker.hand.push(...source.splice(index, 1));
assert.deepEqual(privateLotPhysicalIds(game), original);
return truth.id;
}
function ask(game: Game, card: string, name: string) {
let next = applyAction(game, 'ally', {type:'card',card});
while (next.truthtrance?.stage === 'priority') {
const p = next.players.find(player => !next.truthtrance!.passed.includes(player.id)); assert.ok(p);
next = applyAction(next, p.id, {type:'truthPass'});
}
return applyAction(next, 'ally', {type:'truthAsk', question:{kind:'fact',target:'occupier',fact:{kind:'auctionPeek',name}}});
}
function verify(state: Game, truth: string, name: string, expected: 'yes' | 'no', lot: 'ordinary' | 'blackMarket') {
const before = privateLotPhysicalIds(state), game = ask(structuredClone(state), truth, name);
assert.equal(viewGame(game, 'occupier').truthAnswer, expected);
assert.equal(viewGame(game, 'ally').truthAnswer, null);
assert.equal(viewGame(game, 'rival').truthAnswer, null);
if (lot === 'ordinary') {
assert.ok(viewGame(game, 'occupier').auction?.card);
assert.equal(viewGame(game, 'ally').auction?.card, null);
} else {
assert.ok(viewGame(game, 'occupier').richeseAuction?.card);
assert.equal(viewGame(game, 'ally').richeseAuction?.card, null);
}
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'occupier');
view.players.find(player => player.id === 'occupier')!.bot = difficulty;
assert.deepEqual(botActions(view), [{type:'truthAnswer',answer:expected}]);
const done = applyAction(game, 'occupier', {type:'truthAnswer',answer:expected});
assert.equal(done.truthHistory?.at(-1)?.answer, expected);
assert.deepEqual(privateLotPhysicalIds(done), before);
}
}
for (const denied of [false, true]) {
void test(`Caladan occupier answers actual Advanced Black Market inspection with native Atreides ${denied ? 'canceled' : 'allowed'}`, () => {
const fixture = freshPrivateLotGame();
let game = passPrivateLotWindow(fixture.game, 'richeseBlackMarket');
game = denied ? applyAction(game, 'rival', {type:'card',mode:'cancel',card:fixture.karama.id})
: passPrivateLotWindow(game, 'atreidesAuction');
assert.equal(game.homeworldAuctionInspection?.occupiedKnown, true);
assert.equal(game.richesePeekKnown, !denied);
const truth = stageTruth(game, fixture.card.id);
const wrong = TRUTH_CARD_NAMES.find(name => name !== fixture.card.name); assert.ok(wrong);
verify(game, truth, fixture.card.name, 'yes', 'blackMarket');
verify(game, truth, wrong, 'no', 'blackMarket');
});
}
void test('Caladan occupier answers after the original ordinary lot inspection', () => {
const fixture = freshPrivateLotGame();
let game = passPrivateLotWindow(fixture.game, 'richeseBlackMarket');
game = passPrivateLotWindow(game, 'atreidesAuction');
game = sellPrivateBlackMarketLot(game);
game = recyclePrivatePurchaseIntoOriginalNormalLot(game, fixture.card.id);
game = passPrivateLotWindow(game, 'atreidesAuction');
const truth = stageTruth(game, fixture.card.id);
const wrong = TRUTH_CARD_NAMES.find(name => name !== fixture.card.name); assert.ok(wrong);
verify(game, truth, fixture.card.name, 'yes', 'ordinary');
verify(game, truth, wrong, 'no', 'ordinary');
});
void test('absent or stale occupied receipt does not expose an auction observation', () => {
const fixture = freshPrivateLotGame();
let game = passPrivateLotWindow(fixture.game, 'richeseBlackMarket');
game = passPrivateLotWindow(game, 'atreidesAuction');
const receipt = structuredClone(game.homeworldAuctionInspection!);
game = sellPrivateBlackMarketLot(game);
game = recyclePrivatePurchaseIntoOriginalNormalLot(game, fixture.card.id);
const fact = {kind:'auctionPeek',name:fixture.card.name} as const;
const stale = structuredClone(game); stale.homeworldAuctionInspection = receipt;
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(stale, privateLotPlayer(stale, 'occupier'))), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, privateLotPlayer(game, 'occupier'))), 'unknown');
});
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, type Game } from '../game/engine';
import { knowledgeFactAnswer, truthKnowledgeOf } from '../game/truthtrance-knowledge';
import { originalAuctionInspectionLot } from '../game/auction-inspection-source';
function fixture(): Game {
const game = createGame('SHAREDPEEK', newPlayer('at', 'Atreides', 'atreides'), true, ['choam']);
joinGame(game, newPlayer('occ', 'Occupier', 'guild'));
joinGame(game, newPlayer('other', 'Other', 'emperor'));
joinGame(game, newPlayer('seller', 'Richese', 'richese'));
game.status = 'playing'; game.turn = 2; game.phase = 3; game.homeworldOccupationPreview = true;
game.auction = { cards:[{id:'lot-card', name:'Shield', kind:'shield'}], index:0,
occupiedEvent:'normal-event', bid:0, bidder:null, active:'occ', passed:[], opener:0, peekKnown:false };
game.homeworldAuctionInspection = {lot:originalAuctionInspectionLot(game)!, audience:['at','occ'],
native:'at', occupation:'public-qualification', occupiedKnown:true};
return game;
}
function answer(game: Game, actor: string) {
const seat = game.players.find(player => player.id === actor); assert.ok(seat);
return knowledgeFactAnswer({kind:'auctionPeek', name:'Shield'}, truthKnowledgeOf(game, seat));
}
void test('Caladan share binds the current ordinary lot and is independent of native denial', () => {
const game = fixture();
assert.equal(answer(game,'occ'),'yes');
assert.equal(answer(game,'at'),'unknown');
assert.equal(answer(game,'other'),'unknown');
game.auction!.peekKnown = true;
assert.equal(answer(game,'at'),'yes');
game.homeworldAuctionInspection!.occupiedKnown = false;
assert.equal(answer(game,'occ'),'unknown');
assert.equal(answer(game,'at'),'yes');
});
void test('stale, foreign and expired ordinary lot receipts never grant knowledge', () => {
const game = fixture();
game.homeworldAuctionInspection!.audience = ['at','other'];
assert.equal(answer(game,'occ'),'unknown');
game.homeworldAuctionInspection!.audience = ['at','occ'];
game.auction!.occupiedEvent = 'new-event';
assert.equal(answer(game,'occ'),'unknown');
game.auction!.occupiedEvent = 'normal-event'; game.phase = 4;
assert.equal(answer(game,'occ'),'unknown');
game.phase = 3; delete game.homeworldOccupationPreview;
assert.equal(answer(game,'occ'),'unknown');
});
void test('Black Market shared inspection binds its original event and current physical face', () => {
const game = fixture(), card = game.auction!.cards[0];
game.auction = null;
game.players.find(player => player.id === 'seller')!.hand.push(card);
game.richeseAuction = {event:'black-one', cardId:card.id, source:'blackMarket', owner:'seller',
method:'silent', eligible:['occ'], order:['occ'], tieOrder:['occ'], active:'occ', bid:0,
bidder:null, passed:[], acted:[], sealed:{}, outcome:null};
game.richesePeekKnown = false;
game.homeworldAuctionInspection!.lot = 'black-one';
assert.equal(answer(game,'occ'),'yes');
assert.equal(answer(game,'at'),'unknown');
assert.equal(answer(game,'other'),'unknown');
game.richeseAuction.event = 'black-two';
assert.equal(answer(game,'occ'),'unknown', 'the same card in a new lot does not reuse a receipt');
game.richeseAuction.event = 'black-one'; game.richeseAuction.outcome = {kind:'unbid'};
assert.equal(answer(game,'occ'),'unknown');
});