Connect auction knowledge and group transport quotes
Verify and publish checkpoint container / container (push) Successful in 11m12s
Verify and publish checkpoint container / container (push) Successful in 11m12s
This commit is contained in:
1 parent
599af2475d
commit
4feb418488
8 files changed
+254
-9
No files matched your search
@@ -17,6 +17,18 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Black Market knowledge and group Guild transport — 29 October. Prototyped /
|
||||
Partial.** Atreides can answer the existing private auction-knowledge question
|
||||
from an actual Advanced Black Market inspection. A staged group Guild ally can
|
||||
also use self-funded transport while another member's positive bribe promise
|
||||
fences group escrow; the quote, table and server now agree. Seven related files
|
||||
pass 43 cases, including a genuine Advanced offer and staged Basic/Advanced
|
||||
group action journeys; typecheck, lint and build pass. Unavailable inspections yield unknown; live bribe
|
||||
answers and affordability remain binding. Semuta, occupied inspections, full
|
||||
group games and broader interactions remain open.
|
||||
[Knowledge scope](TRUTHTRANCE_KNOWLEDGE.md#advanced-black-market-inspection--29-october-2026);
|
||||
[transport scope](OPTIONAL_ALLIANCE_VICTORY.md#self-funded-guild-transport-with-a-live-promise--29-october-2026).
|
||||
|
||||
**Advanced Emperor auction funding — 29 October. Prototyped / Partial.**
|
||||
New ordinary and Richese auction invoices route the Emperor's direct allied
|
||||
contribution to the bank under the supplied Advanced source, while direct gifts
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Optional three-stronghold alliance victory
|
||||
|
||||
## Self-funded Guild transport with a live promise — 29 October 2026
|
||||
|
||||
In staged Basic and Advanced group games, a Guild ally's transport quote now
|
||||
recognizes the alliance even when a live positive bribe answer temporarily
|
||||
fences group funding. The player must still afford their own payment and keep
|
||||
the answer possible; absent escrow cannot supply donor contributions. Focused
|
||||
cases form the group and the promise through actual actions, stage physical
|
||||
positions and wallets, then complete transport and the promised bribe. Group
|
||||
lobby entry and broader interactions remain unfinished.
|
||||
|
||||
## Advanced classic group foundation — 29 October 2026
|
||||
|
||||
**Staged prototype, not a selectable game mode.** The same group negotiation
|
||||
|
||||
@@ -58,13 +58,25 @@ Eight related unit files pass 27 cases, including a genuine Basic inspection,
|
||||
private questions and all-profile responses. Missing and aged observations are
|
||||
explicitly staged; broader combinations and deployed acceptance remain open.
|
||||
|
||||
## Advanced Black Market inspection — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** The existing `auctionPeek` fact now also reports the
|
||||
native Atreides seat's successful inspection of an active Advanced Richese
|
||||
Black Market lot. It uses the offered card bound to that lot, or the reserved
|
||||
physical seller card in older frames. Missing, canceled, expired, completed or
|
||||
other-faction observations return unknown. This does not disclose the card to
|
||||
the questioner. A real Advanced setup exercises the offer, inspection, private
|
||||
Yes/No question and four bot responses; a conserved Truthtrance is transferred
|
||||
between hands by the fixture. Semuta, occupied Homeworld inspection and broader
|
||||
auction interactions remain open.
|
||||
|
||||
## Atreides ordinary auction knowledge — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The structured fact `{kind:'auctionPeek', name}` asks
|
||||
whether Atreides' currently inspected ordinary auction card has the named
|
||||
printed face. The answer uses only the current lot's successful native
|
||||
inspection. Other factions, canceled or expired inspections and missing lots
|
||||
return unknown. Active Ixian and Richese special auctions and non-Atreides
|
||||
return unknown. Apart from the Black Market extension above, active special auctions and non-Atreides
|
||||
occupied Homeworld inspection are separate and remain unfinished. The card identity is
|
||||
not added to another seat's view; the selected answer is public through the
|
||||
normal Truthtrance flow. Single and compound selectors use the public printed
|
||||
|
||||
+5
-5
@@ -2791,7 +2791,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Set an unspent pledge. That spice is reserved for your ally for the current phase.',
|
||||
'Your ally can choose its share of the next payment, or leave it blank to use personal spice first.',
|
||||
'Shipment contributions go directly to the Guild, including a contribution toward the Guild’s own shipment. Spice paid by the Guild itself goes to the bank. If the Guild is absent, an independent Karama shipment rate applies, or Guild income is prevented, the applicable payment goes to the bank.',
|
||||
'Guild transport, southern-reserve cross-shipment and return controls show the selected physical forces, rounded total cost, personal payment and pledged share before you submit. A combined-sector group is priced by its actual total. The Guild must fund its own share before receiving shipment income.',
|
||||
'Guild transport, southern-reserve cross-shipment and return controls show the selected physical forces, rounded total cost, personal payment and pledged share before you submit. A combined-sector group is priced by its actual total. The Guild must fund its own share before receiving shipment income. A group Guild ally may still pay their own transport when a binding answer temporarily prevents group pledges.',
|
||||
'Unspent funding returns at phase end. A pledge committed to the current high bid cannot be withdrawn.',
|
||||
],
|
||||
example:
|
||||
@@ -2800,7 +2800,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Ordinary pair funding is connected. Staged Basic group games support recipient-specific pledges and named contributions for ordinary bids and physical shipments, including the native Guild rate and its Karama response. Special transactions remain open.', evidence: ['game/coalition-escrow.ts', 'game/coalition-funding.ts', 'game/engine.ts', 'game/shipment-price.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Pair funding has normal table controls. Staged group bid and shipment controls project each player’s own outgoing and incoming pledges; group play is not selectable yet.', evidence: ['components/coalition-funding.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing bots can participate using their own spice; group funding strategy is deferred.' },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused staged cases exercise multiple donors, Guild and Emperor income, ordinary auctions, rate cancellation, rejected splits and individual refunds. Full group play and deployed acceptance remain open.', evidence: ['tests/coalition-guild-shipment.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-escrow.test.ts', 'tests/coalition-funding-controls.test.tsx'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused staged cases exercise multiple donors, Guild and Emperor income, ordinary auctions, rate cancellation, rejected splits, individual refunds and self-funded Guild transport with a binding bribe answer. Full group play and deployed acceptance remain open.', evidence: ['tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport-commitment.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-escrow.test.ts', 'tests/coalition-funding-controls.test.tsx'] },
|
||||
],
|
||||
related: ['bidding', 'movement', 'faction-guild', 'card-karama'],
|
||||
},
|
||||
@@ -3290,7 +3290,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.',
|
||||
'Ask whether Atreides’ currently known next Spice Card is a sandworm, Sandtrout, or a blow at a named printed territory. If the peek was canceled or is no longer available, the answer is I don’t know.',
|
||||
'Ask whether the ordinary auction card currently inspected by Atreides has a named Treachery Card face. If the inspection was canceled or the lot is no longer available, the answer is I don’t know. Special-auction inspections and other factions’ occupied Homeworld inspections are not yet included.',
|
||||
'Ask whether the current ordinary auction card or Advanced Richese Black Market card inspected by Atreides has a named Treachery Card face. If the inspection was canceled or the lot is no longer available, the answer is I don’t know. Other special-auction inspections and occupied Homeworld inspections are not yet included.',
|
||||
'In a Basic Richese cache auction, ask any participant whether the currently offered public cache card has a named face. If no supported cache lot is active, the answer is I don’t know. This is separate from the Atreides inspection question.',
|
||||
'In Basic Bidding, ask Ixians whether their recorded inspected auction group contains a named card. This refers to the recorded group, including cards later sold, rather than what remains for sale. If the inspection is unavailable or belongs to another player, the answer is I don’t know.',
|
||||
'Ask an entitled participant whether an already answered Prescience reveals a named leader, weapon or defense, no element, or an exact force dial. A pending, unavailable or differently inspected element yields I don’t know.',
|
||||
@@ -3302,11 +3302,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks, revealed Prescience, the Basic Ixian recorded auction group and the current public Basic Richese cache face use shared three-valued fact evaluation; other special auctions and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks including Advanced Black Market, revealed Prescience, the Basic Ixian recorded auction group and the current public Basic Richese cache face use shared three-valued fact evaluation; other special auctions and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Fact choices, numeric comparisons, public faction and card names, bounded inputs and compound question previews.', evidence: ['components/truthtrance-knowledge.tsx', 'components/mixed-shipment-fields.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles answer the owner-only verified scalar through the existing Truthtrance flow. Strategic question selection and freeform understanding remain incomplete.', evidence: ['game/bots.ts', 'tests/truthtrance-knowledge.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Current-knowledge semantics, unknown cases and original FAQ authority are explicit.', evidence: ['docs/TRUTHTRANCE_KNOWLEDGE.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, native Atreides peeks, Prescience and Ixian auction knowledge and all-profile answers; earlier storm cases include JSON and SQLite restoration. Broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-ix-pool-knowledge.test.ts', 'tests/truthtrance-ix-pool-runtime.test.ts', 'tests/truthtrance-spice-peek-runtime.test.ts', 'tests/truthtrance-auction-peek-runtime.test.ts', 'tests/truthtrance-prescience-knowledge.test.ts', 'tests/truthtrance-prescience-runtime.test.ts', 'tests/truthtrance-prescience-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, native Atreides peeks including a genuine Advanced Black Market offer, Prescience and Ixian auction knowledge and all-profile answers; earlier storm cases include JSON and SQLite restoration. Broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-ix-pool-knowledge.test.ts', 'tests/truthtrance-ix-pool-runtime.test.ts', 'tests/truthtrance-spice-peek-runtime.test.ts', 'tests/truthtrance-auction-peek-runtime.test.ts', 'tests/truthtrance-special-auction-knowledge.test.ts', 'tests/truthtrance-prescience-knowledge.test.ts', 'tests/truthtrance-prescience-runtime.test.ts', 'tests/truthtrance-prescience-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -176,7 +176,7 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
!(
|
||||
nexusAllowed ||
|
||||
p.faction === 'guild' ||
|
||||
!!g.coalitionFunding && groupGuildShipmentRate(g.players, p.id, g.coalitionState) ||
|
||||
groupGuildShipmentRate(g.players, p.id, g.coalitionState) ||
|
||||
g.players.some((seat) => seat.id === p.ally && seat.faction === 'guild')
|
||||
)
|
||||
)
|
||||
|
||||
@@ -96,7 +96,7 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
|
||||
}
|
||||
|
||||
export function truthKnowledgeOf(
|
||||
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'richeseCache' | 'ixAuction' | 'ixAuctionKnown' | 'homeworldOccupationPreview' | 'battle' | 'players' | 'dukeVidal'>,
|
||||
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'richeseCache' | 'richesePeekKnown' | 'richeseOfferedCard' | 'ixAuction' | 'ixAuctionKnown' | 'homeworldOccupationPreview' | 'battle' | 'players' | 'dukeVidal'>,
|
||||
p: Pick<Player, 'id' | 'faction' | 'prediction'>,
|
||||
): TruthKnowledge {
|
||||
const dial = g.stormDials[p.id];
|
||||
@@ -105,7 +105,16 @@ export function truthKnowledgeOf(
|
||||
const lot = g.phase === 3 && p.faction === 'atreides' && !g.richeseAuction && !g.ixAuction ? g.auction : null;
|
||||
// The private lot is read only after the same inspection entitlement used by
|
||||
// the ordinary auction view has resolved successfully for this faction.
|
||||
const lotName = lot?.peekKnown ? lot.cards[lot.index]?.name : undefined;
|
||||
const richeseLot = g.phase === 3 && g.advanced && p.faction === 'atreides' &&
|
||||
g.richeseAuction?.source === 'blackMarket' && !g.richeseAuction.outcome && g.richesePeekKnown
|
||||
? g.richeseAuction : null;
|
||||
// The offered copy is only valid for this actual lot. Older frames can
|
||||
// recover the still-reserved physical card from the seller's hand.
|
||||
const offered = richeseLot && g.richeseOfferedCard?.id === richeseLot.cardId
|
||||
? g.richeseOfferedCard : richeseLot
|
||||
? g.players.find(seat => seat.id === richeseLot.owner)?.hand.find(card => card.id === richeseLot.cardId)
|
||||
: undefined;
|
||||
const lotName = lot?.peekKnown ? lot.cards[lot.index]?.name : offered?.name;
|
||||
// A Basic Richese cache lot is shown to every seat. It is not the owner's
|
||||
// private cache inventory or an Advanced Black Market card.
|
||||
const cacheLot = g.phase === 3 && !g.advanced && g.players.some(seat => seat.id === p.id) &&
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { guildTransportQuote } from '../game/transport-quote';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
|
||||
},
|
||||
});
|
||||
const {GameTable} = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
|
||||
/** Group entry is not exposed in the lobby. Stage an empty ledger and a later
|
||||
* physical movement position; form the group and the positive Truthtrance
|
||||
* commitment through their native actions. Card transfers come from the deck. */
|
||||
function committedGroup(advanced: boolean): Game {
|
||||
let game = createGame('GROUPGUILDCOMMITMENT', newPlayer('g', 'Guild', 'guild'), advanced);
|
||||
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
|
||||
newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['g','e','f','h'],
|
||||
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
game = act(game, 'g', {type:'alliance', target:'e'});
|
||||
game = act(game, 'e', {type:'alliance', target:'g'});
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'g'); assert.ok(pair);
|
||||
game = act(game, 'g', {type:'coalitionPropose', groupId:pair.id, entrant:'f',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of ['e','f']) {
|
||||
const proposal = game.coalitionState!.proposal!;
|
||||
game = act(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
|
||||
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
Object.assign(game, {phase:5, nexus:false, active:'e', ready:[],
|
||||
movementRemaining:['e','g','f','h'], guildTimingGranted:false, guildTimingLocked:false});
|
||||
for (const player of game.players) {
|
||||
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
|
||||
player.hand = []; player.traitors = []; player.traitorChoices = []; player.shipped = false; player.moved = 0;
|
||||
}
|
||||
seat(game, 'e').forces = {'imperial_basin:10':3}; seat(game, 'e').reserves = 17;
|
||||
for (const effect of ['truthtrance','karama']) {
|
||||
const index = game.deck.findIndex(card => card.effect === effect); assert.ok(index >= 0);
|
||||
seat(game, 'h').hand.push(...game.deck.splice(index, 1));
|
||||
}
|
||||
const truth = seat(game, 'h').hand.find(card => card.effect === 'truthtrance')!;
|
||||
game = act(game, 'h', {type:'card', card:truth.id});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(player);
|
||||
game = act(game, player.id, {type:'truthPass'});
|
||||
}
|
||||
game = act(game, 'h', {type:'truthAsk', question:{kind:'bribe', target:'f', turn:game.turn,
|
||||
recipient:'h', minimum:1}});
|
||||
game = act(game, 'f', {type:'truthAnswer', answer:'yes'});
|
||||
assert.equal(game.truthtrance, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
const transport: Action = {type:'guildShip', forces:{'imperial_basin:10':3},
|
||||
territory:'red_chasm', sector:7, allyPayment:0};
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} group ally can self-fund Guild transport while another ally has a positive bribe promise`, () => {
|
||||
let game = committedGroup(advanced);
|
||||
assert.ok(game.bribePromises?.some(promise => promise.player === 'f' && promise.answer &&
|
||||
!promise.fulfilled && !promise.released));
|
||||
const view = viewGame(game, 'e');
|
||||
assert.equal(view.coalitionFunding, null);
|
||||
const quote = guildTransportQuote(view, transport);
|
||||
assert.deepEqual(quote.unavailableReasons, []);
|
||||
assert.deepEqual(quote.quote, {cost:3, normalCost:3, ownPayment:3, pledgedPayment:0});
|
||||
const markup = renderToStaticMarkup(createElement(GameTable, {game:view, busy:false, send:async () => {}, onExit() {}}));
|
||||
assert.match(markup, /<button\b[^>]*>(?:(?!<\/button>)[\s\S])*Guild transport(?:(?!<\/button>)[\s\S])*<\/button>/);
|
||||
const insufficient = structuredClone(view);
|
||||
insufficient.players.find(player => player.id === 'e')!.spice = 2;
|
||||
assert.ok(guildTransportQuote(insufficient, transport).unavailableReasons.some(reason => reason.includes('spice')));
|
||||
const unavailable = guildTransportQuote(view, {...transport, coalitionContributions:[{donor:'g',amount:1}]});
|
||||
assert.ok(unavailable.unavailableReasons.length);
|
||||
game = act(game, 'e', transport);
|
||||
assert.equal(game.response?.kind, 'guildTransportPermission');
|
||||
for (let i = 0; game.response && i < 32; i++) {
|
||||
const player = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(player);
|
||||
game = act(game, player.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response ?? null, null);
|
||||
assert.equal(seat(game, 'e').forces['red_chasm:7'], 3);
|
||||
assert.equal(seat(game, 'e').forces['imperial_basin:10'] ?? 0, 0);
|
||||
assert.equal(seat(game, 'e').spice, 7);
|
||||
assert.equal(seat(game, 'g').spice, 13);
|
||||
assert.ok(game.bribePromises?.some(promise => promise.player === 'f' && promise.answer &&
|
||||
!promise.fulfilled && !promise.released));
|
||||
game = act(game, 'f', {type:'bribe', target:'h', amount:1});
|
||||
assert.ok(game.bribePromises?.some(promise => promise.player === 'f' && promise.fulfilled));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Action, type Game, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { knowledgeFactAnswer, truthKnowledgeOf } from '../game/truthtrance-knowledge';
|
||||
import { createTruthtranceRichesePromisesFixture, advanceTruthtranceRichesePromises as advance,
|
||||
actTruthtranceRichesePromises as act, truthtranceRichesePromisesInventory as inventory } from './fixture-truthtrance-richese-promises';
|
||||
|
||||
const player = (g: Game, id: string) => {
|
||||
const seat = g.players.find(candidate => candidate.id === id);
|
||||
assert.ok(seat); return seat;
|
||||
};
|
||||
|
||||
/** Genuine Advanced Richese/Atreides/Emperor setup, Black Market offer and
|
||||
* successful inspection. Only the existing Truthtrance is moved between hands
|
||||
* so a different faction can ask the native Atreides seat. */
|
||||
function inspectedLot() {
|
||||
const fixture = createTruthtranceRichesePromisesFixture({ advanced: true });
|
||||
let game = structuredClone(fixture.game);
|
||||
const atreides = player(game, 'atreides'), emperor = player(game, 'emperor');
|
||||
const truth = atreides.hand.find(card => card.effect === 'truthtrance'); assert.ok(truth);
|
||||
atreides.hand = atreides.hand.filter(card => card.id !== truth.id);
|
||||
emperor.hand.push(truth);
|
||||
game = advance(game, state => state.decision?.kind === 'richeseBlackMarket');
|
||||
const card = player(game, 'richese').hand.find(candidate => candidate.name === 'Shield'); assert.ok(card);
|
||||
game = act(game, 'richese', { type:'decision', event:game.richeseBidding!.event,
|
||||
card:card.id, method:'silent' } as Action);
|
||||
for (let count = 0; game.response && count < 20; count++) {
|
||||
const respondent = game.players.find(candidate => !game.response!.passed.includes(candidate.id));
|
||||
assert.ok(respondent); game = act(game, respondent.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(game.richeseAuction?.source, 'blackMarket');
|
||||
assert.equal(game.richeseAuction?.cardId, card.id);
|
||||
assert.equal(game.richesePeekKnown, true);
|
||||
assert.deepEqual(inventory(game), fixture.originalCards);
|
||||
return {game, card, truth};
|
||||
}
|
||||
|
||||
function ask(game: Game, truth: string, name: string): Game {
|
||||
game = act(game, 'emperor', {type:'card', card:truth});
|
||||
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const respondent = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
|
||||
assert.ok(respondent); game = act(game, respondent.id, {type:'truthPass'});
|
||||
}
|
||||
return act(game, 'emperor', {type:'truthAsk', question:{kind:'fact', target:'atreides',
|
||||
fact:{kind:'auctionPeek',name}}});
|
||||
}
|
||||
|
||||
void test('native Advanced Black Market inspection gives Atreides a private Yes or No and all bots answer legally', () => {
|
||||
const {game, card, truth} = inspectedLot();
|
||||
for (const [name,answer] of [[card.name,'yes'],['Snooper','no']] as const) {
|
||||
const question = ask(structuredClone(game), truth.id, name);
|
||||
assert.equal(viewGame(question, 'atreides').truthAnswer, answer);
|
||||
assert.equal(viewGame(question, 'emperor').truthAnswer, null);
|
||||
assert.equal(viewGame(question, 'emperor').richeseAuction?.card, null);
|
||||
assert.equal(viewGame(question, 'atreides').richeseAuction?.card?.id, card.id);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(question, 'atreides');
|
||||
const seat = view.players.find(candidate => candidate.id === 'atreides'); assert.ok(seat);
|
||||
seat.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(actions, [{type:'truthAnswer',answer}]);
|
||||
const done = act(question, 'atreides', actions[0]);
|
||||
assert.equal(done.truthHistory?.at(-1)?.answer, answer);
|
||||
assert.equal(done.richeseAuction?.cardId, card.id);
|
||||
assert.deepEqual(inventory(done), inventory(game));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('an uninspected, expired, missing or unauthorized Black Market observation is unknown', () => {
|
||||
const {game, card} = inspectedLot();
|
||||
const fact = {kind:'auctionPeek', name:card.name} as const;
|
||||
const answer = (state: Game, id = 'atreides') => knowledgeFactAnswer(fact, truthKnowledgeOf(state, player(state,id)));
|
||||
assert.equal(answer(game), 'yes');
|
||||
assert.equal(answer(game, 'emperor'), 'unknown');
|
||||
const uninspected = structuredClone(game); uninspected.richesePeekKnown = false;
|
||||
assert.equal(answer(uninspected), 'unknown');
|
||||
const ended = structuredClone(game); ended.richeseAuction!.outcome = {kind:'unbid'};
|
||||
assert.equal(answer(ended), 'unknown');
|
||||
const absent = structuredClone(game); absent.richeseAuction = null;
|
||||
assert.equal(answer(absent), 'unknown');
|
||||
const missing = structuredClone(game); missing.richeseOfferedCard = null;
|
||||
player(missing, 'richese').hand = player(missing, 'richese').hand.filter(item => item.id !== card.id);
|
||||
assert.equal(answer(missing), 'unknown');
|
||||
const saved = structuredClone(game); saved.richeseOfferedCard = null;
|
||||
assert.equal(answer(saved), 'yes', 'Older lot frames can still use the reserved physical seller card.');
|
||||
const later = structuredClone(game); later.phase = 4;
|
||||
assert.equal(answer(later), 'unknown');
|
||||
});
|
||||
Reference in new issue
Block a user