Add bids and ordinary Revival to combined turn commitments
This commit is contained in:
1 parent
0d72a33ee5
commit
921b4fe1e3
15 files changed
+1255
-65
No files matched your search
@@ -200,7 +200,7 @@ export function Truthtrance({
|
||||
const [fact, setFact] = useState<TruthFact>({ kind: 'hand', name: 'Shield' });
|
||||
const factId = useId();
|
||||
const leaderName = (id: string) =>
|
||||
id === CHEAP_HERO_TRAITOR
|
||||
id === 'kwisatz' ? 'Kwisatz Haderach' : id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
: (g.allLeaders.find((l) => l.id === id)?.name ?? id);
|
||||
const name = (id: string) => g.players.find((p) => p.id === id)!.name;
|
||||
@@ -581,7 +581,7 @@ export function Truthtrance({
|
||||
<option value="mixedBid">Combine current facts and ordinary bids</option>
|
||||
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
|
||||
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
|
||||
<option value="turnActions">Combine this turn’s payments, shipment and ground movements</option>
|
||||
<option value="turnActions">Combine this turn’s bids, Revival, payments, shipment and ground movements</option>
|
||||
<option value="travel">Combine this turn’s shipment and ground movements</option>
|
||||
<option value="shipment">
|
||||
Bind a shipment from reserves
|
||||
@@ -602,7 +602,7 @@ export function Truthtrance({
|
||||
<>
|
||||
<TurnActionFields value={turnActions} game={g} target={target}
|
||||
disabled={busy || !g.truthTurnActionTargets.includes(target)} onChange={setTurnActions} />
|
||||
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with a supported payment, shipment or ground-movement opportunity this turn.
|
||||
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with a supported ordinary bid, Revival, payment, shipment or ground-movement opportunity this turn.
|
||||
Ordinary payments may remain available after their Shipment and Movement opportunity ends.</p>}
|
||||
<p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
@@ -887,7 +887,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
|
||||
<p>
|
||||
{name(r.asker)} → {name(r.question.target)}:{' '}
|
||||
{truthQuestionText(r.question, (id) =>
|
||||
id === CHEAP_HERO_TRAITOR
|
||||
id === 'kwisatz' ? 'Kwisatz Haderach' : id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
: (g.allLeaders.find((l) => l.id === id)?.name ?? id),
|
||||
id => g.combatLocations.find(place => place.id === id)!.name,
|
||||
|
||||
@@ -12,20 +12,34 @@ const recipients = (game: GameView, target: string) => {
|
||||
const player = game.players.find(player => player.id === target);
|
||||
return player ? game.players.filter(recipient => recipient.id !== target && recipient.id !== player.ally) : [];
|
||||
};
|
||||
/** Public faction names only; casualties, custody and native eligibility stay
|
||||
* on the server. Advanced Atreides may name their Kwisatz Haderach. */
|
||||
const leaders = (game: GameView, target: string) => {
|
||||
const faction = game.players.find(player => player.id === target)?.faction;
|
||||
return [...game.allLeaders.filter(leader => !!faction && leader.faction === faction),
|
||||
...(game.advanced && faction === 'atreides' ? [{ id: 'kwisatz', name: 'Kwisatz Haderach' }] : [])];
|
||||
};
|
||||
const currentLot = (game: GameView) => game.bidPromiseLot?.turn === game.turn ? game.bidPromiseLot : null;
|
||||
const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
prefix: 'turn-action',
|
||||
legend: 'Future payments, shipment and ground movements, with optional current facts',
|
||||
actionLabel: 'payment, shipment or ground-movement condition',
|
||||
actionTiming: 'Future ordinary payment, reserve shipment or ground move · this turn',
|
||||
legend: 'Future bids, Revival, payments, shipment and ground movements, with optional current facts',
|
||||
actionLabel: 'bid, Revival, payment, shipment or ground-movement condition',
|
||||
actionTiming: 'Future ordinary bid, Revival, payment, reserve shipment or ground move · this turn',
|
||||
actionHelp: <>Ordinary payment conditions sum future real bribes to the same recipient after the answer this turn.
|
||||
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
|
||||
Payments made during Mentat Pause wait until next turn's Mentat opening; collection never counts twice.
|
||||
Ordinary bid conditions use the highest accepted future bid declaration in the named current lot, never the sum of smaller bids.
|
||||
A qualifying bid counts even if another player wins; it does not promise a purchase. Winning still requires the original payment.
|
||||
Ordinary Revival conditions count cumulative future normal and elite physical force returns, with each counter counted once,
|
||||
or an actual future return of a named native leader. Smaller ordinary force returns add together.
|
||||
Earlier returns, requests alone, Ghola, foreign leaders and special card returns do not count.
|
||||
Shipment conditions concern the single future original reserve shipment after the answer.
|
||||
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
|
||||
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
||||
Shipment and movement close at your own Movement turn end, while payment conditions last until the actual turn ends.
|
||||
AND requires every condition; OR keeps legal alternatives. No makes the whole expression false, not every condition separately.
|
||||
Original costs, timing, cards, preparation and action rights still apply; this claim grants no extra action.</>,
|
||||
fallbackError: 'Choose a future ordinary payment, reserve shipment or ground-movement condition, with optional current facts.',
|
||||
fallbackError: 'Choose a future ordinary bid, Revival, payment, reserve shipment or ground-movement condition, with optional current facts.',
|
||||
maxLeaves: TURN_ACTION_MAX_LEAVES,
|
||||
maxDepth: TURN_ACTION_MAX_DEPTH,
|
||||
requireFact: false,
|
||||
@@ -34,24 +48,67 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
const action = parseTurnActionLeaf(input);
|
||||
if (action.kind === 'payment' && !recipients(game, target).some(recipient => recipient.id === action.recipient))
|
||||
throw new Error('Choose a public non-allied recipient for the future ordinary payment.');
|
||||
if (action.kind === 'bid' && currentLot(game)?.lot !== action.lot)
|
||||
throw new Error('Choose the current public ordinary lot for the future bid.');
|
||||
if (action.kind === 'leader' && !leaders(game, target).some(leader => leader.id === action.leader))
|
||||
throw new Error('Choose a public native leader for the respondent’s faction.');
|
||||
return action;
|
||||
},
|
||||
renderAction: ({ value, id, game, target, disabled, onChange }) => {
|
||||
const choices = recipients(game, target);
|
||||
const choices = recipients(game, target), leaderChoices = leaders(game, target), lot = currentLot(game);
|
||||
return <>
|
||||
<label htmlFor={`${id}-action`}>Future action</label>
|
||||
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
if (kind === value.kind) return;
|
||||
if (kind === 'payment') onChange({ kind, recipient: '', minimum: value.minimum });
|
||||
const minimum = value.kind === 'leader' ? 1 : value.minimum;
|
||||
if (kind === 'payment') onChange({ kind, recipient: '', minimum });
|
||||
if (kind === 'bid') onChange({ kind, lot: lot?.lot ?? NaN, minimum });
|
||||
if (kind === 'forces') onChange({ kind, counter: 'total', minimum });
|
||||
if (kind === 'leader') onChange({ kind, leader: leaderChoices[0]?.id ?? '' });
|
||||
if (kind === 'ship' || kind === 'move') onChange({ kind,
|
||||
territory: value.kind === 'payment' ? 'carthag' : value.territory, minimum: value.minimum });
|
||||
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
|
||||
}}>
|
||||
<option value="bid">Place an ordinary-lot bid</option>
|
||||
<option value="forces">Revive ordinary physical forces</option>
|
||||
<option value="leader">Revive a named ordinary leader</option>
|
||||
<option value="payment">Make an ordinary payment</option>
|
||||
<option value="ship">Ship from reserves</option>
|
||||
<option value="move">Make ground movement</option>
|
||||
</select>
|
||||
{value.kind === 'payment' ? <>
|
||||
{value.kind === 'bid' ? <>
|
||||
<label htmlFor={`${id}-lot`}>Current ordinary lot</label>
|
||||
<select id={`${id}-lot`} value={lot?.lot === value.lot ? String(value.lot) : ''} disabled={disabled}
|
||||
aria-invalid={lot?.lot !== value.lot} onChange={event => {
|
||||
const selected = event.currentTarget.value;
|
||||
if (selected === '' || lot && selected === String(lot.lot))
|
||||
onChange({ ...value, lot: selected === '' ? NaN : Number(selected) });
|
||||
}}>
|
||||
<option value="">Choose the current ordinary lot</option>
|
||||
{lot && <option value={lot.lot}>Turn {lot.turn} · Ordinary lot {lot.lot}</option>}
|
||||
</select>
|
||||
{!lot && <p className="fine">No public ordinary lot is currently available. Special auctions do not count.</p>}
|
||||
</> : value.kind === 'forces' ? <>
|
||||
<label htmlFor={`${id}-counter`}>Physical force counters</label>
|
||||
<select id={`${id}-counter`} value={value.counter} disabled={disabled} onChange={event => {
|
||||
const counter = event.currentTarget.value;
|
||||
if (counter === 'total' || counter === 'normal' || counter === 'elite') onChange({ ...value, counter });
|
||||
}}>
|
||||
<option value="total">All physical forces</option>
|
||||
<option value="normal">Normal forces</option>
|
||||
<option value="elite">Elite forces</option>
|
||||
</select>
|
||||
</> : value.kind === 'leader' ? <>
|
||||
<label htmlFor={`${id}-leader`}>Named ordinary leader</label>
|
||||
<select id={`${id}-leader`} value={leaderChoices.some(leader => leader.id === value.leader) ? value.leader : ''}
|
||||
disabled={disabled} aria-invalid={!leaderChoices.some(leader => leader.id === value.leader)} onChange={event => {
|
||||
const leader = event.currentTarget.value;
|
||||
if (leader === '' || leaderChoices.some(choice => choice.id === leader)) onChange({ ...value, leader });
|
||||
}}>
|
||||
<option value="">Choose a named leader</option>
|
||||
{leaderChoices.map(leader => <option key={leader.id} value={leader.id}>{leader.name}</option>)}
|
||||
</select>
|
||||
</> : value.kind === 'payment' ? <>
|
||||
<label htmlFor={`${id}-recipient`}>Ordinary payment recipient</label>
|
||||
<select id={`${id}-recipient`} value={choices.some(recipient => recipient.id === value.recipient) ? value.recipient : ''}
|
||||
disabled={disabled} aria-invalid={!choices.some(recipient => recipient.id === value.recipient)}
|
||||
@@ -71,12 +128,17 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
</>}
|
||||
<label htmlFor={`${id}-minimum`}>{value.kind === 'payment' ? 'Minimum future ordinary payments in spice'
|
||||
: `Minimum physical forces in one ${value.kind === 'ship' ? 'reserve shipment' : 'ground move'}`}</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={value.kind === 'payment' ? Number.MAX_SAFE_INTEGER : 20}
|
||||
step={1} required disabled={disabled} value={Number.isFinite(value.minimum) ? value.minimum : ''}
|
||||
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 || value.kind !== 'payment' && value.minimum > 20}
|
||||
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
|
||||
{value.kind !== 'leader' && <>
|
||||
<label htmlFor={`${id}-minimum`}>{value.kind === 'payment' ? 'Minimum future ordinary payments in spice'
|
||||
: value.kind === 'bid' ? 'Minimum future accepted bid in spice'
|
||||
: value.kind === 'forces' ? 'Minimum future ordinary force returns'
|
||||
: `Minimum physical forces in one ${value.kind === 'ship' ? 'reserve shipment' : 'ground move'}`}</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={value.kind === 'payment' || value.kind === 'bid' ? Number.MAX_SAFE_INTEGER : 20}
|
||||
step={1} required disabled={disabled} value={Number.isFinite(value.minimum) ? value.minimum : ''}
|
||||
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 ||
|
||||
value.kind !== 'payment' && value.kind !== 'bid' && value.minimum > 20}
|
||||
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
|
||||
</>}
|
||||
</>;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const native = game.richeseAuction, order = game.revivalOrder, revival = game.revival;
|
||||
const auctionSource = (current: GameView) => current.auction && JSON.stringify({
|
||||
bid: current.auction.bid, bidder: current.auction.bidder, active: current.auction.active,
|
||||
remaining: current.auction.remaining, allyPayment: current.auction.allyPayment,
|
||||
remaining: current.auction.remaining, allyPayment: current.auction.allyPayment, lot: current.bidPromiseLot,
|
||||
});
|
||||
const nativeSource = (current: GameView) => current.richeseAuction && JSON.stringify({
|
||||
event: current.richeseAuction.event, method: current.richeseAuction.method,
|
||||
@@ -119,10 +119,10 @@ export function TurnActionPromises(props: TurnActionProps) {
|
||||
if (!own.length) return null;
|
||||
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance turn-action promises" id="turn-action-promise-guidance">
|
||||
<h3>Your payment, shipment and movement promises</h3>
|
||||
<h3>Your bid, Revival, payment, shipment and movement promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'turnActions', target: promise.player,
|
||||
turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
|
||||
turn: promise.turn, mixed: promise.mixed }, id => id === 'kwisatz' ? 'Kwisatz Haderach' : id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
|
||||
: game.allLeaders.find(leader => leader.id === id)?.name ?? id,
|
||||
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
|
||||
id => game.players.find(player => player.id === id)?.name ?? id)}.
|
||||
@@ -136,8 +136,14 @@ export function TurnActionPromises(props: TurnActionProps) {
|
||||
<p className="fine">Count the sum of future real ordinary payments to each named recipient after the answer.
|
||||
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
|
||||
Payments made during Mentat Pause wait until next turn's Mentat opening; collection never counts twice.
|
||||
Bid conditions count the highest accepted future declaration in the named current ordinary lot, never the sum of smaller bids.
|
||||
A qualifying bid counts even when another player wins; winning still requires the original payment.
|
||||
Force Revival conditions count cumulative future ordinary returns; normal and elite physical counters each count once.
|
||||
Named leader conditions require an actual future ordinary native return. Earlier returns, requests alone, Ghola,
|
||||
foreign leaders and special card returns do not count.
|
||||
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
|
||||
never the sum of smaller moves. Earlier shipment and movement do not count.
|
||||
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
||||
Shipment and movement close at your own Movement turn end; ordinary payments remain possible until the actual turn ends.
|
||||
Original costs, timing, cards, preparation and action rights still apply. This claim grants no extra action.</p>
|
||||
{live && <p>{game.phase === 8 ? 'Complete any remaining ordinary payment before the actual turn ends. Late recipients collect next turn.'
|
||||
@@ -164,9 +170,12 @@ export function TurnActionAnswers(props: TurnActionProps) {
|
||||
!!me && !me.autopilot && !current.response && !current.decision && !current.phaseOpening &&
|
||||
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
return <section aria-label="Answer the whole payment, shipment and movement question" className="space-y-3">
|
||||
return <section aria-label="Answer the whole bid, Revival, payment, shipment and movement question" className="space-y-3">
|
||||
<p className="notice">Answer the whole expression. Current facts freeze now; OR keeps legal alternatives and No keeps the whole expression false.
|
||||
Future real ordinary payments add together per recipient. Only one future original reserve shipment and actual future ground-movement groups count.
|
||||
Ordinary bids use the maximum future accepted declaration in the named current lot; ordinary force returns add together,
|
||||
while named leaders must actually return through ordinary native Revival.
|
||||
Bids close with their lot; Revival closes with your native Revival opportunity.
|
||||
Shipment and movement close at your own Movement turn end; payment conditions last until the actual turn ends.
|
||||
Original rights, preparation, timing and costs still apply. Only your whole answer is shared.</p>
|
||||
{answers.map(answer => {
|
||||
|
||||
@@ -16,6 +16,42 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Truthtrance / combined ordinary bidding and Revival — 26 October. Prototyped / Partial.**
|
||||
The whole current-turn question now includes original ordinary-lot bids, typed
|
||||
ordinary force returns and personal native leader returns alongside payments
|
||||
and transport. Private native constraints share actual resources and preserve
|
||||
alternative whole answers. Bids count the largest future accepted declaration,
|
||||
not summed bids or the later invoice. A bid No survives temporary passes and
|
||||
reentry until the same physical lot closes. Basic Revival No lasts to phase
|
||||
close; ordered Advanced closes at actual own finish. Later payment alternatives
|
||||
can remain. Own guides retain current native stage, including actual owned
|
||||
cash/Karama invoices. Older rows remain valid without private preparation data.
|
||||
|
||||
Root integration with disjoint helper/control/native lanes and bounded review
|
||||
passes **19 files,203/203**, types/lint/build and **four native paths**,33
|
||||
GameTable SSR renders/four actual answer callbacks/15 guide callbacks.
|
||||
Original source22 adds18 for bid3, pay2 and later paid shipment1. Source50 adds14
|
||||
for bid1, two ordinary force returns and pay1 from the same four-spice purse.
|
||||
Personal leader source102 adds8 for Hidar, a real free force return/income and
|
||||
pay2. Owned-Karama source59 adds17: wait for the actual invoice, spend the real
|
||||
held card, then pay for three force returns. Original physical inventories
|
||||
remain; no played source state is installed.
|
||||
|
||||
Twenty-two helper,17 control and18 new native cases cover intervals, maxima,
|
||||
typed cumulative progress, whole answers, Basic/Advanced closure and four minimal
|
||||
profiles. Review repairs bid-pass expiry and an out-of-stage invoice guide.
|
||||
An additional actual probe repairs zero-payment overbid funding: the owned
|
||||
Karama can preserve current cash for later Revival; the deferred Kull scope
|
||||
stays fenced. V1's source checks pass, but its private leader smoke omits the
|
||||
force clause it expected. The corrected v2 source and all four journeys pass.
|
||||
Proof/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-preparation-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-turn-preparation-runtime/`.
|
||||
Broader preparation/order/module compositions, other action families and final
|
||||
rules/browser/deployed/recovery/privacy/custody acceptance remain. No saved-game/
|
||||
database/server/reset/restart/deployment operation is performed.
|
||||
[Contract and scope](TRUTHTRANCE_TURN_ACTIONS.md#combined-ordinary-bidding-and-revival--26-october-2026).
|
||||
|
||||
**Truthtrance / combined payments, shipment and movement — 26 October. Prototyped / Partial.**
|
||||
One whole current-turn AND/OR question now combines ordinary cumulative payer
|
||||
payments, one future original reserve shipment and physical ground-group maxima,
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Combined ordinary bids and Revival
|
||||
|
||||
**Truthtrance / combined ordinary bidding and Revival — 26 October. Prototyped / Partial.**
|
||||
The whole current-turn question now includes original ordinary-lot bids, typed
|
||||
ordinary force returns and personal native leader returns alongside payments
|
||||
and transport. Private native constraints share actual resources and preserve
|
||||
alternative whole answers. Bids count the largest future accepted declaration,
|
||||
not summed bids or the later invoice. A bid No survives temporary passes and
|
||||
reentry until the same physical lot closes. Basic Revival No lasts to phase
|
||||
close; ordered Advanced closes at actual own finish. Later payment alternatives
|
||||
can remain. Own guides retain current native stage, including actual owned
|
||||
cash/Karama invoices. Older rows remain valid without private preparation data.
|
||||
|
||||
Root integration with disjoint helper/control/native lanes and bounded review
|
||||
passes **19 files,203/203**, types/lint/build and **four native paths**,33
|
||||
GameTable SSR renders/four actual answer callbacks/15 guide callbacks.
|
||||
Original source22 adds18 for bid3, pay2 and later paid shipment1. Source50 adds14
|
||||
for bid1, two ordinary force returns and pay1 from the same four-spice purse.
|
||||
Personal leader source102 adds8 for Hidar, a real free force return/income and
|
||||
pay2. Owned-Karama source59 adds17: wait for the actual invoice, spend the real
|
||||
held card, then pay for three force returns. Original physical inventories
|
||||
remain; no played source state is installed.
|
||||
|
||||
Twenty-two helper,17 control and18 new native cases cover intervals, maxima,
|
||||
typed cumulative progress, whole answers, Basic/Advanced closure and four minimal
|
||||
profiles. Review repairs bid-pass expiry and an out-of-stage invoice guide.
|
||||
An additional actual probe repairs zero-payment overbid funding: the owned
|
||||
Karama can preserve current cash for later Revival; the deferred Kull scope
|
||||
stays fenced. V1's source checks pass, but its private leader smoke omits the
|
||||
force clause it expected. The corrected v2 source and all four journeys pass.
|
||||
Proof/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-preparation-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-turn-preparation-runtime/`.
|
||||
Broader preparation/order/module compositions, other action families and final
|
||||
rules/browser/deployed/recovery/privacy/custody acceptance remain. No saved-game/
|
||||
database/server/reset/restart/deployment operation is performed.
|
||||
[Contract and scope](TRUTHTRANCE_TURN_ACTIONS.md#combined-ordinary-bidding-and-revival--26-october-2026).
|
||||
|
||||
## 26 October 2026 — Combined current-turn payments and native transport
|
||||
|
||||
**Truthtrance / combined payments, shipment and movement — 26 October. Prototyped / Partial.**
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Rule decision index
|
||||
|
||||
## Combined ordinary bids and Revival — 26 October 2026
|
||||
|
||||
Extend the recorded current-turn AND/OR composition to the original ordinary
|
||||
lot's future accepted bid declarations and ordinary personal native Revival.
|
||||
Bid maxima never sum or become purchase payments. Force returns accumulate by
|
||||
physical type; earlier/card/special/foreign returns stay excluded. Personal
|
||||
leader identities and original costs/cycles/slots remain. Bids remain bound to
|
||||
the original physical lot despite temporary passes that another bid can reset.
|
||||
Basic Revival No closes with the phase; ordered Advanced closes at actual own
|
||||
finish. Own completion must preserve the whole answer or an affordable later
|
||||
alternative. Exact future owned invoice preparations are guides only at their
|
||||
actual native decision. These are provisional native-solver compositions of
|
||||
publisher FAQp8 and supplied Advanced physical39, not a complete arbitrary
|
||||
promise solver or new action rights. Broader preparation/module and full-mode
|
||||
acceptance remain. [Contract/evidence](TRUTHTRANCE_TURN_ACTIONS.md#combined-ordinary-bidding-and-revival--26-october-2026).
|
||||
|
||||
## Combined payments and native transport — 26 October 2026
|
||||
|
||||
Compose the existing publisher November2020 FAQp8 current-turn AND/OR and
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Truthtrance combined current-turn actions
|
||||
|
||||
## Payments, reserve shipment and physical movement prototype
|
||||
## Native current-turn action prototype
|
||||
|
||||
**Prototyped / Partial.** New raw question:
|
||||
`{kind:'turnActions',target,turn,mixed}`. Action leaves are ordinary
|
||||
`{kind:'payment',recipient,minimum}` or native
|
||||
`{kind:'ship'|'move',territory,minimum}`. Optional current facts and full AND/OR
|
||||
share sixteen logical leaves/four grouping levels. Old travel/payment question
|
||||
shapes and mode gates remain.
|
||||
shapes and mode gates remain. Current ordinary-lot bids and personal native
|
||||
Revival leaves extend this envelope as detailed below.
|
||||
|
||||
## Authority and meaning
|
||||
|
||||
@@ -103,3 +104,69 @@ unknown-fact correlations and complete rules/browser/deployed/recovery/privacy/
|
||||
custody acceptance remain. Full strategy/calibration stays deferred. Preserve
|
||||
all games and the hosting project; this batch performs no save/database/server/
|
||||
reset/restart/deployment operations and certifies no complete mode.
|
||||
|
||||
## Combined ordinary bidding and Revival — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.**
|
||||
|
||||
Extend action leaves with `{kind:'bid',lot,minimum}` for the current ordinary
|
||||
lot, `{kind:'forces',counter,minimum}` and `{kind:'leader',leader}`. Accepted
|
||||
future bids use their maximum declaration, never a sum or winning invoice.
|
||||
Ordinary physical force returns accumulate; actual personal native leader
|
||||
returns count once. Earlier returns, special/card/foreign-ghola returns do not.
|
||||
|
||||
An optional private preparation stage records the original physical lot source,
|
||||
actual future bid maximum and cumulative typed returns/leader identities. Old
|
||||
raw payment/transport rows stay valid without it. Stage data and virtual native
|
||||
constraints remain private; own projected history retains the original whole
|
||||
question and aggregate lifecycle only. All raw identities/lots are validated
|
||||
eagerly, including folded alternatives.
|
||||
|
||||
Bidding closes at the original physical lot close. A later bid can reset a
|
||||
pass, so No remains binding through reentry in that same lot. Basic Revival No lasts
|
||||
to phase close; Advanced closes at the owner's actual ordered finish. Own Ready
|
||||
must retain a whole completion, including a funded later payment alternative.
|
||||
Future payments, shipment and ground groups keep their existing deadlines,
|
||||
costs and scope. Native bids and Revival continue through original human/AI
|
||||
controls and exact current-phase guides.
|
||||
|
||||
The frozen v2 source passes **19 files,203/203**, types/lint, production build and
|
||||
four native paths with33 GameTable SSR renders, four actual answer callbacks
|
||||
and15 guide callbacks. Original source22 +18 performs bid3, pay2 and later paid
|
||||
shipment1; source50 +14 funds bid1, two ordinary returns and pay1 from four spice.
|
||||
Personal leader source102 +8 returns Hidar plus a real free force/income and
|
||||
pays2. Owned-Karama source59 +17 retains the actual foreign bidder before the
|
||||
owned invoice, consumes the physical held card and pays for three returns.
|
||||
All original physical inventories remain. No played source state is installed;
|
||||
these are local engine/SSR/callback journeys, not browser or deployed acceptance.
|
||||
|
||||
Twenty-two helper and17 control cases support18 new native cases. They cover
|
||||
both bid/payment alternatives, current-lot declaration/invoice separation,
|
||||
whole-AND No event orders, maxima versus summed bids, cumulative ordinary
|
||||
returns, personal leader identity, prior-return exclusion, later payments,
|
||||
Basic phase/Advanced own closure, exact source guards and four minimal profiles.
|
||||
Three genuine cross-stage cases prove a shared bid/force/payment purse, a No
|
||||
surviving pass-reset/reentry and an invoice guide waiting for actual ownership.
|
||||
An additional genuine held-Karama overbid case preserves the four-spice purse
|
||||
for required Revival. Zero projected payments require no additional spendable
|
||||
spice; native quotes still own the actual free invoice and later return costs.
|
||||
Kull modes retain their existing fences and deferred overbid boundary.
|
||||
|
||||
Focused feedback corrects cash-only automatic-invoice, current bidder and Basic
|
||||
phase-close test assumptions. Bounded review repairs a temporary-pass expiry
|
||||
and an out-of-stage invoice guide. V1 passes source checks but the private
|
||||
leader smoke expects free-force income without asking for that force. The
|
||||
corrected smoke asks the intended full conjunction. A separate actual overbid
|
||||
probe exposes the zero-payment resource defect; its repair/regression and all
|
||||
checks pass in v2.
|
||||
|
||||
Proof/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-preparation-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-turn-preparation-runtime/`.
|
||||
|
||||
Payment clauses retain the existing funded-current-purse envelope; wider
|
||||
invoice-first payment ordering, owned/module preparations, other action
|
||||
families and full-mode acceptance remain unfinished. Existing native stage
|
||||
promises retain their original scope. No comprehensive rule/recovery/privacy/
|
||||
custody or deployed gate opens, and this batch performs no database/save/server/
|
||||
reset/restart/deployment operation.
|
||||
+83
-9
@@ -1,6 +1,7 @@
|
||||
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
|
||||
validateTurnActionPromises, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf } from './turn-action-promises';
|
||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival,
|
||||
closeTurnActionRevival, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
|
||||
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
||||
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
|
||||
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
@@ -15839,6 +15840,17 @@ function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) {
|
||||
if (action.bidPromiseEvent !== undefined) requireRule(typeof action.bidPromiseEvent === 'string' &&
|
||||
live.some(promise => promise.event === action.bidPromiseEvent), 'This bid commitment guide is stale.');
|
||||
if (!opportunity) return;
|
||||
if (g.turnActionPromises) {
|
||||
if (amount !== undefined) g.turnActionPromises = turnActionRule(() =>
|
||||
recordTurnActionBid(g.turnActionPromises!, p.id, g.turn, {
|
||||
lot: opportunity.lot, source: opportunity.source, amount }));
|
||||
// A pass can be reset by a later bid in this same physical lot. Close only
|
||||
// the private Yes-alternative witness; keep actual No caps until lot close.
|
||||
const afterPass = amount === undefined ? { ...g, turnActionPromises: turnActionRule(() =>
|
||||
closeTurnActionBidding(g.turnActionPromises!, g.turn, opportunity.source, p.id)) } : g;
|
||||
requireRule(!!turnActionCompletion(afterPass, p.id),
|
||||
'Retain a legal whole Truthtrance completion after this ordinary bid or pass.');
|
||||
}
|
||||
if (amount !== undefined && live.length) {
|
||||
try { g.bidPromises = recordPromisedBid(g.bidPromises ?? [], p.id, opportunity, amount); }
|
||||
catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; }
|
||||
@@ -15861,6 +15873,9 @@ function auctionNext(g: Game) {
|
||||
passed: a.passed,
|
||||
});
|
||||
if (next.kind !== 'bid') {
|
||||
const lot = bidOpportunity(g);
|
||||
if (lot && g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
|
||||
closeTurnActionBidding(g.turnActionPromises!, g.turn, lot.source));
|
||||
if (next.kind === 'unbid') {
|
||||
g.deck = [...a.cards.slice(a.index), ...g.deck];
|
||||
g.auction = null;
|
||||
@@ -18693,7 +18708,7 @@ function initializeTurnActionOrder(g: Game) {
|
||||
if (!g.travelPromiseOrder!.some(entry => entry.kind === kind && entry.index === index))
|
||||
g.travelPromiseOrder!.push({ kind, index });
|
||||
}
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[] };
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan };
|
||||
/** Whole-answer alternatives stay private. Only detached native rows quote a
|
||||
* route, sharing actual interval progress and the remaining physical purse. */
|
||||
function turnActionCompletion(source: Game, actor: string): TurnActionCompletion | null {
|
||||
@@ -18727,7 +18742,13 @@ function turnActionCompletion(source: Game, actor: string): TurnActionCompletion
|
||||
recipient: leaf.recipient, minimum: leaf.minimum, progress: leaf.progress });
|
||||
else if (leaf.kind === 'ship') trial.shipmentPromises!.push({ ...metadata,
|
||||
territory: leaf.territory, minimum: leaf.minimum });
|
||||
else trial.movementPromises!.push({ ...metadata, territory: leaf.territory, minimum: leaf.minimum });
|
||||
else if (leaf.kind === 'move') trial.movementPromises!.push({ ...metadata,
|
||||
territory: leaf.territory, minimum: leaf.minimum });
|
||||
else if (leaf.kind === 'bid') (trial.bidPromises ??= []).push({ ...metadata,
|
||||
lot: leaf.lot, source: leaf.source, minimum: leaf.minimum });
|
||||
else if (leaf.kind === 'forces') (trial.revivalPromises ??= []).push({ ...metadata,
|
||||
counter: leaf.counter, minimum: leaf.minimum, progress: leaf.progress });
|
||||
else if (leaf.kind === 'leader') (trial.leaderRevivalPromises ??= []).push({ ...metadata, leader: leaf.leader });
|
||||
});
|
||||
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
|
||||
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
|
||||
@@ -18739,7 +18760,7 @@ function turnActionCompletion(source: Game, actor: string): TurnActionCompletion
|
||||
pendingNullentropy: ownedBox ? undefined : trial.pendingNullentropy } : trial;
|
||||
if (required.length && bribeTimingBlock(paymentWindow)) continue;
|
||||
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
if (!Number.isSafeInteger(total) || total > uncommittedSpice(trial, owner) ||
|
||||
if (!Number.isSafeInteger(total) || total > 0 && total > uncommittedSpice(trial, owner) ||
|
||||
required.some(payment => payment.recipient === actor || payment.recipient === owner.ally ||
|
||||
!trial.players.some(player => player.id === payment.recipient))) continue;
|
||||
const funded = structuredClone(trial), payer = getPlayer(funded, actor);
|
||||
@@ -18752,14 +18773,25 @@ function turnActionCompletion(source: Game, actor: string): TurnActionCompletion
|
||||
reconcileBattlePromises(funded, voluntary);
|
||||
reconcileBidPromises(funded, voluntary);
|
||||
reconcileRevivalPromises(funded, voluntary);
|
||||
const lot = bidOpportunity(funded);
|
||||
const needsBid = !!lot && bidRequired(funded.bidPromises ?? [], actor, lot);
|
||||
const bid = needsBid ? bidPromiseCompletion(funded, payer) : undefined;
|
||||
if (needsBid && !bid) continue;
|
||||
const needsRevival = [
|
||||
...liveRevivalPromises(funded.revivalPromises ?? [], actor, source.turn),
|
||||
...liveLeaderRevivalPromises(funded.leaderRevivalPromises ?? [], actor, source.turn),
|
||||
...liveRevivalExpressionPromises(funded.revivalExpressionPromises ?? [], actor, source.turn),
|
||||
].some(promise => promise.answer);
|
||||
const revival = needsRevival ? revivalPromisePlan(funded, payer) : undefined;
|
||||
if (needsRevival && !revival) continue;
|
||||
const transport = [
|
||||
...liveShipmentPromises(funded.shipmentPromises ?? [], actor, source.turn),
|
||||
...liveMovementPromises(funded.movementPromises ?? [], actor, source.turn),
|
||||
...liveMovementExpressionPromises(funded.movementExpressionPromises ?? [], actor, source.turn),
|
||||
];
|
||||
const completion = transport.length ? groundMovementCompletionNative(funded, actor) : { actions: [] };
|
||||
if (completion) return { ...completion, payments: required.map(payment =>
|
||||
({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
|
||||
if (completion) return { ...completion, ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
return null;
|
||||
@@ -18771,7 +18803,23 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
const compiled = compileMixedActionExpression(claim.mixed,
|
||||
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
|
||||
const open = turnActionTransportOpen(g, p.id);
|
||||
let bid = false, revival = false;
|
||||
const visit = (node: TurnActionClaim['mixed']): void => {
|
||||
if (node.kind === 'action') {
|
||||
if (node.action.kind === 'bid') bid = true;
|
||||
if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true;
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(claim.mixed);
|
||||
const lot = bid ? bidOpportunity(g) : null;
|
||||
requireRule(!bid || !!lot, 'Retain the original current ordinary auction lot.');
|
||||
const preparation: TurnActionPreparation | undefined = bid || revival ? {
|
||||
bid: lot ? { lot: lot.lot, source: lot.source, maximum: 0, closed: false } : null,
|
||||
revival: revival ? { total: 0, normal: 0, elite: 0, leaders: [], closed: g.phase > 4 ||
|
||||
g.phase === 4 && !!g.revivalOrder && !g.revivalOrder.remaining.includes(p.id) } : null,
|
||||
} : undefined;
|
||||
return { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
|
||||
...(preparation ? { preparation } : {}),
|
||||
shipment: { pending: open && (g.phase < 5 || shipmentAvailable(g, p)), event: null },
|
||||
progress: [], payments: [], transportClosed: !open, paymentClosed: false,
|
||||
fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false };
|
||||
@@ -18814,6 +18862,14 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
|
||||
'You cannot voluntarily close an unfulfilled whole Truthtrance answer.');
|
||||
g.turnActionPromises = finished;
|
||||
}
|
||||
const lot = bidOpportunity(g);
|
||||
for (const promise of liveTurnActionPromises(g.turnActionPromises!, p.id, g.turn)) {
|
||||
const bid = promise.preparation?.bid;
|
||||
if (bid && (!lot || lot.source !== bid.source))
|
||||
g.turnActionPromises = turnActionRule(() => closeTurnActionBidding(g.turnActionPromises!, g.turn, bid.source, p.id));
|
||||
}
|
||||
if (g.phase > 4 || g.phase === 4 && g.revivalOrder && !g.revivalOrder.remaining.includes(p.id))
|
||||
g.turnActionPromises = turnActionRule(() => closeTurnActionRevival(g.turnActionPromises!, p.id, g.turn));
|
||||
if (g.phase > 5) g.turnActionPromises = turnActionRule(() =>
|
||||
closeTurnActionTransport(g.turnActionPromises!, p.id, g.turn));
|
||||
}
|
||||
@@ -18830,7 +18886,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
|
||||
const early = g.phase < 5 || sourcePhase < 5;
|
||||
const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action &&
|
||||
(['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive',
|
||||
'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
|
||||
'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'emperorRevival', 'decision', 'passBid', 'karama', 'specialKarama'].includes(action.type) ||
|
||||
action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 ||
|
||||
action.type === 'ready' && [2, 4, 7, 8].includes(sourcePhase)));
|
||||
if (g.phaseOpening || g.response || g.decision || g.pendingTreacheryDiscard || g.pendingAmbassador ||
|
||||
@@ -18875,12 +18931,18 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
|
||||
if (!action && g.phase <= 5) {
|
||||
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
|
||||
if (lot && bidRequired(g.bidPromises ?? [], player.id, lot)) action = projectedBidPromiseGuide(g, player.id)?.action;
|
||||
if (lot && g.auction?.active === player.id) action = completion.bid ??
|
||||
(bidRequired(g.bidPromises ?? [], player.id, lot) ? projectedBidPromiseGuide(g, player.id)?.action : undefined);
|
||||
const revival = [
|
||||
...liveRevivalPromises(g.revivalPromises ?? [], player.id, g.turn),
|
||||
...liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player.id, g.turn),
|
||||
...liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player.id, g.turn),
|
||||
].some(row => row.answer);
|
||||
if (!action && completion.revival && !ownedBox &&
|
||||
!(g.phase === 4 && nativeRevivalOrderBlock(g, player.id)))
|
||||
action = g.phase === 4 ? completion.revival.action
|
||||
: g.phase === 2 || g.phase === 3 && ownedInvoice
|
||||
? completion.revival.early.find(step => step.phase === g.phase)?.action : undefined;
|
||||
if (!action && revival && !ownedBox) action = (projectedRevivalExpressionPromiseGuide(g, player.id) ??
|
||||
projectedLeaderRevivalPromiseGuide(g, player.id) ?? projectedRevivalPromiseGuide(g, player.id))?.action;
|
||||
if (!action && g.phase < 5) {
|
||||
@@ -31272,6 +31334,8 @@ function revivalExpressionRule<T>(calculate: () => T): T {
|
||||
catch (error) { if (error instanceof RevivalExpressionPromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function recordRevivalExpressionEvent(g: Game, player: string, event: RevivalExpressionReturn) {
|
||||
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
|
||||
recordTurnActionRevival(g.turnActionPromises!, player, g.turn, event));
|
||||
if (liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player, g.turn).length)
|
||||
g.revivalExpressionPromises = revivalExpressionRule(() => recordRevivalExpressionReturn(
|
||||
g.revivalExpressionPromises ?? [], player, g.turn, event));
|
||||
@@ -41023,7 +41087,17 @@ function applyActionInner(
|
||||
if (bribeWindowCloses) requireRule(!hasBribePromises(g, p.id, true),
|
||||
g.phase === 8 ? 'Complete your Truthtrance bribe payments before finishing Mentat Pause.'
|
||||
: 'Pay your Truthtrance bribes before the known Inflation Double opening prevents them.');
|
||||
if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); guardRevivalExpressionReady(g, p); }
|
||||
if (g.phase === 4) {
|
||||
if (g.turnActionPromises) {
|
||||
const closed = turnActionRule(() => closeTurnActionRevival(g.turnActionPromises!, p.id, g.turn));
|
||||
requireRule(!!turnActionCompletion({ ...g, turnActionPromises: closed }, p.id),
|
||||
'Complete your whole Truthtrance Revival answer or retain another legal alternative.');
|
||||
// Basic No retains its cap until phase close, even if a new Truth later
|
||||
// reopens concurrent Ready. Ordered Advanced finish closes it now.
|
||||
if (g.revivalOrder) g.turnActionPromises = closed;
|
||||
}
|
||||
guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); guardRevivalExpressionReady(g, p);
|
||||
}
|
||||
requireRule(
|
||||
[1, 2, 4, 7, 8].includes(g.phase) ||
|
||||
(g.phase === 0 && g.stormPending !== null),
|
||||
|
||||
+10
-7
@@ -3187,24 +3187,27 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-turn-actions',
|
||||
title: 'Truthtrance: combined payments, shipment and movements',
|
||||
title: 'Truthtrance: combined bids, Revival, payments and transport',
|
||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'Combine ordinary future payments and native physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
summary: 'Combine ordinary current-lot bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
steps: [
|
||||
'Choose Combine this turn’s payments, shipment and ground movements. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of the native funded-payment and owned-stage travel prototypes; wider action families and optional combinations remain unfinished.',
|
||||
'Choose Combine this turn’s bids, Revival, payments and transport. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of the native current-lot bid, ordinary Revival, funded-payment and owned-stage travel prototypes; wider action families and optional combinations remain unfinished.',
|
||||
'Only actual ordinary bribes paid by the respondent after answering add payment progress. Payments to the same recipient accumulate. Shipment fees, gifts and later receipt of incoming bribes do not count. Native recipient restrictions, costs and receipt timing remain.',
|
||||
'An ordinary-bid clause names the current public lot and counts the maximum new accepted declaration after answering. Earlier bids, summed bids and later purchase invoices do not count. A pass can reset after another bid, so No stays binding through reentry until that original physical lot closes.',
|
||||
'Ordinary force Revival clauses accumulate future total, normal or elite physical returns. Personal native leader clauses include eligible Advanced Atreides Kwisatz. Prior returns, Ghola, foreign leaders and special returns do not count. Original costs, slots, cycles and actual return controls remain.',
|
||||
'Basic Revival No lasts to phase close; ordered Advanced finishes at the respondent’s actual own turn end. Own Ready must retain a whole completion, including a funded later payment alternative. An owned Karama invoice guide appears only at the actual payment decision, never while another bidder is active.',
|
||||
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
|
||||
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
|
||||
'Transport closes at your actual Movement end. A payment alternative may remain after that close, through the full current turn including Mentat. Complete a required payment before finishing the turn. No payment caps remain until their native opportunity closes.',
|
||||
'Review the persistent own history and current source-bound native guide, or choose another legal manual route. Every definite answer uniformly reopens applicable concurrent preparation or final payment readiness. Four minimal AI profiles participate; complete strategy and final rules/deployed acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-travel', 'truthtrance-bribe-expression', 'implementation-checklist'],
|
||||
related: ['card-truthtrance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native payment and transport constraints with separate actual intervals and deadlines.', evidence: ['game/turn-action-promises.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native bid, Revival, payment and transport constraints with separate actual intervals and deadlines.', evidence: ['game/turn-action-promises.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources; broader composition and final acceptance remain.', evidence: ['tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources; broader composition and final acceptance remain.', evidence: ['tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+14
-2
@@ -52,6 +52,7 @@ import {
|
||||
type PlanClaim,
|
||||
} from './battle-promises';
|
||||
import { gameTerritories } from './board';
|
||||
import { leaders } from './cards';
|
||||
import { combatLocationName } from './combat-location';
|
||||
import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
|
||||
import type { Action, Game, Player } from './engine';
|
||||
@@ -385,6 +386,12 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const action = parseTurnActionLeaf(input);
|
||||
if (action.kind === 'payment') check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
|
||||
if (action.kind === 'bid') check(bidPromiseModeSupported(g) && g.phase === 3 && g.auction &&
|
||||
!g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && action.lot === g.auction.index + 1,
|
||||
'Choose the current ordinary bid lot in a supported native auction.');
|
||||
if (action.kind === 'leader') check(leaders(respondent.faction).some(leader => leader.id === action.leader) ||
|
||||
action.leader === 'kwisatz' && g.advanced && respondent.faction === 'atreides',
|
||||
'Choose a personal native faction leader, or the Advanced Atreides Kwisatz Haderach.');
|
||||
return action;
|
||||
}, { requireFact: false, maxDepth: TURN_ACTION_MAX_DEPTH, maxLeaves: TURN_ACTION_MAX_LEAVES });
|
||||
return { kind: 'turnActions', target: v.target as string, turn: g.turn, mixed };
|
||||
@@ -699,8 +706,13 @@ export function truthQuestionText(
|
||||
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
|
||||
};
|
||||
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => action.kind === 'payment' ? `you ${bribeClaimText({ turn: q.turn, ...action }, playerName)}` :
|
||||
`you ${action.kind === 'ship' ? shipmentClaimText(action) : movementClaimText(action)} before your Shipment and Movement turn ends`)}?`;
|
||||
action => {
|
||||
if (action.kind === 'payment') return `you ${bribeClaimText({ turn: q.turn, ...action }, playerName)}`;
|
||||
if (action.kind === 'bid') return `you ${bidClaimText({ turn: q.turn, ...action })}`;
|
||||
if (action.kind === 'forces' || action.kind === 'leader') return `you ${revivalExpressionText(action,
|
||||
id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your ordinary Revival opportunity ends this turn`;
|
||||
return `you ${action.kind === 'ship' ? shipmentClaimText(action) : movementClaimText(action)} before your Shipment and Movement turn ends`;
|
||||
})}?`;
|
||||
if (q.kind === 'travel') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => `you ${action.kind === 'ship' ? shipmentClaimText(action) : movementClaimText(action)}`)} this turn?`;
|
||||
if (q.kind === 'mixedTraitorCall') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
|
||||
+156
-18
@@ -1,5 +1,8 @@
|
||||
import { TERRITORIES } from './board';
|
||||
import { BribePromiseError, parseBribeClaim } from './bribe-promises';
|
||||
import { BidPromiseError, parseBidClaim } from './bid-promises';
|
||||
import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-revival-question';
|
||||
import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises';
|
||||
import {
|
||||
MixedActionQuestionError,
|
||||
parseMixedActionExpression,
|
||||
@@ -18,11 +21,20 @@ import type { TruthFact } from './truthtrance';
|
||||
export const TURN_ACTION_MAX_LEAVES = 16;
|
||||
export const TURN_ACTION_MAX_DEPTH = 4;
|
||||
|
||||
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number };
|
||||
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } |
|
||||
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim;
|
||||
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
|
||||
export type TurnActionPayment = { recipient: string; amount: number };
|
||||
export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
{ kind: 'payment'; recipient: string; minimum: number; progress: number; answer: boolean };
|
||||
{ kind: 'payment'; recipient: string; minimum: number; progress: number; answer: boolean } |
|
||||
{ kind: 'bid'; lot: number; source: string; minimum: number; answer: boolean } |
|
||||
{ kind: 'forces'; counter: 'total' | 'normal' | 'elite'; minimum: number; progress: number; answer: boolean } |
|
||||
{ kind: 'leader'; leader: string; answer: boolean };
|
||||
export type TurnActionPreparation = {
|
||||
bid: { lot: number; source: string; maximum: number; closed: boolean } | null;
|
||||
revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null;
|
||||
};
|
||||
export type TurnActionBid = { lot: number; source: string; amount: number };
|
||||
export type TurnActionPromise = TurnActionClaim & {
|
||||
event: string;
|
||||
player: string;
|
||||
@@ -39,6 +51,8 @@ export type TurnActionPromise = TurnActionClaim & {
|
||||
paymentClosed: boolean;
|
||||
fulfilled: boolean;
|
||||
released: boolean;
|
||||
/** Optional only for original shipment/movement/payment records. */
|
||||
preparation?: TurnActionPreparation;
|
||||
};
|
||||
|
||||
export class TurnActionError extends Error {
|
||||
@@ -67,14 +81,21 @@ const printedTerritory = (value: unknown): value is string =>
|
||||
|
||||
/** Public leaves carry only their native destination/recipient and minimum. */
|
||||
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
check(object(value), 'Choose one future reserve shipment, ground movement or ordinary bribe payment.');
|
||||
check(object(value), 'Choose one future ordinary bid, Revival, reserve shipment, ground movement or bribe payment.');
|
||||
try {
|
||||
if (value.kind === 'bid') {
|
||||
check(keys(value, 'kind,lot,minimum'), 'Supply only the current ordinary lot and positive bid minimum.');
|
||||
const claim = parseBidClaim({ turn: 1, lot: value.lot, minimum: value.minimum });
|
||||
return { kind: 'bid', lot: claim.lot, minimum: claim.minimum };
|
||||
}
|
||||
if (value.kind === 'forces' || value.kind === 'leader') return parseRevivalActionClaim(value);
|
||||
if (value.kind !== 'payment') return parseTravelActionClaim(value);
|
||||
check(keys(value, 'kind,minimum,recipient'), 'Supply only the ordinary payment recipient and minimum.');
|
||||
const claim = parseBribeClaim({ turn: 1, recipient: value.recipient, minimum: value.minimum });
|
||||
return { kind: 'payment', recipient: claim.recipient, minimum: claim.minimum };
|
||||
} catch (error) {
|
||||
if (error instanceof TravelPromiseError || error instanceof BribePromiseError)
|
||||
if (error instanceof TravelPromiseError || error instanceof BribePromiseError || error instanceof BidPromiseError ||
|
||||
error instanceof RevivalExpressionPromiseError)
|
||||
throw new TurnActionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -95,8 +116,11 @@ function storedFact(value: unknown, depth = 0): TruthFact {
|
||||
}
|
||||
|
||||
function actionKey(action: TurnActionLeaf): string {
|
||||
return JSON.stringify(action.kind === 'payment'
|
||||
? [action.kind, action.recipient, action.minimum] : [action.kind, action.territory, action.minimum]);
|
||||
if (action.kind === 'leader') return JSON.stringify([action.kind, action.leader]);
|
||||
if (action.kind === 'forces') return JSON.stringify([action.kind, action.counter, action.minimum]);
|
||||
if (action.kind === 'bid') return JSON.stringify([action.kind, action.lot, action.minimum]);
|
||||
return JSON.stringify(action.kind === 'payment' ? [action.kind, action.recipient, action.minimum] :
|
||||
[action.kind, action.territory, action.minimum]);
|
||||
}
|
||||
|
||||
function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledMixedAction<TurnActionLeaf> {
|
||||
@@ -131,8 +155,9 @@ function validateEvent(event: unknown): asserts event is TravelEvent {
|
||||
}
|
||||
|
||||
function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnActionLeaf> {
|
||||
check(object(promise) && keys(promise,
|
||||
'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn') &&
|
||||
const fields = 'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn';
|
||||
check(object(promise) && (keys(promise, fields) || keys(promise,
|
||||
'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,preparation,progress,released,shipment,transportClosed,turn')) &&
|
||||
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
|
||||
'Retain the complete original turn-action commitment and its metadata.');
|
||||
let mixed: MixedActionExpression<TurnActionLeaf>;
|
||||
@@ -147,14 +172,38 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
const originals = new Set<string>();
|
||||
const destinations = new Set<string>();
|
||||
const recipients = new Set<string>();
|
||||
const lots = new Set<number>();
|
||||
let hasRevival = false;
|
||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||
if (node.kind === 'action') {
|
||||
originals.add(actionKey(node.action));
|
||||
if (node.action.kind === 'move') destinations.add(node.action.territory);
|
||||
if (node.action.kind === 'payment') recipients.add(node.action.recipient);
|
||||
if (node.action.kind === 'bid') lots.add(node.action.lot);
|
||||
if (node.action.kind === 'forces' || node.action.kind === 'leader') hasRevival = true;
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
check(!lots.size || !!promise.preparation?.bid, 'Retain the original private lot binding for every ordinary bid leaf.');
|
||||
check(!hasRevival || !!promise.preparation?.revival, 'Retain this answer’s ordinary Revival interval.');
|
||||
if ('preparation' in promise) {
|
||||
const preparation = promise.preparation;
|
||||
check(object(preparation) && keys(preparation, 'bid,revival'), 'Retain only the private native preparation stages.');
|
||||
if (preparation.bid !== null) {
|
||||
const bid = preparation.bid;
|
||||
check(object(bid) && keys(bid, 'closed,lot,maximum,source') && positive(bid.lot) && identifier(bid.source) &&
|
||||
whole(bid.maximum) && typeof bid.closed === 'boolean' && [...lots].every(lot => lot === bid.lot),
|
||||
'Retain one original ordinary lot, source, actual maximum accepted bid and closure.');
|
||||
}
|
||||
if (preparation.revival !== null) {
|
||||
const revival = preparation.revival;
|
||||
check(object(revival) && keys(revival, 'closed,elite,leaders,normal,total') && physical(revival.total) &&
|
||||
physical(revival.normal) && physical(revival.elite) && revival.total === revival.normal + revival.elite &&
|
||||
Array.isArray(revival.leaders) && revival.leaders.every(identifier) &&
|
||||
new Set(revival.leaders).size === revival.leaders.length && typeof revival.closed === 'boolean',
|
||||
'Retain typed actual ordinary force returns, unique personal leaders and Revival closure.');
|
||||
}
|
||||
}
|
||||
const compiled = readCompiled(promise.compiled, originals);
|
||||
check(typeof promise.answer === 'boolean' && typeof promise.fulfilled === 'boolean' &&
|
||||
typeof promise.released === 'boolean' && typeof promise.transportClosed === 'boolean' &&
|
||||
@@ -185,6 +234,9 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
|
||||
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'bid') return (promise.preparation?.bid?.maximum ?? 0) >= action.minimum;
|
||||
if (action.kind === 'forces') return (promise.preparation?.revival?.[action.counter] ?? 0) >= action.minimum;
|
||||
if (action.kind === 'leader') return promise.preparation?.revival?.leaders.includes(action.leader) ?? false;
|
||||
if (action.kind === 'payment') return (promise.payments.find(payment =>
|
||||
payment.recipient === action.recipient)?.amount ?? 0) >= action.minimum;
|
||||
if (action.kind === 'ship') return promise.shipment.event !== null &&
|
||||
@@ -200,6 +252,8 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
|
||||
}
|
||||
|
||||
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'bid') return promise.preparation!.bid!.closed;
|
||||
if (action.kind === 'forces' || action.kind === 'leader') return promise.preparation!.revival!.closed;
|
||||
return action.kind === 'payment' ? promise.paymentClosed : action.kind === 'ship'
|
||||
? !promise.shipment.pending : promise.transportClosed;
|
||||
}
|
||||
@@ -233,21 +287,30 @@ export function liveTurnActionPromises<T extends TurnActionPromise>(promises: re
|
||||
}
|
||||
|
||||
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
||||
return left.kind === right.kind && (left.kind === 'payment'
|
||||
? right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress
|
||||
: right.kind !== 'payment' && left.territory === right.territory);
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === 'leader') return right.kind === 'leader' && left.leader === right.leader;
|
||||
if (left.kind === 'forces') return right.kind === 'forces' && left.counter === right.counter && left.progress === right.progress;
|
||||
if (left.kind === 'bid') return right.kind === 'bid' && left.lot === right.lot && left.source === right.source;
|
||||
if (left.kind === 'payment') return right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress;
|
||||
return (right.kind === 'ship' || right.kind === 'move') && left.territory === right.territory;
|
||||
}
|
||||
|
||||
/** Called within one record only: payment progress belongs to that interval. */
|
||||
function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf[]): TurnActionBranchLeaf[] | null {
|
||||
const merged = left.map(leaf => ({ ...leaf }));
|
||||
for (const leaf of right) {
|
||||
if (leaf.kind === 'leader') {
|
||||
const prior = merged.find(other => sameAction(other, leaf));
|
||||
if (prior && prior.answer !== leaf.answer) return null;
|
||||
if (!prior) merged.push({ ...leaf });
|
||||
continue;
|
||||
}
|
||||
const same = merged.find(other => sameAction(other, leaf) && other.answer === leaf.answer);
|
||||
if (same) same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum);
|
||||
if (same && same.kind !== 'leader') same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum);
|
||||
else merged.push({ ...leaf });
|
||||
const yes = merged.find(other => sameAction(other, leaf) && other.answer);
|
||||
const no = merged.find(other => sameAction(other, leaf) && !other.answer);
|
||||
if (yes && no && yes.minimum >= no.minimum) return null;
|
||||
if (yes && no && yes.kind !== 'leader' && no.kind !== 'leader' && yes.minimum >= no.minimum) return null;
|
||||
if (leaf.kind === 'ship' && leaf.answer && merged.some(other =>
|
||||
other.kind === 'ship' && other.answer && other.territory !== leaf.territory)) return null;
|
||||
}
|
||||
@@ -264,9 +327,11 @@ export function* turnActionBranches(promise: TurnActionPromise): Generator<TurnA
|
||||
const action = node.action;
|
||||
const achieved = actionValue(action, promise);
|
||||
if (achieved || actionClosed(action, promise)) { if (achieved === answer) yield []; return; }
|
||||
yield [action.kind === 'payment' ? { ...action, answer,
|
||||
progress: promise.payments.find(payment => payment.recipient === action.recipient)?.amount ?? 0 }
|
||||
: { ...action, answer }];
|
||||
if (action.kind === 'bid') yield [{ ...action, source: promise.preparation!.bid!.source, answer }];
|
||||
else if (action.kind === 'forces') yield [{ ...action, progress: promise.preparation!.revival![action.counter], answer }];
|
||||
else if (action.kind === 'payment') yield [{ ...action, answer,
|
||||
progress: promise.payments.find(payment => payment.recipient === action.recipient)?.amount ?? 0 }];
|
||||
else yield [{ ...action, answer }];
|
||||
return;
|
||||
}
|
||||
const conjunction = node.kind === 'and' ? answer : !answer;
|
||||
@@ -295,6 +360,73 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
|
||||
? { ...update, fulfilled: true } : update;
|
||||
}
|
||||
|
||||
/** Only an actually accepted ordinary declaration counts for its original lot.
|
||||
* Repeated declarations retain their largest amount, never their sum or invoice. */
|
||||
export function recordTurnActionBid<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, event: TurnActionBid): T[] {
|
||||
check(object(event) && keys(event, 'amount,lot,source') && positive(event.lot) && identifier(event.source) && positive(event.amount),
|
||||
'Retain the original ordinary lot, private source and positive actual accepted bid.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const stage = promise.preparation?.bid;
|
||||
if (!stage || stage.closed || stage.source !== event.source || stage.lot !== event.lot || event.amount <= stage.maximum) continue;
|
||||
updates.set(promise, settle(promise, { ...promise, preparation: { ...promise.preparation!,
|
||||
bid: { ...stage, maximum: event.amount } } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** Close this physical ordinary lot, or one respondent's actual opportunity. */
|
||||
export function closeTurnActionBidding<T extends TurnActionPromise>(promises: readonly T[], turn: number,
|
||||
source: string, player?: string): T[] {
|
||||
check(Array.isArray(promises) && positive(turn) && identifier(source) &&
|
||||
(player === undefined || identifier(player)), 'Choose the original ordinary lot and optional respondent.');
|
||||
for (const promise of promises) check(object(promise), 'Retain an original turn-action record.');
|
||||
return promises.map(promise => {
|
||||
if (promise.turn !== turn || player !== undefined && promise.player !== player || promise.fulfilled || promise.released) return promise;
|
||||
readPromise(promise);
|
||||
const stage = promise.preparation?.bid;
|
||||
if (!stage || stage.closed || stage.source !== source) return promise;
|
||||
return settle(promise, { ...promise, preparation: { ...promise.preparation!, bid: { ...stage, closed: true } } });
|
||||
});
|
||||
}
|
||||
|
||||
/** Completed ordinary native returns only. Card/special/foreign returns must
|
||||
* not call this hook, and each personal identity is recorded only once. */
|
||||
export function recordTurnActionRevival<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, event: RevivalExpressionReturn): T[] {
|
||||
check(object(event) && (event.kind === 'forces' && keys(event, 'amount,elite,kind') && physical(event.amount) &&
|
||||
physical(event.elite) && event.elite <= event.amount ||
|
||||
event.kind === 'leader' && keys(event, 'kind,leader') && identifier(event.leader)),
|
||||
'Retain an actual typed ordinary force return or personal leader identity.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const stage = promise.preparation?.revival;
|
||||
if (!stage || stage.closed || event.kind === 'forces' && event.amount === 0 ||
|
||||
event.kind === 'leader' && stage.leaders.includes(event.leader)) continue;
|
||||
const revival = event.kind === 'forces' ? { ...stage, total: stage.total + event.amount,
|
||||
normal: stage.normal + event.amount - event.elite, elite: stage.elite + event.elite, leaders: [...stage.leaders] } :
|
||||
{ ...stage, leaders: [...stage.leaders, event.leader] };
|
||||
updates.set(promise, settle(promise, { ...promise, preparation: { ...promise.preparation!, revival } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** The respondent's ordinary Revival stage closes independently of transport. */
|
||||
export function closeTurnActionRevival<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number): T[] {
|
||||
const live = new Set(liveTurnActionPromises(promises, player, turn));
|
||||
return promises.map(promise => {
|
||||
if (!live.has(promise)) return promise;
|
||||
readPromise(promise);
|
||||
const stage = promise.preparation?.revival;
|
||||
if (!stage || stage.closed) return promise;
|
||||
return settle(promise, { ...promise, preparation: { ...promise.preparation!, revival: { ...stage, closed: true } } });
|
||||
});
|
||||
}
|
||||
|
||||
/** Exactly one original completed future reserve shipment or actual skip. */
|
||||
export function recordTurnActionShipment<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, event: TravelEvent | null): T[] {
|
||||
@@ -379,7 +511,10 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
||||
if (!live.has(promise)) return promise;
|
||||
readPromise(promise);
|
||||
const closed = { ...promise, transportClosed: true, paymentClosed: true,
|
||||
shipment: { ...promise.shipment, pending: false } };
|
||||
shipment: { ...promise.shipment, pending: false }, ...(promise.preparation ? { preparation: {
|
||||
bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null,
|
||||
revival: promise.preparation.revival ? { ...promise.preparation.revival, closed: true } : null,
|
||||
} } : {}) };
|
||||
const fulfilled = turnActionValue(closed) === promise.answer;
|
||||
return { ...closed, fulfilled, released: !fulfilled };
|
||||
});
|
||||
@@ -393,6 +528,9 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
for (const promise of promises) {
|
||||
const compiled = readPromise(promise);
|
||||
const satisfied = evaluate(compiled, promise);
|
||||
const allClosed = promise.transportClosed && promise.paymentClosed &&
|
||||
(!promise.preparation?.bid || promise.preparation.bid.closed) &&
|
||||
(!promise.preparation?.revival || promise.preparation.revival.closed);
|
||||
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||||
? node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player
|
||||
: node.kind === 'fact' || node.terms.every(recipientsValid);
|
||||
@@ -401,7 +539,7 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(promise.released || (promise.answer || !satisfied) &&
|
||||
(!promise.answer || promise.fulfilled === satisfied) &&
|
||||
(!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) &&
|
||||
(!(promise.transportClosed && promise.paymentClosed) || promise.fulfilled)),
|
||||
(!allClosed || promise.fulfilled)),
|
||||
'The turn-action commitment lost its event, participants or whole-answer progress.');
|
||||
events.add(promise.event);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { RevivalExpressionLeaf } from '../game/revival-expression-promises';
|
||||
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthBid, createTruthBidFixture, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer,
|
||||
type TruthBidStep,
|
||||
} from './fixture-truth-bid';
|
||||
import { createTruthMixedRevivalFixture, nextTruthMixedRevival } from './fixture-truth-mixed-revival';
|
||||
import { createTruthRevivalEarlyFixture, reachTruthRevivalEarlyOpportunity } from './fixture-truth-revival-early';
|
||||
import type { TruthTurnActionClaim } from './fixture-truth-turn-action';
|
||||
|
||||
export type TruthTurnPreparationLeaf = TruthTurnActionClaim | { kind: 'bid'; lot: number; minimum: number }
|
||||
| RevivalExpressionLeaf;
|
||||
export type TruthTurnPreparationExpression = MixedActionExpression<TruthTurnPreparationLeaf>;
|
||||
export type TruthTurnPreparationStep = TruthBidStep;
|
||||
export const actTruthTurnPreparation = actTruthBid;
|
||||
export const truthTurnPreparationInventory = truthBidInventory;
|
||||
export const truthTurnPreparationPlayer = truthBidPlayer;
|
||||
export const truthTurnPreparationClean = (game: Game) => truthBidClean(game) && !game.pendingShipment && !game.pendingRevival;
|
||||
export const truthTurnPreparationFact = (fact: TruthFact): TruthTurnPreparationExpression => ({ kind: 'fact', fact });
|
||||
export const truthTurnPreparationBid = (lot = 1, minimum = 3): TruthTurnPreparationExpression =>
|
||||
({ kind: 'action', action: { kind: 'bid', lot, minimum } });
|
||||
export const truthTurnPreparationForce = (minimum = 2, counter: 'total' | 'normal' | 'elite' = 'total'):
|
||||
TruthTurnPreparationExpression => ({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
||||
export const truthTurnPreparationLeader = (leader: string): TruthTurnPreparationExpression =>
|
||||
({ kind: 'action', action: { kind: 'leader', leader } });
|
||||
export const truthTurnPreparationPayment = (recipient: string, minimum = 2): TruthTurnPreparationExpression =>
|
||||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
export const truthTurnPreparationShip = (territory = 'polar_sink', minimum = 1): TruthTurnPreparationExpression =>
|
||||
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
|
||||
export const truthTurnPreparationGroup = (kind: 'and' | 'or', ...terms: TruthTurnPreparationExpression[]):
|
||||
TruthTurnPreparationExpression => ({ kind, terms });
|
||||
|
||||
/** Existing genuinely dealt two-Truth sources only. The bid adapter adds a
|
||||
* real Harkonnen bid and Atreides pass so the Emperor can declare while a real
|
||||
* opposing bidder still owns the next turn. Force casualties and Hidar's death
|
||||
* remain the source's actual shipment/Storm and original projectile history. */
|
||||
export function createTruthTurnPreparationFixture(options: {
|
||||
source?: 'bid' | 'forces' | 'leader'; advanced?: boolean; mixed?: TruthTurnPreparationExpression;
|
||||
beforeRevival?: boolean; targetKarama?: boolean;
|
||||
} = {}) {
|
||||
const sourceKind = options.source ?? 'bid';
|
||||
const bidSource = sourceKind === 'bid' ? createTruthBidFixture({ window: 'waiting', advanced: options.advanced, targetKarama: options.targetKarama }) : null;
|
||||
const forceSource = sourceKind === 'forces' ? createTruthRevivalEarlyFixture({ phase: 3, advanced: options.advanced, targetKarama: options.targetKarama }) : null;
|
||||
const leaderSource = sourceKind === 'leader' ? createTruthMixedRevivalFixture({ advanced: options.advanced }) : null;
|
||||
const original = bidSource ?? forceSource ?? leaderSource!;
|
||||
const actions = structuredClone(original.sourceActions), inheritedCount = actions.length;
|
||||
let game = structuredClone(original.game);
|
||||
if (bidSource) {
|
||||
game = actTruthTurnPreparation(game, bidSource.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
|
||||
game = actTruthTurnPreparation(game, bidSource.opponent, { type: 'passBid' }, actions);
|
||||
assert.equal(game.auction?.active, bidSource.target); assert.ok(!game.auction?.passed.includes(bidSource.asker));
|
||||
}
|
||||
if (forceSource && !options.beforeRevival) game = reachTruthRevivalEarlyOpportunity(game, forceSource.target, actions);
|
||||
const asker = original.asker, target = original.target, recipient = asker;
|
||||
const observer = game.players.find(candidate => candidate.id !== asker && candidate.id !== target)!.id;
|
||||
const truths = structuredClone(original.truths), lot = game.auction ? game.auction.index + 1 : null;
|
||||
const mixed = structuredClone(options.mixed ?? (bidSource
|
||||
? truthTurnPreparationGroup('and', truthTurnPreparationBid(lot!), truthTurnPreparationPayment(recipient), truthTurnPreparationShip())
|
||||
: forceSource ? truthTurnPreparationGroup('and', truthTurnPreparationForce(), truthTurnPreparationPayment(recipient))
|
||||
: truthTurnPreparationGroup('and', truthTurnPreparationLeader(leaderSource!.leader), truthTurnPreparationPayment(recipient))));
|
||||
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
|
||||
assert.ok(truthTurnPreparationClean(game)); assert.equal(game.turnActionPromises?.length ?? 0, 0);
|
||||
assert.ok(truths.every(truth => truthBidPlayer(game, asker).hand.some(card => card.id === truth)));
|
||||
assert.deepEqual(truthBidInventory(game), original.originalCards);
|
||||
return {
|
||||
game, source: structuredClone(game), sourceKind, bidSource, forceSource, leaderSource,
|
||||
asker, target, recipient, observer, truths, truth: truths[0], lot, mixed, question,
|
||||
setup: original.setup, afterSetup: original.afterSetup, beforeStartingDeal: original.beforeStartingDeal,
|
||||
originalCards: original.originalCards, originalSpice: original.originalSpice,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { inherited: inheritedCount, nativeContinuation: actions.length - inheritedCount, selected: actions.length },
|
||||
bid: { type: 'bid', amount: 3, allyPayment: 0 } satisfies Action,
|
||||
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
|
||||
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
forceAction: { type: 'revive', amount: 1, elite: 0 } satisfies Action,
|
||||
leader: leaderSource?.leader ?? null,
|
||||
leaderAction: leaderSource ? structuredClone(leaderSource.revivalAction) : null,
|
||||
sourceDisclosure: `${original.sourceDisclosure} This adapter retains only native recorded continuations and changes the raw question. ` +
|
||||
'Bid maxima describe actual future declarations in this original ordinary lot, and Revival counts describe actual ordinary future returns. ' +
|
||||
'No played hand, card, cash, force, death, clock, promise, frozen fact or event progress is installed.',
|
||||
};
|
||||
}
|
||||
export type TruthTurnPreparationFixture = ReturnType<typeof createTruthTurnPreparationFixture>;
|
||||
|
||||
export function declareTruthTurnPreparation(f: TruthTurnPreparationFixture, source = f.game, copy: 0 | 1 = 0,
|
||||
actions: TruthTurnPreparationStep[] = []) {
|
||||
const before = structuredClone(source), from = actions.length;
|
||||
let game = actTruthTurnPreparation(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
|
||||
game = actTruthTurnPreparation(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, before, actions, actionCount: actions.length - from };
|
||||
}
|
||||
export function askTruthTurnPreparation(game: Game, asker: string, target: string, mixed: TruthTurnPreparationExpression,
|
||||
actions: TruthTurnPreparationStep[] = []): Game {
|
||||
return actTruthTurnPreparation(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthTurnPreparation(game: Game, target: string, answer: 'yes' | 'no',
|
||||
actions: TruthTurnPreparationStep[] = []): Game {
|
||||
return actTruthTurnPreparation(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthTurnPreparation(f: TruthTurnPreparationFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
|
||||
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnPreparationStep[] = []) {
|
||||
const from = actions.length, declared = declareTruthTurnPreparation(f, source, copy, actions);
|
||||
const asked = askTruthTurnPreparation(declared.game, f.asker, f.target, mixed, actions);
|
||||
const game = answerTruthTurnPreparation(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length - from };
|
||||
}
|
||||
export function truthTurnPreparationPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
|
||||
const promise = viewGame(game, target).turnActionPromises.find(row => row.player === target); assert.ok(promise); return promise;
|
||||
}
|
||||
export function nextTruthTurnPreparationNative(game: Game): TruthTurnPreparationStep | null {
|
||||
return game.expansions.includes('ix') ? nextTruthMixedRevival(game) : nextTruthBid(game);
|
||||
}
|
||||
export function nextTruthTurnPreparation(game: Game): TruthTurnPreparationStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const guide = viewGame(game, player.id).turnActionGuide;
|
||||
if (guide) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
return nextTruthTurnPreparationNative(game);
|
||||
}
|
||||
export function advanceTruthTurnPreparation(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthTurnPreparationStep[] = [], nativeOnly = false): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original native preparation source ended before its requested boundary');
|
||||
const step = nativeOnly ? nextTruthTurnPreparationNative(game) : nextTruthTurnPreparation(game);
|
||||
assert.ok(step, 'Supply the real current Truthtrance question or answer before continuing');
|
||||
game = actTruthTurnPreparation(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The genuine preparation source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthTurnPreparation(game: Game, actions: TruthTurnPreparationStep[] = []): Game {
|
||||
return advanceTruthTurnPreparation(game, truthTurnPreparationClean, actions);
|
||||
}
|
||||
export function finishTruthTurnPreparation(game: Game, actions: TruthTurnPreparationStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthTurnPreparation(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnPreparationClean(state), actions);
|
||||
}
|
||||
@@ -21,6 +21,10 @@ const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression =
|
||||
const physical = (kind: 'ship' | 'move' = 'ship', minimum = 1, territory = 'carthag'): Expression =>
|
||||
({ kind: 'action', action: { kind, territory, minimum } });
|
||||
const payment = (minimum = 3, recipient = 'r'): Expression => ({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
const bid = (minimum = 3, lot = 2): Expression => ({ kind: 'action', action: { kind: 'bid', lot, minimum } });
|
||||
const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum = 1): Expression =>
|
||||
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
||||
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
||||
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
|
||||
|
||||
function elements(node: ReactNode): Element[] {
|
||||
@@ -66,6 +70,7 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
|
||||
}
|
||||
function auction(next: Action = { type: 'passBid' }): GameView {
|
||||
const game = projected(3, next);
|
||||
game.bidPromiseLot = { turn: 2, lot: 2 };
|
||||
game.auction = { bid: 7, bidder: next.type === 'decision' ? 'p' : 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
|
||||
if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; }
|
||||
return game;
|
||||
@@ -121,15 +126,20 @@ void test('turn-action editor keeps all action families and optional facts in th
|
||||
});
|
||||
|
||||
void test('turn-action editor preserves sixteen clauses and four shared grouping levels without truncation', () => {
|
||||
const e = editor({ kind: 'or', terms: [payment(), physical('move', 4)] });
|
||||
for (let count = 3; count <= 16; count++) { click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count); }
|
||||
const game = auction(), e = editor({ kind: 'or', terms: [payment(), physical('move', 4)] }, false, game);
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count);
|
||||
if (count % 4 === 0) select(e.tree(), `1.${count}`, 'action', 'forces');
|
||||
if (count % 4 === 1) select(e.tree(), `1.${count}`, 'action', 'leader');
|
||||
if (count % 4 === 2) select(e.tree(), `1.${count}`, 'action', 'bid');
|
||||
}
|
||||
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/);
|
||||
const full = structuredClone(e.value), changes = e.changes;
|
||||
for (const key of ['add-action-1', 'add-fact-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||||
}
|
||||
assert.deepEqual(e.value, full); assert.equal(e.changes, changes);
|
||||
const deep = editor({ kind: 'and', terms: [fact(), payment()] });
|
||||
const deep = editor({ kind: 'and', terms: [fact(), forceRevival()] });
|
||||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||||
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||||
assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true);
|
||||
@@ -138,7 +148,7 @@ void test('turn-action editor preserves sixteen clauses and four shared grouping
|
||||
});
|
||||
|
||||
void test('turn-action inputs keep invalid drafts repairable and reject disabled callbacks', () => {
|
||||
for (const expression of [physical(), payment()]) {
|
||||
for (const expression of [physical(), payment(), forceRevival()]) {
|
||||
for (const invalid of [NaN, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const e = editor(expression); minimum(e.tree(), '1', invalid); assert.ok(e.error());
|
||||
if (!Number.isFinite(invalid)) assert.match(e.html(), /value=""/);
|
||||
@@ -173,6 +183,74 @@ void test('turn-action fields use public recipients and printed destinations wit
|
||||
assert.match(editor(physical(), false, game).html(), /Carthag/);
|
||||
});
|
||||
|
||||
void test('turn-action bid leaves use only the current public ordinary lot and retain repairable drafts', () => {
|
||||
const game = auction(), e = editor(payment(), false, game);
|
||||
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden lot face inspection'); }, configurable: true });
|
||||
select(e.tree(), '1', 'action', 'bid'); assert.deepEqual(e.value, bid()); assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Turn 2 · Ordinary lot 2/);
|
||||
for (const invalid of [NaN, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
minimum(e.tree(), '1', invalid); assert.ok(e.error()); minimum(e.tree(), '1', 3); assert.equal(e.error(), null);
|
||||
}
|
||||
minimum(e.tree(), '1', Number.MAX_SAFE_INTEGER); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1', 'lot', ''); assert.ok(e.error()); assert.deepEqual(e.value, bid(Number.MAX_SAFE_INTEGER, NaN));
|
||||
select(e.tree(), '1', 'lot', '999'); assert.deepEqual(e.value, bid(Number.MAX_SAFE_INTEGER, NaN));
|
||||
select(e.tree(), '1', 'lot', '2'); assert.equal(e.error(), null);
|
||||
game.bidPromiseLot = { turn: 2, lot: 3 }; assert.ok(e.error()); assert.match(e.html(), /aria-invalid="true"/);
|
||||
assert.deepEqual(elements(find(e.tree(), 'id', 'truth-turn-action-1-lot')).filter(element => element.type === 'option')
|
||||
.map(element => element.props.value), ['', 3]);
|
||||
select(e.tree(), '1', 'lot', '3'); assert.equal(e.error(), null);
|
||||
game.bidPromiseLot = { turn: 1, lot: 3 }; assert.ok(e.error()); assert.match(e.html(), /No public ordinary lot is currently available/);
|
||||
game.bidPromiseLot = null; assert.ok(e.error()); select(e.tree(), '1', 'action', 'forces');
|
||||
minimum(e.tree(), '1', 2); assert.equal(e.error(), null);
|
||||
});
|
||||
|
||||
void test('turn-action Revival leaves use public faction names and physical counters without casualties, custody or private preparation', () => {
|
||||
const game = auction(); game.advanced = true; game.players.find(player => player.id === 'p')!.faction = 'atreides';
|
||||
const ownLeader = game.allLeaders.find(leader => leader.faction === 'atreides'); assert.ok(ownLeader);
|
||||
const foreignLeader = game.allLeaders.find(leader => leader.faction === 'emperor'); assert.ok(foreignLeader);
|
||||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'leaders', 'tanks', 'capturedLeaders'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
Object.defineProperty(game, 'revival', { get() { throw Error('No private Revival eligibility inspection'); }, configurable: true });
|
||||
const e = editor(forceRevival('total', 20), false, game); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1', 'counter', 'normal'); assert.deepEqual(e.value, forceRevival('normal', 20));
|
||||
select(e.tree(), '1', 'counter', 'elite'); assert.deepEqual(e.value, forceRevival('elite', 20));
|
||||
minimum(e.tree(), '1', 21); assert.ok(e.error()); minimum(e.tree(), '1', 1); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1', 'action', 'leader'); assert.deepEqual(e.value, leaderRevival(ownLeader.id));
|
||||
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-leader')).filter(element => element.type === 'option')
|
||||
.map(element => element.props.value);
|
||||
assert.ok(choices.includes('kwisatz')); assert.ok(choices.includes(ownLeader.id)); assert.ok(!choices.includes(foreignLeader.id));
|
||||
select(e.tree(), '1', 'leader', foreignLeader.id); assert.deepEqual(e.value, leaderRevival(ownLeader.id));
|
||||
assert.ok(turnActionInputError(leaderRevival(foreignLeader.id), game, 'p'));
|
||||
select(e.tree(), '1', 'leader', ''); assert.ok(e.error()); select(e.tree(), '1', 'leader', 'kwisatz'); assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Kwisatz Haderach/); game.advanced = false; assert.ok(e.error());
|
||||
select(e.tree(), '1', 'leader', ownLeader.id); assert.equal(e.error(), null);
|
||||
for (const initial of [bid(), forceRevival(), leaderRevival(ownLeader.id)]) {
|
||||
const frozen = editor(initial, true, game), original = structuredClone(frozen.value);
|
||||
select(frozen.tree(), '1', 'action', 'payment');
|
||||
if (initial.kind === 'action' && initial.action.kind === 'bid') { select(frozen.tree(), '1', 'lot', ''); minimum(frozen.tree(), '1', 7); }
|
||||
if (initial.kind === 'action' && initial.action.kind === 'forces') { select(frozen.tree(), '1', 'counter', 'elite'); minimum(frozen.tree(), '1', 7); }
|
||||
if (initial.kind === 'action' && initial.action.kind === 'leader') select(frozen.tree(), '1', 'leader', '');
|
||||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, original);
|
||||
}
|
||||
});
|
||||
|
||||
void test('turn-action preparation history and guides use original public leaves without reading private stage progress', () => {
|
||||
const game = auction({ type: 'bid', amount: 8, allyPayment: 0 }), named = game.allLeaders.find(leader => leader.faction === 'emperor');
|
||||
assert.ok(named);
|
||||
game.turnActionPromises[0].mixed = { kind: 'and', terms: [bid(8), forceRevival('elite', 2), leaderRevival(named.id), payment(), physical(), fact()] };
|
||||
for (const key of ['preparation', 'compiled', 'progress', 'payments'])
|
||||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
const html = markup(game);
|
||||
assert.match(html, /ordinary lot 2/); assert.match(html, /8 spice/); assert.match(html, /elite forces/); assert.match(html, new RegExp(named.name));
|
||||
assert.match(html, /Bid conditions close with their named ordinary lot/); assert.match(html, /Revival conditions close with your native Revival opportunity/);
|
||||
assert.doesNotMatch(html, /compiled|bidProgress|revivalProgress/);
|
||||
const sent: Action[] = []; press(guide({ game, busy: false, act(value) { sent.push(value); } }));
|
||||
assert.equal(sent[0], game.turnActionGuide!.action);
|
||||
const answerGame = answering(3); answerGame.truthtrance!.question = { kind: 'turnActions', target: 'p', turn: 2, mixed: game.turnActionPromises[0].mixed };
|
||||
const answerMarkup = renderToStaticMarkup(createElement(TurnActionAnswers, { game: answerGame, busy: false, act() {} }));
|
||||
assert.match(answerMarkup, /ordinary force returns add together/); assert.match(answerMarkup, /Bids close with their lot/);
|
||||
});
|
||||
|
||||
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
|
||||
const game = projected(); game.turnActionGuide = null;
|
||||
game.turnActionPromises.push(
|
||||
@@ -181,13 +259,14 @@ void test('turn-action history retains original own whole questions and terminal
|
||||
{ ...game.turnActionPromises[0], event: 'controlled-release', released: true },
|
||||
{ ...game.turnActionPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), physical('ship', 19, 'sietch_tabr')] } },
|
||||
);
|
||||
for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress'])
|
||||
for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress', 'preparation', 'prep', 'bids', 'returns', 'leaders'])
|
||||
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
const html = markup(game);
|
||||
for (const text of [/Turn 1/, /You answered Yes/, /You answered No/, /you hold Shield/, / OR /, / AND /,
|
||||
/Fulfilled: this whole commitment is complete/, /Released: this whole commitment is no longer possible/,
|
||||
/No negates the whole expression, not each condition separately/, /Separate No answers each create their own restriction/,
|
||||
/maximum physical forces in one actual future move/, /Individual fact answers and any reduced future branch remain private/,
|
||||
/highest accepted future declaration/, /cumulative future ordinary returns/, /Revival conditions close with your native Revival opportunity/,
|
||||
/ordinary payments remain possible until the actual turn ends/]) assert.match(html, text);
|
||||
assert.doesNotMatch(html, /Poison Blade|19 physical|controlled-foreign|controlled-complete|factAnswers/);
|
||||
assert.equal(TurnActionPromises({ game: { ...game, me: 'missing' }, busy: false, act() {} }), null);
|
||||
@@ -293,6 +372,7 @@ void test('turn-action native guides reject stale auction, invoice, Revival and
|
||||
[() => auction({ type: 'bid', amount: 8, allyPayment: 0 }), [
|
||||
game => { game.auction = { ...game.auction! }; }, game => { game.auction!.active = 'a'; },
|
||||
game => { game.auction!.bid = 8; }, game => { game.auction!.remaining = 1; },
|
||||
game => { game.bidPromiseLot = { turn: 2, lot: 3 }; },
|
||||
]],
|
||||
[() => auction({ type: 'decision', karama: false }), [
|
||||
game => { game.decision = { ...game.decision! }; }, game => { game.decision = null; },
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthTurnPreparation as act, advanceTruthTurnPreparation as advance, answerTruthTurnPreparation as answer,
|
||||
askTruthTurnPreparation as ask, bindTruthTurnPreparation as bind, createTruthTurnPreparationFixture as fixture,
|
||||
declareTruthTurnPreparation as declare, finishTruthTurnPreparation as finish,
|
||||
nextTruthTurnPreparationNative as nativeStep, settleTruthTurnPreparation as settle,
|
||||
truthTurnPreparationBid as bid, truthTurnPreparationClean as clean, truthTurnPreparationForce as force,
|
||||
truthTurnPreparationGroup as group, truthTurnPreparationInventory as inventory, truthTurnPreparationLeader as leader,
|
||||
truthTurnPreparationPayment as payment, truthTurnPreparationPlayer as player, truthTurnPreparationPromise as promise,
|
||||
type TruthTurnPreparationFixture, type TruthTurnPreparationStep,
|
||||
} from './fixture-truth-turn-preparation';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function physical(game: Game, f: TruthTurnPreparationFixture) {
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
function closeLot(game: Game, lot: number, actions: TruthTurnPreparationStep[] = []) {
|
||||
return advance(game, state => state.phase !== 3 || !state.auction || state.auction.index + 1 !== lot, actions, true);
|
||||
}
|
||||
|
||||
void test('native preparation adapters retain original two-Truth deals and programmatic source prefixes', () => {
|
||||
for (const source of ['bid', 'forces', 'leader'] as const) {
|
||||
const f = fixture({ source });
|
||||
assert.equal(f.setup.status, 'setup'); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2);
|
||||
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(f.sourceCounts.selected, f.sourceActionCount);
|
||||
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.sourceActionCount);
|
||||
assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.equal(f.game.truthHistory?.length ?? 0, 0);
|
||||
assert.deepEqual(inventory(f.game), f.originalCards);
|
||||
if (source === 'bid') {
|
||||
assert.equal(f.sourceActionCount, 22); assert.equal(f.sourceCounts.nativeContinuation, 2);
|
||||
assert.equal(f.game.phase, 3); assert.equal(player(f.game, f.target).spice, 10);
|
||||
assert.equal(f.game.auction?.bidder, f.asker); assert.equal(f.game.auction?.active, f.target);
|
||||
} else {
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4); assert.equal(player(f.game, f.target).spice, 4);
|
||||
assert.equal(player(f.game, f.target).tanks, source === 'forces' ? 3 : 1);
|
||||
if (source === 'leader') {
|
||||
assert.equal(f.sourceActionCount, 102); assert.equal(f.originalCards.length, 47);
|
||||
assert.equal(player(f.game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('native AND declares a real bid before its invoice and shares current cash with payment and later shipment', () => {
|
||||
const f = fixture(), actions: TruthTurnPreparationStep[] = [];
|
||||
let game = bind(f, 'yes', f.mixed, f.game, 0, actions).game;
|
||||
rejected(game, f.target, { type: 'passBid' });
|
||||
game = act(game, f.target, f.payment, actions); assert.equal(player(game, f.target).spice, 8);
|
||||
game = act(game, f.target, f.bid, actions);
|
||||
assert.equal(player(game, f.target).spice, 8, 'Actual declaration records a bid without paying the later invoice');
|
||||
assert.equal(game.auction?.active, f.asker); assert.equal(game.auction?.bidder, f.target);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 3);
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
|
||||
assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.target).reserves, 20);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.bidSource!.lotCard));
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
game = settle(act(game, f.target, f.shipment, actions), actions);
|
||||
assert.equal(player(game, f.target).spice, 3); assert.equal(player(game, f.target).reserves, 19);
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(player(game, f.recipient).bribes, 2);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
assert.ok(actions.some(step => step.action.type === 'bid'));
|
||||
// This original winner holds no Karama choice: the cash-only invoice
|
||||
// settles automatically. The real card and three-spice debit above prove it.
|
||||
});
|
||||
|
||||
void test('native bid OR payment Yes preserves both manual routes and bid declarations are independent of an eventual invoice', () => {
|
||||
const f = fixture(), mixed = group('or', bid(f.lot!), payment(f.recipient)), bound = bind(f, 'yes', mixed).game;
|
||||
for (const action of [f.bid, f.payment]) {
|
||||
const game = act(bound, f.target, action);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, action.type === 'bid' ? 3 : 0);
|
||||
assert.equal(player(game, f.target).spice, action.type === 'bid' ? 10 : 8);
|
||||
assert.equal(player(game, f.recipient).bribes, action.type === 'bribe' ? 2 : 0); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native pass and actual lot close fold a missing bid false while the ordinary payment OR survives', () => {
|
||||
const f = fixture(), mixed = group('or', bid(f.lot!), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
game = act(game, f.target, { type: 'passBid' }); game = closeLot(game, f.lot!);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.bid?.closed, true);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 0);
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'bribe'); assert.equal(guide.action.turnActionPromisePhase, game.phase);
|
||||
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
for (const first of ['bid', 'payment'] as const)
|
||||
void test(`native whole AND No permits its ${first} event and rejects only completion of the whole bid/payment tree`, () => {
|
||||
const f = fixture(), mixed = group('and', bid(f.lot!), payment(f.recipient));
|
||||
let game = bind(f, 'no', mixed).game;
|
||||
game = act(game, f.target, first === 'bid' ? f.bid : f.payment);
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
|
||||
rejected(game, f.target, first === 'bid' ? f.payment : f.bid);
|
||||
if (first === 'payment') game = act(game, f.target, { type: 'passBid' });
|
||||
game = closeLot(game, f.lot!); assert.ok(!promise(game, f.target).released);
|
||||
if (first === 'bid') {
|
||||
game = act(game, f.target, { ...f.payment, amount: 1 });
|
||||
rejected(game, f.target, { ...f.payment, amount: 1 });
|
||||
}
|
||||
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native repeated ordinary declarations retain the largest bid rather than adding bid amounts', () => {
|
||||
const f = fixture(), mixed = group('and', bid(f.lot!, 5), payment(f.recipient));
|
||||
let game = bind(f, 'no', mixed).game;
|
||||
game = act(game, f.target, f.payment);
|
||||
game = act(game, f.target, { ...f.bid, amount: 2 });
|
||||
game = act(game, f.asker, { type: 'bid', amount: 3, allyPayment: 0 });
|
||||
assert.equal(game.auction?.active, f.bidSource!.opponent);
|
||||
game = act(game, f.bidSource!.opponent, { type: 'passBid' });
|
||||
game = act(game, f.target, { ...f.bid, amount: 4 });
|
||||
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 4);
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
|
||||
game = closeLot(game, f.lot!); game = finish(game);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native bid No survives a pass reset and reentry until the same physical lot closes', () => {
|
||||
const f = fixture(); let game = bind(f, 'no', bid(f.lot!, 5)).game;
|
||||
game = act(game, f.target, { ...f.bid, amount: 2 });
|
||||
game = act(game, f.asker, { type: 'passBid' });
|
||||
game = act(game, f.observer, { type: 'bid', amount: 3, allyPayment: 0 });
|
||||
assert.equal(game.auction?.active, f.target);
|
||||
game = act(game, f.target, { type: 'passBid' });
|
||||
assert.equal(game.turnActionPromises![0].preparation?.bid?.closed, false);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = act(game, f.asker, { type: 'bid', amount: 4, allyPayment: 0 });
|
||||
game = act(game, f.observer, { type: 'passBid' });
|
||||
assert.equal(game.auction?.active, f.target); assert.ok(!game.auction?.passed.includes(f.target));
|
||||
rejected(game, f.target, { ...f.bid, amount: 5 });
|
||||
game = closeLot(game, f.lot!);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native one question jointly funds its original bid force return and ordinary payment', () => {
|
||||
const f = fixture({ source: 'forces', beforeRevival: true }), actions: TruthTurnPreparationStep[] = [];
|
||||
assert.equal(f.game.phase, 3); assert.equal(player(f.game, f.target).spice, 4);
|
||||
const mixed = group('and', bid(f.lot!, 1), force(2), payment(f.recipient, 1));
|
||||
let game = bind(f, 'yes', mixed, f.game, 0, actions).game;
|
||||
game = advance(game, state => promise(state, f.target).fulfilled, actions);
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
assert.equal(player(game, f.recipient).bribes, 1);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 1);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
|
||||
assert.equal(player(game, f.target).reserves, 19); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native Revival Karama invoice guide waits for the actual owned payment decision', () => {
|
||||
const f = fixture({ source: 'forces', beforeRevival: true, targetKarama: true }), actions: TruthTurnPreparationStep[] = [];
|
||||
let game = advance(f.game, state => state.auction?.active === f.asker, actions, true);
|
||||
game = act(game, f.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
|
||||
assert.equal(game.auction?.active, f.observer);
|
||||
game = act(game, f.observer, { type: 'passBid' }, actions);
|
||||
assert.equal(game.auction?.active, f.target);
|
||||
const karama = player(game, f.target).hand.find(card => card.effect === 'karama')!; assert.ok(karama);
|
||||
game = bind(f, 'yes', force(3), game, 0, actions).game;
|
||||
game = act(game, f.target, { type: 'bid', amount: 2, allyPayment: 0 }, actions);
|
||||
assert.equal(game.auction?.active, f.asker); assert.equal(game.decision, null);
|
||||
assert.equal(viewGame(game, f.target).turnActionGuide, null);
|
||||
game = advance(game, state => state.decision?.kind === 'auctionPayment' && state.decision.player === f.target, actions, true);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'decision'); assert.equal(guide.action.karama, true); assert.equal(guide.action.card, karama.id);
|
||||
game = act(game, f.target, guide.action, actions);
|
||||
assert.equal(player(game, f.target).spice, 4); assert.ok(game.discard.some(card => card.id === karama.id));
|
||||
game = advance(game, state => promise(state, f.target).fulfilled, actions);
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).reserves, 20);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 3); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native owned Karama overbid preserves original cash for its required future ordinary Revival', () => {
|
||||
const f = fixture({ source: 'forces', beforeRevival: true, targetKarama: true }), actions: TruthTurnPreparationStep[] = [];
|
||||
let game = advance(f.game, state => state.auction?.active === f.asker, actions, true);
|
||||
game = act(game, f.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
|
||||
game = act(game, f.observer, { type: 'passBid' }, actions);
|
||||
assert.equal(player(game, f.target).spice, 4);
|
||||
const karama = player(game, f.target).hand.find(card => card.effect === 'karama')!; assert.ok(karama);
|
||||
game = bind(f, 'yes', group('and', bid(f.lot!, 10), force(3)), game, 0, actions).game;
|
||||
game = act(game, f.target, { type: 'bid', amount: 10, allyPayment: 0 }, actions);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 10);
|
||||
assert.equal(player(game, f.target).spice, 4); assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = advance(game, state => promise(state, f.target).fulfilled, actions);
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).reserves, 20);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 3);
|
||||
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
|
||||
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.karama === true));
|
||||
physical(game, f);
|
||||
});
|
||||
|
||||
void test('native cumulative typed force returns and payment share one original four-spice purse before own Revival Ready', () => {
|
||||
const f = fixture({ source: 'forces' }); let game = bind(f).game;
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
game = settle(act(game, f.target, f.forceAction));
|
||||
assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.target).reserves, 18);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 1);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.normal, 1);
|
||||
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 2);
|
||||
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.forceAction));
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).reserves, 19);
|
||||
assert.equal(player(game, f.target).tanks, 1);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.normal, 2);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native force OR payment Yes may finish its actual Revival opportunity and pay in late Mentat', () => {
|
||||
const f = fixture({ source: 'forces' }), mixed = group('or', force(), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target));
|
||||
// Basic No retains its cap until phase close if a later Truth reopens Ready.
|
||||
// The actual Ready flag currently prevents another ordinary return.
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.closed, false);
|
||||
assert.equal(player(game, f.target).revived, 0); assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.closed, true);
|
||||
assert.ok(!promise(game, f.target).released); rejected(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target)); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native Advanced ordered Revival finish closes only its own preparation stage before a later payment', () => {
|
||||
const f = fixture({ source: 'forces', advanced: true }), mixed = group('or', force(2), payment(f.recipient));
|
||||
assert.ok(f.game.advanced); assert.ok(f.game.revivalOrder); assert.equal(f.game.active, f.target);
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
game = act(game, f.target, { type: 'ready' });
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.closed, true);
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!game.revivalOrder?.remaining.includes(f.target));
|
||||
game = act(game, f.target, f.payment);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 2);
|
||||
physical(game, f);
|
||||
});
|
||||
|
||||
void test('native personal leader and free force returns retain actual income and costs alongside an ordinary payment', () => {
|
||||
const f = fixture({ source: 'leader' }); assert.ok(f.leader && f.leaderAction);
|
||||
const mixed = group('and', leader(f.leader), force(1), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
|
||||
assert.equal(player(game, f.target).spice, 2); game = settle(act(game, f.target, f.forceAction));
|
||||
assert.equal(player(game, f.target).spice, 3); assert.equal(player(game, f.target).tanks, 0);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 1);
|
||||
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.leaderAction));
|
||||
assert.equal(player(game, f.target).spice, 1);
|
||||
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
|
||||
assert.deepEqual(game.turnActionPromises![0].preparation?.revival?.leaders, [f.leader]);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native ordinary force returns before answering do not count toward the new preparation interval', () => {
|
||||
const f = fixture({ source: 'leader' }); assert.ok(f.leader && f.leaderAction);
|
||||
let game = settle(act(f.game, f.target, f.forceAction));
|
||||
assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).tanks, 0);
|
||||
assert.equal(player(game, f.target).spice, 5);
|
||||
const mixed = group('and', group('or', force(1), leader(f.leader)), payment(f.recipient));
|
||||
game = bind(f, 'yes', mixed, game).game;
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 0);
|
||||
assert.deepEqual(game.turnActionPromises![0].preparation?.revival?.leaders, []);
|
||||
game = act(game, f.target, f.payment); assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.leaderAction));
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 0);
|
||||
assert.deepEqual(game.turnActionPromises![0].preparation?.revival?.leaders, [f.leader]);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native preparation guides keep exact phase/event packets and raw own history without private preparation progress', () => {
|
||||
const f = fixture({ source: 'forces' }), declared = declare(f);
|
||||
let game = answer(ask(declared.game, f.asker, f.target, f.mixed), f.target, 'yes');
|
||||
const row = promise(game, f.target), allowed = ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'];
|
||||
assert.ok(Object.keys(row).every(key => allowed.includes(key)));
|
||||
assert.deepEqual(row.mixed, f.mixed); assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
|
||||
for (const actor of [f.asker, f.observer]) {
|
||||
assert.deepEqual(viewGame(game, actor).turnActionPromises, []); assert.equal(viewGame(game, actor).turnActionGuide, null);
|
||||
}
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
|
||||
assert.equal(guide.action.turnActionPromisePhase, 4);
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-preparation-event' });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 3 });
|
||||
rejected(game, f.target, { ...guide.action, amount: Number(guide.action.amount) + 1 });
|
||||
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
|
||||
rejected(game, f.target, wire);
|
||||
game = advance(game, state => promise(state, f.target).fulfilled === true);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles actually submit current native bid and Revival preparation guides through fulfillment', () => {
|
||||
for (const source of ['bid', 'forces'] as const) {
|
||||
const f = fixture({ source }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthTurnPreparationStep[] = [];
|
||||
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(action, difficulty);
|
||||
assert.equal(action.answer, 'yes'); game = act(game, f.target, action, actions);
|
||||
let guided = 0;
|
||||
for (let count = 0; count < 100 && !promise(game, f.target).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const guide = view.turnActionGuide;
|
||||
if (guide) {
|
||||
const next = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
|
||||
assert.ok(next, `${source}/${difficulty}`); assert.deepEqual(next, guide.action);
|
||||
game = settle(act(game, f.target, next, actions), actions); guided++;
|
||||
} else {
|
||||
const next = nativeStep(game); assert.ok(next, `${source}/${difficulty}`);
|
||||
game = act(game, next.actor, next.action, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, `${source}/${difficulty}`); assert.ok(guided > 0);
|
||||
assert.ok(actions.some(step => step.action.type === (source === 'bid' ? 'bid' : 'revive')));
|
||||
assert.ok(actions.some(step => step.action.type === 'bribe'));
|
||||
if (source === 'bid') assert.ok(actions.some(step => step.action.type === 'ship'));
|
||||
physical(game, f);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
TURN_ACTION_MAX_DEPTH,
|
||||
TURN_ACTION_MAX_LEAVES,
|
||||
TurnActionError,
|
||||
closeTurnActionBidding,
|
||||
closeTurnActionRevival,
|
||||
closeTurnActionTransport,
|
||||
finishTurnActionTurn,
|
||||
liveTurnActionPromises,
|
||||
parseTurnActionLeaf,
|
||||
recordTurnActionBid,
|
||||
recordTurnActionMovement,
|
||||
recordTurnActionPayment,
|
||||
recordTurnActionShipment,
|
||||
recordTurnActionRevival,
|
||||
turnActionBranches,
|
||||
turnActionHasPayments,
|
||||
turnActionValue,
|
||||
@@ -30,6 +34,10 @@ const travel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3): Expr
|
||||
({ kind: 'action', action: { kind, territory, minimum } });
|
||||
const pay = (recipient = 'recipient', minimum = 3): Expression =>
|
||||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
const bid = (minimum = 3, lot = 1): Expression => ({ kind: 'action', action: { kind: 'bid', lot, minimum } });
|
||||
const forces = (minimum = 3, counter: 'total' | 'normal' | 'elite' = 'total'): Expression =>
|
||||
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
||||
const leader = (leader = 'atreides-0'): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
||||
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
|
||||
@@ -47,6 +55,12 @@ function promise(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAc
|
||||
};
|
||||
}
|
||||
const branches = (record: TurnActionPromise) => [...turnActionBranches(record)];
|
||||
function prepared(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
|
||||
return { ...promise(mixed, answer, id), preparation: {
|
||||
bid: { lot: 1, source: 'original-lot:1', maximum: 0, closed: false },
|
||||
revival: { total: 0, normal: 0, elite: 0, leaders: [], closed: false },
|
||||
} };
|
||||
}
|
||||
|
||||
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
|
||||
for (const kind of ['ship', 'move'] as const) {
|
||||
@@ -377,3 +391,169 @@ void test('stored rows reject missing or extra metadata, foreign private leaves
|
||||
{ ...payment(), recipient: '' }, { ...payment(), received: true }])
|
||||
assert.throws(() => recordTurnActionPayment([original], 'respondent', 1, invalid as never), TurnActionError);
|
||||
});
|
||||
|
||||
void test('new preparation leaves reuse strict native bid and typed Revival parsers without changing old rows', () => {
|
||||
for (const leaf of [{ kind: 'bid', lot: 1, minimum: Number.MAX_SAFE_INTEGER },
|
||||
{ kind: 'forces', counter: 'elite', minimum: 20 }, { kind: 'leader', leader: 'atreides-0' }]) {
|
||||
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
|
||||
assert.notEqual(parseTurnActionLeaf(leaf), leaf);
|
||||
}
|
||||
for (const leaf of [{ kind: 'bid', lot: 0, minimum: 1 }, { kind: 'bid', lot: 1, minimum: 0 },
|
||||
{ kind: 'bid', lot: 1, minimum: 1, source: 'hidden' }, { kind: 'bid', lot: 1, minimum: 1, progress: 0 },
|
||||
{ kind: 'forces', counter: 'normal', minimum: 21 }, { kind: 'forces', counter: 'other', minimum: 1 },
|
||||
{ kind: 'forces', counter: 'normal', minimum: 1, amount: 1 },
|
||||
{ kind: 'leader', leader: '' }, { kind: 'leader', leader: 'atreides-0', answer: true }])
|
||||
assert.throws(() => parseTurnActionLeaf(leaf), TurnActionError);
|
||||
const old = promise(group('and', pay(), travel('move')));
|
||||
validateTurnActionPromises([old], seats);
|
||||
assert.equal('preparation' in recordTurnActionPayment([old], 'respondent', 1, payment())[0], false);
|
||||
for (const mixed of [bid(), forces(), leader()])
|
||||
assert.throws(() => turnActionValue(promise(mixed)), TurnActionError);
|
||||
const folded = prepared(group('or', fact(), bid()));
|
||||
delete folded.preparation;
|
||||
assert.throws(() => turnActionValue(folded), TurnActionError, 'Folded original bid leaves still retain their physical source');
|
||||
});
|
||||
|
||||
void test('bid progress retains a maximum accepted declaration from one future original lot and answer interval', () => {
|
||||
const original = prepared(group('and', bid(7), pay()));
|
||||
const once = recordTurnActionBid([original], 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 3 });
|
||||
const twice = recordTurnActionBid(once, 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 5 });
|
||||
assert.equal(twice[0].preparation!.bid!.maximum, 5);
|
||||
assert.equal(turnActionValue(twice[0]), false, 'Three and five never combine into an eight-spice bid');
|
||||
assert.deepEqual(branches(twice[0]), [[{ kind: 'bid', lot: 1, source: 'original-lot:1', minimum: 7, answer: true }, signedPay()]]);
|
||||
for (const event of [{ lot: 1, source: 'other-lot', amount: 9 }, { lot: 2, source: 'original-lot:1', amount: 9 },
|
||||
{ lot: 1, source: 'original-lot:1', amount: 4 }])
|
||||
assert.equal(recordTurnActionBid(twice, 'respondent', 1, event)[0], twice[0]);
|
||||
const fresh = prepared(group('and', bid(7), pay()), true, 'turn-action:2');
|
||||
const combined = recordTurnActionBid([twice[0], fresh], 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 4 });
|
||||
assert.equal(combined[0].preparation!.bid!.maximum, 5);
|
||||
assert.equal(combined[1].preparation!.bid!.maximum, 4);
|
||||
const accepted = recordTurnActionBid(combined, 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 7 });
|
||||
assert.deepEqual(branches(accepted[0]), [[signedPay()]]);
|
||||
assert.equal(original.preparation!.bid!.maximum, 0);
|
||||
assert.equal(recordTurnActionBid(accepted, 'other', 1, { lot: 1, source: 'original-lot:1', amount: 20 })[0], accepted[0]);
|
||||
assert.equal(recordTurnActionBid(accepted, 'respondent', 2, { lot: 1, source: 'original-lot:1', amount: 20 })[0], accepted[0]);
|
||||
});
|
||||
|
||||
void test('ordinary Revival accumulates typed physical returns and records each personal leader identity once', () => {
|
||||
const original = prepared(group('and', forces(5), forces(3, 'normal'), forces(2, 'elite'), leader(), pay()));
|
||||
const once = recordTurnActionRevival([original], 'respondent', 1, { kind: 'forces', amount: 3, elite: 1 });
|
||||
assert.deepEqual(once[0].preparation!.revival, { total: 3, normal: 2, elite: 1, leaders: [], closed: false });
|
||||
assert.deepEqual(branches(once[0]), [[
|
||||
{ kind: 'forces', counter: 'total', minimum: 5, progress: 3, answer: true },
|
||||
{ kind: 'forces', counter: 'normal', minimum: 3, progress: 2, answer: true },
|
||||
{ kind: 'forces', counter: 'elite', minimum: 2, progress: 1, answer: true },
|
||||
{ kind: 'leader', leader: 'atreides-0', answer: true }, signedPay(),
|
||||
]]);
|
||||
const twice = recordTurnActionRevival(once, 'respondent', 1, { kind: 'forces', amount: 2, elite: 1 });
|
||||
const returned = recordTurnActionRevival(twice, 'respondent', 1, { kind: 'leader', leader: 'atreides-0' });
|
||||
assert.deepEqual(returned[0].preparation!.revival, { total: 5, normal: 3, elite: 2, leaders: ['atreides-0'], closed: false });
|
||||
assert.equal(recordTurnActionRevival(returned, 'respondent', 1, { kind: 'leader', leader: 'atreides-0' })[0], returned[0]);
|
||||
assert.deepEqual(branches(returned[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(returned, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.equal(original.preparation!.revival!.total, 0);
|
||||
const fresh = prepared(group('and', forces(5), pay()), true, 'turn-action:2');
|
||||
const intervals = recordTurnActionRevival([returned[0], fresh], 'respondent', 1, { kind: 'forces', amount: 1, elite: 0 });
|
||||
assert.equal(intervals[0].preparation!.revival!.total, 6);
|
||||
assert.equal(intervals[1].preparation!.revival!.total, 1);
|
||||
});
|
||||
|
||||
void test('cross-preparation whole No uses De Morgan and rejects the actual event completing its aggregate', () => {
|
||||
const original = prepared(group('and', bid(4), group('or', forces(2), leader())), false);
|
||||
assert.deepEqual(branches(original), [[{ kind: 'bid', lot: 1, source: 'original-lot:1', minimum: 4, answer: false }],
|
||||
[{ kind: 'forces', counter: 'total', minimum: 2, progress: 0, answer: false },
|
||||
{ kind: 'leader', leader: 'atreides-0', answer: false }]]);
|
||||
const accepted = recordTurnActionBid([original], 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 4 });
|
||||
const before = structuredClone(accepted);
|
||||
assert.throws(() => recordTurnActionRevival(accepted, 'respondent', 1, { kind: 'forces', amount: 2, elite: 0 }), TurnActionError);
|
||||
assert.throws(() => recordTurnActionRevival(accepted, 'respondent', 1, { kind: 'leader', leader: 'atreides-0' }), TurnActionError);
|
||||
assert.deepEqual(accepted, before);
|
||||
const orNo = prepared(group('or', bid(4), leader()), false);
|
||||
assert.throws(() => recordTurnActionBid([orNo], 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 4 }), TurnActionError);
|
||||
assert.throws(() => recordTurnActionRevival([orNo], 'respondent', 1, { kind: 'leader', leader: 'atreides-0' }), TurnActionError);
|
||||
});
|
||||
|
||||
void test('Bidding and Revival close independently while transport closure preserves both preparation alternatives', () => {
|
||||
const original = prepared(group('or', bid(), forces(), pay()));
|
||||
const transport = closeTurnActionTransport([original], 'respondent', 1);
|
||||
assert.equal(transport[0].preparation!.bid!.closed, false);
|
||||
assert.equal(transport[0].preparation!.revival!.closed, false);
|
||||
const wrong = closeTurnActionBidding(transport, 1, 'other-lot');
|
||||
assert.equal(wrong[0], transport[0]);
|
||||
assert.equal(closeTurnActionBidding(transport, 1, 'original-lot:1', 'other')[0], transport[0]);
|
||||
const bidding = closeTurnActionBidding(transport, 1, 'original-lot:1', 'respondent');
|
||||
assert.equal(bidding[0].preparation!.bid!.closed, true);
|
||||
assert.equal(bidding[0].preparation!.revival!.closed, false);
|
||||
const revival = closeTurnActionRevival(bidding, 'respondent', 1);
|
||||
assert.deepEqual(branches(revival[0]), [[signedPay()]]);
|
||||
assert.equal(revival[0].released, false);
|
||||
assert.equal(recordTurnActionBid(revival, 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 20 })[0], revival[0]);
|
||||
assert.equal(recordTurnActionRevival(revival, 'respondent', 1, { kind: 'forces', amount: 20, elite: 0 })[0], revival[0]);
|
||||
const no = prepared(group('and', bid(), leader()), false);
|
||||
const bidClosed = closeTurnActionBidding([no], 1, 'original-lot:1');
|
||||
assert.equal(bidClosed[0].fulfilled, false);
|
||||
assert.equal(closeTurnActionRevival(bidClosed, 'respondent', 1)[0].fulfilled, true);
|
||||
const paidOnlyClosed = { ...original, transportClosed: true, paymentClosed: true,
|
||||
shipment: { pending: false, event: null } };
|
||||
validateTurnActionPromises([paidOnlyClosed], seats);
|
||||
});
|
||||
|
||||
void test('same-source bid and force bounds merge strongest thresholds without combining distinct typed counters', () => {
|
||||
const mixed = group('and', bid(3), bid(5), forces(2, 'normal'), forces(4, 'normal'), forces(1, 'elite'), leader(), leader());
|
||||
assert.deepEqual(branches(prepared(mixed)), [[
|
||||
{ kind: 'bid', lot: 1, source: 'original-lot:1', minimum: 5, answer: true },
|
||||
{ kind: 'forces', counter: 'normal', minimum: 4, progress: 0, answer: true },
|
||||
{ kind: 'forces', counter: 'elite', minimum: 1, progress: 0, answer: true },
|
||||
{ kind: 'leader', leader: 'atreides-0', answer: true },
|
||||
]]);
|
||||
const no = prepared(group('or', bid(3), bid(5), forces(2), forces(4)), false);
|
||||
assert.deepEqual(branches(no), [[
|
||||
{ kind: 'bid', lot: 1, source: 'original-lot:1', minimum: 3, answer: false },
|
||||
{ kind: 'forces', counter: 'total', minimum: 2, progress: 0, answer: false },
|
||||
]]);
|
||||
});
|
||||
|
||||
void test('full expiry closes every preparation stage while terminal rows preserve their original progress and deadlines', () => {
|
||||
const original = prepared(group('and', bid(), forces(), leader(), pay()));
|
||||
const no = prepared(group('and', bid(), forces(), leader(), pay()), false, 'turn-action:2');
|
||||
const finished = finishTurnActionTurn([original, no], 'respondent', 1);
|
||||
assert.equal(finished[0].released, true);
|
||||
assert.equal(finished[1].fulfilled, true);
|
||||
for (const row of finished) {
|
||||
assert.equal(row.preparation!.bid!.closed, true);
|
||||
assert.equal(row.preparation!.revival!.closed, true);
|
||||
}
|
||||
for (const transform of [
|
||||
(rows: TurnActionPromise[]) => recordTurnActionBid(rows, 'respondent', 1, { lot: 1, source: 'original-lot:1', amount: 20 }),
|
||||
(rows: TurnActionPromise[]) => recordTurnActionRevival(rows, 'respondent', 1, { kind: 'forces', amount: 20, elite: 0 }),
|
||||
(rows: TurnActionPromise[]) => closeTurnActionBidding(rows, 1, 'original-lot:1'),
|
||||
(rows: TurnActionPromise[]) => closeTurnActionRevival(rows, 'respondent', 1),
|
||||
]) assert.ok(transform(finished).every((row, index) => row === finished[index]));
|
||||
validateTurnActionPromises(finished, seats);
|
||||
});
|
||||
|
||||
void test('preparation metadata and actual events reject foreign sources, mismatched stages and impossible typed totals', () => {
|
||||
const original = prepared(group('and', bid(), forces(), leader()));
|
||||
for (const preparation of [undefined, null, {}, { bid: null, revival: null },
|
||||
{ ...original.preparation, hidden: true },
|
||||
{ ...original.preparation, bid: { ...original.preparation!.bid!, lot: 2 } },
|
||||
{ ...original.preparation, bid: { ...original.preparation!.bid!, source: '' } },
|
||||
{ ...original.preparation, bid: { ...original.preparation!.bid!, maximum: -1 } },
|
||||
{ ...original.preparation, bid: { ...original.preparation!.bid!, maximum: Number.MAX_SAFE_INTEGER + 1 } },
|
||||
{ ...original.preparation, revival: { ...original.preparation!.revival!, total: 2, normal: 1 } },
|
||||
{ ...original.preparation, revival: { ...original.preparation!.revival!, leaders: ['atreides-0', 'atreides-0'] } },
|
||||
{ ...original.preparation, revival: { ...original.preparation!.revival!, closed: 'yes' } }])
|
||||
assert.throws(() => turnActionValue({ ...original, preparation } as TurnActionPromise), TurnActionError);
|
||||
assert.throws(() => turnActionValue(prepared(group('or', bid(1, 1), bid(1, 2)))), TurnActionError);
|
||||
for (const invalid of [{ lot: 0, source: 'original-lot:1', amount: 1 }, { lot: 1, source: '', amount: 1 },
|
||||
{ lot: 1, source: 'original-lot:1', amount: 0 }, { lot: 1, source: 'original-lot:1', amount: 1, answer: true }])
|
||||
assert.throws(() => recordTurnActionBid([original], 'respondent', 1, invalid), TurnActionError);
|
||||
for (const invalid of [{ kind: 'forces', amount: 2, elite: 3 }, { kind: 'forces', amount: 21, elite: 0 },
|
||||
{ kind: 'forces', amount: 2, elite: 0, source: 'ghola' }, { kind: 'leader', leader: 'atreides-0', card: 'ghola' }])
|
||||
assert.throws(() => recordTurnActionRevival([original], 'respondent', 1, invalid as never), TurnActionError);
|
||||
const full = { ...original, preparation: { ...original.preparation!,
|
||||
revival: { total: 20, normal: 20, elite: 0, leaders: [], closed: false } } };
|
||||
const before = structuredClone(full);
|
||||
assert.throws(() => recordTurnActionRevival([full], 'respondent', 1, { kind: 'forces', amount: 1, elite: 0 }), TurnActionError);
|
||||
assert.deepEqual(full, before);
|
||||
});
|
||||
Reference in new issue
Block a user