Support Sapho replay and public cache questions
This commit is contained in:
1 parent
7d71647a90
commit
26be6fd8ee
17 files changed
+393
-22
No files matched your search
@@ -29,10 +29,12 @@ During play, **Public deals** lets two to six human seats record terms,
|
|||||||
accept or decline an offer, and agree to release it. Every party must accept,
|
accept or decline an offer, and agree to release it. Every party must accept,
|
||||||
and every pair of parties must be nonallied when the offer is made or accepted.
|
and every pair of parties must be nonallied when the offer is made or accepted.
|
||||||
Terms are visible to the
|
Terms are visible to the
|
||||||
table and rely on players to honor them; the game does not interpret promises
|
table and rely on players to honor free-text terms. An optional immediate spice
|
||||||
or determine impossibility. An optional immediate spice payment is executed
|
payment is executed using the normal bribe rules when the final participant
|
||||||
using the normal bribe rules when the final participant accepts. AI negotiation
|
accepts. A structured payment may instead be due on the current or a later
|
||||||
and enforcement of future promises remain unfinished. See
|
turn; the payer can pay it early or on the due turn. The provisional payment
|
||||||
|
feasibility and expiry rules cover supported cases, while free-text enforcement,
|
||||||
|
broader combinations and AI negotiation remain unfinished. See
|
||||||
[public deals](docs/PUBLIC_DEALS.md).
|
[public deals](docs/PUBLIC_DEALS.md).
|
||||||
|
|
||||||
For the provisional two-person setup, the host enables **Two players, two
|
For the provisional two-person setup, the host enables **Two players, two
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
|
|||||||
stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 },
|
stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 },
|
||||||
spicePeek: { kind: 'spicePeek', value: 'worm' },
|
spicePeek: { kind: 'spicePeek', value: 'worm' },
|
||||||
auctionPeek: { kind: 'auctionPeek', name: 'Shield' },
|
auctionPeek: { kind: 'auctionPeek', name: 'Shield' },
|
||||||
|
cacheLot: { kind: 'cacheLot', name: 'Shield' },
|
||||||
ixPoolPeek: { kind: 'ixPoolPeek', name: 'Shield' },
|
ixPoolPeek: { kind: 'ixPoolPeek', name: 'Shield' },
|
||||||
presciencePeek: { kind: 'presciencePeek', field: 'leader', name: 'None' },
|
presciencePeek: { kind: 'presciencePeek', field: 'leader', name: 'None' },
|
||||||
spice: { kind: 'spice', compare: 'gte', value: 6 },
|
spice: { kind: 'spice', compare: 'gte', value: 6 },
|
||||||
@@ -89,6 +90,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
|
|||||||
<option value="stormForecast">Known storm forecast</option>
|
<option value="stormForecast">Known storm forecast</option>
|
||||||
<option value="spicePeek">Known next Spice Blow card</option>
|
<option value="spicePeek">Known next Spice Blow card</option>
|
||||||
<option value="auctionPeek">Inspected ordinary auction card</option>
|
<option value="auctionPeek">Inspected ordinary auction card</option>
|
||||||
|
<option value="cacheLot">Public Basic Richese cache auction card</option>
|
||||||
<option value="ixPoolPeek">Recorded Ixian inspected auction pool includes a card</option>
|
<option value="ixPoolPeek">Recorded Ixian inspected auction pool includes a card</option>
|
||||||
<option value="presciencePeek">Inspected battle plan field</option>
|
<option value="presciencePeek">Inspected battle plan field</option>
|
||||||
<option value="spice">Current personal spice</option>
|
<option value="spice">Current personal spice</option>
|
||||||
@@ -106,7 +108,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
|
|||||||
<FaceDancerFactFields value={fact} leaders={game.allLeaders} onChange={onChange} /> : fact.kind === 'noFieldValue' ?
|
<FaceDancerFactFields value={fact} leaders={game.allLeaders} onChange={onChange} /> : fact.kind === 'noFieldValue' ?
|
||||||
<NoFieldValueFields value={fact} onChange={onChange} /> : fact.kind === 'forceCount' ?
|
<NoFieldValueFields value={fact} onChange={onChange} /> : fact.kind === 'forceCount' ?
|
||||||
<ForceCountFields value={fact} game={game} player={game.players.find(player => player.id === target)!} onChange={onChange} /> :
|
<ForceCountFields value={fact} game={game} player={game.players.find(player => player.id === target)!} onChange={onChange} /> :
|
||||||
fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' || fact.kind === 'spicePeek' || fact.kind === 'auctionPeek' || fact.kind === 'ixPoolPeek' || fact.kind === 'presciencePeek' ?
|
fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' || fact.kind === 'spicePeek' || fact.kind === 'auctionPeek' || fact.kind === 'cacheLot' || fact.kind === 'ixPoolPeek' || fact.kind === 'presciencePeek' ?
|
||||||
<KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <>
|
<KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <>
|
||||||
<label>
|
<label>
|
||||||
Compare current personal spice
|
Compare current personal spice
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { knowledgeFactInputError, PRESCIENCE_LEADER_NAMES, SPICE_PEEK_VALUES, ty
|
|||||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
|
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' } | { kind: 'spicePeek' } | { kind: 'auctionPeek' } | { kind: 'ixPoolPeek' } | { kind: 'presciencePeek' }>;
|
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' } | { kind: 'spicePeek' } | { kind: 'auctionPeek' } | { kind: 'cacheLot' } | { kind: 'ixPoolPeek' } | { kind: 'presciencePeek' }>;
|
||||||
function isNumericKnowledgeFact(fact: KnowledgeFact): fact is NumericKnowledgeFact {
|
function isNumericKnowledgeFact(fact: KnowledgeFact): fact is NumericKnowledgeFact {
|
||||||
return fact.kind !== 'spicePeek' && fact.kind !== 'auctionPeek' && fact.kind !== 'ixPoolPeek' && fact.kind !== 'presciencePeek' && (fact.kind !== 'prediction' || fact.field === 'turn');
|
return fact.kind !== 'spicePeek' && fact.kind !== 'auctionPeek' && fact.kind !== 'cacheLot' && fact.kind !== 'ixPoolPeek' && fact.kind !== 'presciencePeek' && (fact.kind !== 'prediction' || fact.field === 'turn');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KnowledgeFactFields({
|
export function KnowledgeFactFields({
|
||||||
@@ -72,6 +72,16 @@ export function KnowledgeFactFields({
|
|||||||
</label>
|
</label>
|
||||||
<p className="fine">In Basic Bidding, this asks whether the named card is in the Ixian’s recorded inspected auction pool. If that knowledge is unavailable, the answer is I don’t know. Only the answer is published; the pool itself remains private.</p>
|
<p className="fine">In Basic Bidding, this asks whether the named card is in the Ixian’s recorded inspected auction pool. If that knowledge is unavailable, the answer is I don’t know. Only the answer is published; the pool itself remains private.</p>
|
||||||
</>
|
</>
|
||||||
|
) : value.kind === 'cacheLot' ? (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Public Basic Richese cache auction card
|
||||||
|
<select value={value.name} onChange={(event) => onChange({ kind: 'cacheLot', name: event.target.value })}>
|
||||||
|
{TRUTH_CARD_NAMES.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="fine">This asks about the current public Richese cache auction card in Basic play. If that auction is unavailable, the answer is I don’t know. Only the answer is published.</p>
|
||||||
|
</>
|
||||||
) : value.kind === 'auctionPeek' ? (
|
) : value.kind === 'auctionPeek' ? (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**Truthtrance about a public Basic Richese cache lot — 28 October.
|
||||||
|
Prototyped / Partial.** A named-face question covers the current public cache
|
||||||
|
lot while preserving the separate Atreides inspection question. Single and
|
||||||
|
compound controls and minimal bot answers use the same actual lot. Six related
|
||||||
|
files pass 17 cases, including native cache play, true/false answers and
|
||||||
|
unavailable boundaries. Other special auctions, browser and deployed
|
||||||
|
acceptance remain open. [Scope](TRUTHTRANCE_KNOWLEDGE.md#public-basic-richese-cache-lot--28-october-2026).
|
||||||
|
|
||||||
|
**Sapho replay after the same holder's Last — 28 October. Prototyped /
|
||||||
|
Partial.** The unstarted holder may recover the physical card and use First to
|
||||||
|
replace its own earlier Last, preserving completed turns and the active actor.
|
||||||
|
Five related files pass 44 cases, including Basic real discard, paid Box
|
||||||
|
recovery, Semuta responses and replay; Box possession uses a conserved staging
|
||||||
|
exchange. Other holders' protections and suspended-own-turn replay remain
|
||||||
|
guarded; broader combinations and deployed acceptance are open.
|
||||||
|
[Scope](JUICE_OF_SAPHO_RUNTIME.md#recovered-sapho-after-the-same-holders-last--28-october-2026).
|
||||||
|
|
||||||
**Public-deal payments due on later turns — 28 October. Prototyped /
|
**Public-deal payments due on later turns — 28 October. Prototyped /
|
||||||
Partial.** Players may set a future turn and settle early or on the deadline.
|
Partial.** Players may set a future turn and settle early or on the deadline.
|
||||||
Before that turn the obligation does not reserve current spice or assume future
|
Before that turn the obligation does not reserve current spice or assume future
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Juice of Sapho: finite ordering implementation
|
# Juice of Sapho: finite ordering implementation
|
||||||
|
|
||||||
|
## Recovered Sapho after the same holder's Last — 28 October 2026
|
||||||
|
|
||||||
|
**Prototyped / Partial.** An unstarted holder who previously used Sapho to go
|
||||||
|
last may recover that physical card with Nullentropy Box and use it again to
|
||||||
|
go first among the remaining combined Movement turns. The fresh use removes
|
||||||
|
that same holder's Last protection, preserves completed turns and respects an
|
||||||
|
already-started current actor and the Guild's existing timing. Semuta discard
|
||||||
|
responses still finish through the original continuation. Another holder may
|
||||||
|
not replace the protected Last under this bounded policy. Replaying after one's
|
||||||
|
own started turn was suspended remains unsupported.
|
||||||
|
|
||||||
|
The printed card has no stated once-per-turn limit; choosing how a later use
|
||||||
|
interacts with an earlier protected position is a provisional interpretation,
|
||||||
|
not a publisher clarification. A native Basic game covers Sapho's purchase,
|
||||||
|
Last play, paid Box recovery and First replay; Box possession is staged by a
|
||||||
|
conserving exchange with the unopened cache. Five related files pass 44 cases.
|
||||||
|
Broader combinations, browser and deployed acceptance remain open.
|
||||||
|
|
||||||
## Own started Advanced Charity turn — 27 October 2026
|
## Own started Advanced Charity turn — 27 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** At a clean boundary in ordered Advanced Charity, the
|
**Prototyped / Partial.** At a clean boundary in ordered Advanced Charity, the
|
||||||
@@ -468,8 +486,9 @@ different holder behind the active turn under the selected provisional policy.
|
|||||||
Other ordered actions and competing first/Guild precedence beyond the retained
|
Other ordered actions and competing first/Guild precedence beyond the retained
|
||||||
native choice remain unfinished. The own-started Movement and Revival policies
|
native choice remain unfinished. The own-started Movement and Revival policies
|
||||||
above supersede the earlier blanket split-turn exclusion.
|
above supersede the earlier blanket split-turn exclusion.
|
||||||
Reacquiring and replaying Sapho while existing last protection remains is still
|
The same-holder unstarted Movement replay described above is supported; other
|
||||||
guarded. Silent submission stays simultaneous with its original storm tie order.
|
holders' protected Last and suspended-own-turn replay remain guarded. Silent
|
||||||
|
submission stays simultaneous with its original storm tie order.
|
||||||
|
|
||||||
The battle audit in [the battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) found a real Stone Burner counterexample: a plan admitted with original aggressor tie priority can become allocation-ambiguous after late Sapho. A complete implementation needs the actual allocation continuation (and a usable, possession-independent late intervention boundary), not rejection based on an opponent's hidden plan. No aggressor handler has been enabled around that defect.
|
The battle audit in [the battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) found a real Stone Burner counterexample: a plan admitted with original aggressor tie priority can become allocation-ambiguous after late Sapho. A complete implementation needs the actual allocation continuation (and a usable, possession-independent late intervention boundary), not rejection based on an opponent's hidden plan. No aggressor handler has been enabled around that defect.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Rule decision index
|
# Rule decision index
|
||||||
|
|
||||||
|
## Recovered Sapho after the same holder's Last — 28 October 2026
|
||||||
|
|
||||||
|
The Sapho face does not state a once-per-turn limit. Provisionally, a fresh
|
||||||
|
physical use by the same unstarted holder can replace its earlier Last with
|
||||||
|
First, without taking another turn or changing a started opponent's turn.
|
||||||
|
Competing holders and an already-suspended own turn remain guarded.
|
||||||
|
[Scope](JUICE_OF_SAPHO_RUNTIME.md#recovered-sapho-after-the-same-holders-last--28-october-2026).
|
||||||
|
|
||||||
|
## Truthtrance and a public Basic Richese cache lot — 28 October 2026
|
||||||
|
|
||||||
|
The publisher FAQ permits game-related yes/no questions. The named-face
|
||||||
|
selector for a currently public Basic cache lot and its active-lot lifetime
|
||||||
|
are provisional software interpretations. Existing private Atreides
|
||||||
|
inspection questions retain their distinct meaning.
|
||||||
|
[Scope](TRUTHTRANCE_KNOWLEDGE.md#public-basic-richese-cache-lot--28-october-2026).
|
||||||
|
|
||||||
## Structured public-deal payments due later — 28 October 2026
|
## Structured public-deal payments due later — 28 October 2026
|
||||||
|
|
||||||
The publisher permits legal public agreements but does not define an online
|
The publisher permits legal public agreements but does not define an online
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Truthtrance: recorded predictions and storm knowledge
|
# Truthtrance: recorded predictions and storm knowledge
|
||||||
|
|
||||||
|
## Public Basic Richese cache lot — 28 October 2026
|
||||||
|
|
||||||
|
**Prototyped / Partial.** A separate `{kind:'cacheLot', name}` fact asks whether
|
||||||
|
the currently offered public Basic Richese cache card has a named printed face.
|
||||||
|
Any seated respondent can give a definite answer while that actual lot is
|
||||||
|
active. Missing, completed, Advanced and Black Market lots return unknown.
|
||||||
|
Only the offered card is read, not the other cached cards; the existing
|
||||||
|
Atreides inspection question keeps its original meaning. Single and compound
|
||||||
|
editors use the public name registry and minimal bots use the same answer path.
|
||||||
|
|
||||||
|
The [GF9 FAQ p. 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
|
||||||
|
permits game-related yes/no questions. This selector and its active-lot boundary
|
||||||
|
are a provisional online interpretation; broader special auctions and full
|
||||||
|
Truthtrance acceptance remain open.
|
||||||
|
|
||||||
## Already answered battle Prescience — 28 October 2026
|
## Already answered battle Prescience — 28 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** A current-fact question can compare the leader,
|
**Prototyped / Partial.** A current-fact question can compare the leader,
|
||||||
|
|||||||
+3
-2
@@ -27872,8 +27872,9 @@ function playSapho(g: Game, p: Player, action: Action) {
|
|||||||
const quote=saphoMovementRule(()=>reorderSaphoMovementOrder(context,
|
const quote=saphoMovementRule(()=>reorderSaphoMovementOrder(context,
|
||||||
{event:option.event,player:p.id,mode:option.mode}));
|
{event:option.event,player:p.id,mode:option.mode}));
|
||||||
g.movementRemaining = [...quote.next.remaining];
|
g.movementRemaining = [...quote.next.remaining];
|
||||||
if (option.mode === 'last')
|
g.saphoMovementLast = quote.next.protectedLast
|
||||||
g.saphoMovementLast = { event: context.queue.event, turn: g.turn, player: p.id };
|
? { event: context.queue.event, turn: g.turn, player: quote.next.protectedLast }
|
||||||
|
: null;
|
||||||
const used = discard(g, p, 'richese-juice-of-sapho');
|
const used = discard(g, p, 'richese-juice-of-sapho');
|
||||||
log(
|
log(
|
||||||
g,
|
g,
|
||||||
|
|||||||
+4
-2
@@ -504,6 +504,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.',
|
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.',
|
||||||
'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, provisional first ordering retains an unspent Guild’s native next-boundary take/wait choice; Guild may still act ahead. An already granted current Guild turn cannot be displaced, and a Karama-locked privilege stays locked. Last stays after the Guild even if the Guild later chooses to wait.',
|
'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, provisional first ordering retains an unspent Guild’s native next-boundary take/wait choice; Guild may still act ahead. An already granted current Guild turn cannot be displaced, and a Karama-locked privilege stays locked. Last stays after the Guild even if the Guild later chooses to wait.',
|
||||||
'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. In Basic or Advanced, the holder may also provisionally pause its own started turn and resume last, retaining already spent shipment and movement. Pending card, arrival, payment and shared movement preparations must finish first. Advanced Guild retains its next-boundary timing choice; Guild may voluntarily pause its own granted turn.',
|
'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. In Basic or Advanced, the holder may also provisionally pause its own started turn and resume last, retaining already spent shipment and movement. Pending card, arrival, payment and shared movement preparations must finish first. Advanced Guild retains its next-boundary timing choice; Guild may voluntarily pause its own granted turn.',
|
||||||
|
'If you recover the physical Sapho after choosing Last while your turn remains unstarted, you may use it again to choose First. This provisionally replaces your own Last protection; completed turns and the current actor remain unchanged. Another holder cannot replace your protected Last, and replay after suspending your own started turn remains unsupported.',
|
||||||
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
|
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
|
||||||
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
|
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
|
||||||
'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.',
|
'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.',
|
||||||
@@ -3277,7 +3278,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
steps: [
|
steps: [
|
||||||
'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.',
|
'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.',
|
||||||
'Ask whether Atreides’ currently known next Spice Card is a sandworm, Sandtrout, or a blow at a named printed territory. If the peek was canceled or is no longer available, the answer is I don’t know.',
|
'Ask whether Atreides’ currently known next Spice Card is a sandworm, Sandtrout, or a blow at a named printed territory. If the peek was canceled or is no longer available, the answer is I don’t know.',
|
||||||
'Ask whether the ordinary auction card currently inspected by Atreides has a named Treachery Card face. If the inspection was canceled or the lot is no longer available, the answer is I don’t know. Special auctions and other factions’ occupied Homeworld inspections are not yet included.',
|
'Ask whether the ordinary auction card currently inspected by Atreides has a named Treachery Card face. If the inspection was canceled or the lot is no longer available, the answer is I don’t know. Special-auction inspections and other factions’ occupied Homeworld inspections are not yet included.',
|
||||||
|
'In a Basic Richese cache auction, ask any participant whether the currently offered public cache card has a named face. If no supported cache lot is active, the answer is I don’t know. This is separate from the Atreides inspection question.',
|
||||||
'In Basic Bidding, ask Ixians whether their recorded inspected auction group contains a named card. This refers to the recorded group, including cards later sold, rather than what remains for sale. If the inspection is unavailable or belongs to another player, the answer is I don’t know.',
|
'In Basic Bidding, ask Ixians whether their recorded inspected auction group contains a named card. This refers to the recorded group, including cards later sold, rather than what remains for sale. If the inspection is unavailable or belongs to another player, the answer is I don’t know.',
|
||||||
'Ask an entitled participant whether an already answered Prescience reveals a named leader, weapon or defense, no element, or an exact force dial. A pending, unavailable or differently inspected element yields I don’t know.',
|
'Ask an entitled participant whether an already answered Prescience reveals a named leader, weapon or defense, no element, or an exact force dial. A pending, unavailable or differently inspected element yields I don’t know.',
|
||||||
'These questions describe a choice already recorded or information currently known. They do not require anyone to win on a future turn, change a choice, or make the storm move a promised distance.',
|
'These questions describe a choice already recorded or information currently known. They do not require anyone to win on a future turn, change a choice, or make the storm move a promised distance.',
|
||||||
@@ -3288,7 +3290,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
],
|
],
|
||||||
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
|
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
|
||||||
checklist: [
|
checklist: [
|
||||||
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks, revealed Prescience and the Basic Ixian recorded auction group use shared three-valued fact evaluation; special auction and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks, revealed Prescience, the Basic Ixian recorded auction group and the current public Basic Richese cache face use shared three-valued fact evaluation; other special auctions and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||||
{ area: 'Player controls', status: 'Partial', detail: 'Fact choices, numeric comparisons, public faction and card names, bounded inputs and compound question previews.', evidence: ['components/truthtrance-knowledge.tsx', 'components/mixed-shipment-fields.tsx'] },
|
{ area: 'Player controls', status: 'Partial', detail: 'Fact choices, numeric comparisons, public faction and card names, bounded inputs and compound question previews.', evidence: ['components/truthtrance-knowledge.tsx', 'components/mixed-shipment-fields.tsx'] },
|
||||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles answer the owner-only verified scalar through the existing Truthtrance flow. Strategic question selection and freeform understanding remain incomplete.', evidence: ['game/bots.ts', 'tests/truthtrance-knowledge.test.ts'] },
|
{ area: 'AI', status: 'Partial', detail: 'All four profiles answer the owner-only verified scalar through the existing Truthtrance flow. Strategic question selection and freeform understanding remain incomplete.', evidence: ['game/bots.ts', 'tests/truthtrance-knowledge.test.ts'] },
|
||||||
{ area: 'Documentation', status: 'Partial', detail: 'Current-knowledge semantics, unknown cases and original FAQ authority are explicit.', evidence: ['docs/TRUTHTRANCE_KNOWLEDGE.md'] },
|
{ area: 'Documentation', status: 'Partial', detail: 'Current-knowledge semantics, unknown cases and original FAQ authority are explicit.', evidence: ['docs/TRUTHTRANCE_KNOWLEDGE.md'] },
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ function validate(context: SaphoMovementOrderContext): OrderedOpportunity {
|
|||||||
validateOrderedOpportunity(queue);
|
validateOrderedOpportunity(queue);
|
||||||
requireMovement(queue.current !== null && queue.remaining.length > 0,
|
requireMovement(queue.current !== null && queue.remaining.length > 0,
|
||||||
'The combined movement turns have already finished.');
|
'The combined movement turns have already finished.');
|
||||||
requireMovement(!queue.protectedLast, 'Juice of Sapho already protects the last turn in this phase.');
|
|
||||||
requireMovement(Array.isArray(context.started) && context.started.every(identifier) &&
|
requireMovement(Array.isArray(context.started) && context.started.every(identifier) &&
|
||||||
new Set(context.started).size === context.started.length &&
|
new Set(context.started).size === context.started.length &&
|
||||||
context.started.every(player => queue.eligible.includes(player)),
|
context.started.every(player => queue.eligible.includes(player)),
|
||||||
@@ -71,11 +70,18 @@ export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, re
|
|||||||
mode: 'first' | 'last';
|
mode: 'first' | 'last';
|
||||||
}): { event: string; afterCurrent: string | null; suspended?: string; next: OrderedOpportunity } {
|
}): { event: string; afterCurrent: string | null; suspended?: string; next: OrderedOpportunity } {
|
||||||
return wrap(() => {
|
return wrap(() => {
|
||||||
const queue = validate(context), event = eventFor(queue);
|
const original = validate(context), event = eventFor(original);
|
||||||
requireMovement(request && request.event === event, 'This Sapho movement opportunity has expired.');
|
requireMovement(request && request.event === event, 'This Sapho movement opportunity has expired.');
|
||||||
requireMovement(request.mode === 'first' || request.mode === 'last', 'Choose first or last.');
|
requireMovement(request.mode === 'first' || request.mode === 'last', 'Choose first or last.');
|
||||||
requireMovement(identifier(request.player) && queue.remaining.includes(request.player),
|
requireMovement(identifier(request.player) && original.remaining.includes(request.player),
|
||||||
'Only a pending participant may change its movement position.');
|
'Only a pending participant may change its movement position.');
|
||||||
|
// A recovered physical Sapho may supersede its own earlier Last use.
|
||||||
|
// Another seat cannot replace that protection under this bounded policy.
|
||||||
|
if (original.protectedLast) {
|
||||||
|
requireMovement(original.protectedLast === request.player && request.mode === 'first',
|
||||||
|
'Only the protected player may relinquish their earlier last position.');
|
||||||
|
}
|
||||||
|
const queue = original.protectedLast ? { ...original, protectedLast: null } : original;
|
||||||
if (context.started.includes(request.player) && request.player === queue.current &&
|
if (context.started.includes(request.player) && request.player === queue.current &&
|
||||||
context.allowStartedLast === true) {
|
context.allowStartedLast === true) {
|
||||||
requireMovement(request.mode === 'last', 'An already-started turn can only be moved to last.');
|
requireMovement(request.mode === 'last', 'An already-started turn can only be moved to last.');
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type KnowledgeFact =
|
|||||||
| { kind: 'prediction'; field: 'turn'; compare: Comparison; value: number }
|
| { kind: 'prediction'; field: 'turn'; compare: Comparison; value: number }
|
||||||
| { kind: 'spicePeek'; value: string }
|
| { kind: 'spicePeek'; value: string }
|
||||||
| { kind: 'auctionPeek'; name: string }
|
| { kind: 'auctionPeek'; name: string }
|
||||||
|
| { kind: 'cacheLot'; name: string }
|
||||||
| { kind: 'ixPoolPeek'; name: string }
|
| { kind: 'ixPoolPeek'; name: string }
|
||||||
| { kind: 'stormDial' | 'stormForecast'; compare: Comparison; value: number };
|
| { kind: 'stormDial' | 'stormForecast'; compare: Comparison; value: number };
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ export type TruthKnowledge = {
|
|||||||
stormForecast: number | null;
|
stormForecast: number | null;
|
||||||
spicePeek: string | null;
|
spicePeek: string | null;
|
||||||
auctionPeek: string | null;
|
auctionPeek: string | null;
|
||||||
|
/** The currently offered public Basic Richese cache card, not the cache. */
|
||||||
|
cacheLot?: string | null;
|
||||||
/** The authorized respondent's recorded Ixian group, not remaining lots. */
|
/** The authorized respondent's recorded Ixian group, not remaining lots. */
|
||||||
ixPoolPeek?: readonly string[] | null;
|
ixPoolPeek?: readonly string[] | null;
|
||||||
presciencePeek?: { field: 'leader' | 'weapon' | 'defense' | 'dial'; value: string | number } | null;
|
presciencePeek?: { field: 'leader' | 'weapon' | 'defense' | 'dial'; value: string | number } | null;
|
||||||
@@ -43,7 +46,7 @@ export type TruthKnowledge = {
|
|||||||
export function isKnowledgeFact(value: unknown): value is KnowledgeFact {
|
export function isKnowledgeFact(value: unknown): value is KnowledgeFact {
|
||||||
if (!value || typeof value !== 'object') return false;
|
if (!value || typeof value !== 'object') return false;
|
||||||
const kind = (value as { kind?: unknown }).kind;
|
const kind = (value as { kind?: unknown }).kind;
|
||||||
return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast' || kind === 'spicePeek' || kind === 'auctionPeek' || kind === 'ixPoolPeek' || kind === 'presciencePeek';
|
return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast' || kind === 'spicePeek' || kind === 'auctionPeek' || kind === 'cacheLot' || kind === 'ixPoolPeek' || kind === 'presciencePeek';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function knowledgeFactInputError(value: unknown): string | null {
|
export function knowledgeFactInputError(value: unknown): string | null {
|
||||||
@@ -57,7 +60,7 @@ export function knowledgeFactInputError(value: unknown): string | null {
|
|||||||
return typeof value.name === 'string' && (value.name === 'None' || names.includes(value.name))
|
return typeof value.name === 'string' && (value.name === 'None' || names.includes(value.name))
|
||||||
? null : 'Choose a known printed face or None.';
|
? null : 'Choose a known printed face or None.';
|
||||||
}
|
}
|
||||||
if (value.kind === 'auctionPeek' || value.kind === 'ixPoolPeek')
|
if (value.kind === 'auctionPeek' || value.kind === 'cacheLot' || value.kind === 'ixPoolPeek')
|
||||||
return typeof value.name === 'string' && TRUTH_CARD_NAMES.includes(value.name)
|
return typeof value.name === 'string' && TRUTH_CARD_NAMES.includes(value.name)
|
||||||
? null : 'Choose a known Treachery Card name.';
|
? null : 'Choose a known Treachery Card name.';
|
||||||
if (value.kind === 'spicePeek')
|
if (value.kind === 'spicePeek')
|
||||||
@@ -83,7 +86,7 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
|
|||||||
if (value.kind === 'presciencePeek') return value.field === 'dial'
|
if (value.kind === 'presciencePeek') return value.field === 'dial'
|
||||||
? { kind: value.kind, field: value.field, value: value.value }
|
? { kind: value.kind, field: value.field, value: value.value }
|
||||||
: { kind: value.kind, field: value.field, name: value.name };
|
: { kind: value.kind, field: value.field, name: value.name };
|
||||||
if (value.kind === 'auctionPeek' || value.kind === 'ixPoolPeek') return { kind: value.kind, name: value.name };
|
if (value.kind === 'auctionPeek' || value.kind === 'cacheLot' || value.kind === 'ixPoolPeek') return { kind: value.kind, name: value.name };
|
||||||
if (value.kind === 'spicePeek') return { kind: value.kind, value: value.value };
|
if (value.kind === 'spicePeek') return { kind: value.kind, value: value.value };
|
||||||
if (value.kind === 'prediction' && value.field === 'faction')
|
if (value.kind === 'prediction' && value.field === 'faction')
|
||||||
return { kind: value.kind, field: value.field, faction: value.faction };
|
return { kind: value.kind, field: value.field, faction: value.faction };
|
||||||
@@ -93,7 +96,7 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function truthKnowledgeOf(
|
export function truthKnowledgeOf(
|
||||||
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'ixAuction' | 'ixAuctionKnown' | 'homeworldOccupationPreview' | 'battle' | 'players' | 'dukeVidal'>,
|
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'richeseCache' | 'ixAuction' | 'ixAuctionKnown' | 'homeworldOccupationPreview' | 'battle' | 'players' | 'dukeVidal'>,
|
||||||
p: Pick<Player, 'id' | 'faction' | 'prediction'>,
|
p: Pick<Player, 'id' | 'faction' | 'prediction'>,
|
||||||
): TruthKnowledge {
|
): TruthKnowledge {
|
||||||
const dial = g.stormDials[p.id];
|
const dial = g.stormDials[p.id];
|
||||||
@@ -103,6 +106,11 @@ export function truthKnowledgeOf(
|
|||||||
// The private lot is read only after the same inspection entitlement used by
|
// The private lot is read only after the same inspection entitlement used by
|
||||||
// the ordinary auction view has resolved successfully for this faction.
|
// the ordinary auction view has resolved successfully for this faction.
|
||||||
const lotName = lot?.peekKnown ? lot.cards[lot.index]?.name : undefined;
|
const lotName = lot?.peekKnown ? lot.cards[lot.index]?.name : undefined;
|
||||||
|
// A Basic Richese cache lot is shown to every seat. It is not the owner's
|
||||||
|
// private cache inventory or an Advanced Black Market card.
|
||||||
|
const cacheLot = g.phase === 3 && !g.advanced && g.players.some(seat => seat.id === p.id) &&
|
||||||
|
g.richeseAuction?.source === 'cache' && !g.richeseAuction.outcome
|
||||||
|
? g.richeseCache?.find(card => card.id === g.richeseAuction!.cardId)?.name : undefined;
|
||||||
// Basic native Ixians may answer from their own successful inspection. The
|
// Basic native Ixians may answer from their own successful inspection. The
|
||||||
// record describes a known group, not the currently unsold auction cards.
|
// record describes a known group, not the currently unsold auction cards.
|
||||||
const ixKnown = g.phase === 3 && !g.advanced && p.faction === 'ixians'
|
const ixKnown = g.phase === 3 && !g.advanced && p.faction === 'ixians'
|
||||||
@@ -150,6 +158,7 @@ export function truthKnowledgeOf(
|
|||||||
? g.stormCard : null,
|
? g.stormCard : null,
|
||||||
spicePeek: peekFace,
|
spicePeek: peekFace,
|
||||||
auctionPeek: typeof lotName === 'string' && lotName.length > 0 ? lotName : null,
|
auctionPeek: typeof lotName === 'string' && lotName.length > 0 ? lotName : null,
|
||||||
|
cacheLot: typeof cacheLot === 'string' && cacheLot.length > 0 ? cacheLot : null,
|
||||||
ixPoolPeek: ixPool,
|
ixPoolPeek: ixPool,
|
||||||
presciencePeek,
|
presciencePeek,
|
||||||
};
|
};
|
||||||
@@ -171,6 +180,9 @@ export function knowledgeFactAnswer(
|
|||||||
if (fact.kind === 'auctionPeek')
|
if (fact.kind === 'auctionPeek')
|
||||||
return knowledge.auctionPeek == null ? 'unknown'
|
return knowledge.auctionPeek == null ? 'unknown'
|
||||||
: knowledge.auctionPeek === fact.name ? 'yes' : 'no';
|
: knowledge.auctionPeek === fact.name ? 'yes' : 'no';
|
||||||
|
if (fact.kind === 'cacheLot')
|
||||||
|
return knowledge.cacheLot == null ? 'unknown'
|
||||||
|
: knowledge.cacheLot === fact.name ? 'yes' : 'no';
|
||||||
if (fact.kind === 'ixPoolPeek')
|
if (fact.kind === 'ixPoolPeek')
|
||||||
return knowledge.ixPoolPeek == null ? 'unknown'
|
return knowledge.ixPoolPeek == null ? 'unknown'
|
||||||
: knowledge.ixPoolPeek.includes(fact.name) ? 'yes' : 'no';
|
: knowledge.ixPoolPeek.includes(fact.name) ? 'yes' : 'no';
|
||||||
@@ -192,6 +204,8 @@ export function knowledgeFactText(fact: KnowledgeFact): string {
|
|||||||
return `the ${fact.field} you observed through battle Prescience is ${fact.field === 'dial' ? fact.value : fact.name}`;
|
return `the ${fact.field} you observed through battle Prescience is ${fact.field === 'dial' ? fact.value : fact.name}`;
|
||||||
if (fact.kind === 'auctionPeek')
|
if (fact.kind === 'auctionPeek')
|
||||||
return `the current auction card you inspected as Atreides is ${fact.name}`;
|
return `the current auction card you inspected as Atreides is ${fact.name}`;
|
||||||
|
if (fact.kind === 'cacheLot')
|
||||||
|
return `the current public Richese cache auction card is ${fact.name}`;
|
||||||
if (fact.kind === 'ixPoolPeek')
|
if (fact.kind === 'ixPoolPeek')
|
||||||
return `your recorded Ixian auction group includes ${fact.name}`;
|
return `your recorded Ixian auction group includes ${fact.name}`;
|
||||||
if (fact.kind === 'spicePeek') {
|
if (fact.kind === 'spicePeek') {
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ void test('started-tail last includes Guild while retaining the active turn and
|
|||||||
assert.equal(quote.next.protectedLast, 'holder');
|
assert.equal(quote.next.protectedLast, 'holder');
|
||||||
assert.equal(quote.next.event, input.queue.event);
|
assert.equal(quote.next.event, input.queue.event);
|
||||||
assert.deepEqual(input, before);
|
assert.deepEqual(input, before);
|
||||||
assert.throws(() => saphoMovementOrderEvent({ ...input, queue: quote.next }), SaphoMovementOrderError);
|
const protectedContext = { ...input, queue: quote.next };
|
||||||
|
const event = saphoMovementOrderEvent(protectedContext);
|
||||||
|
assert.throws(() => reorderSaphoMovementOrder(protectedContext, { event, player: 'later', mode: 'first' }), SaphoMovementOrderError);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('an already started actor cannot use either mode to split its combined turn', () => {
|
void test('an already started actor cannot use either mode to split its combined turn', () => {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError,
|
||||||
|
type SaphoMovementOrderContext } from '../game/sapho-movement-order';
|
||||||
|
import { advanceSaphoStartedMovement, createSaphoStartedMovementFixture, saphoStartedMovementClean,
|
||||||
|
saphoStartedMovementInventory, saphoStartedMovementPlayer, STARTED_MOVEMENT_SAPHO } from './fixture-sapho-started-movement';
|
||||||
|
|
||||||
|
function order(started = false): SaphoMovementOrderContext {
|
||||||
|
return { queue: { event: 'movement:3', eligible: ['done', 'active', 'later', 'owner'],
|
||||||
|
completed: ['done'], remaining: ['active', 'later', 'owner'], current: 'active',
|
||||||
|
currentStarted: started, protectedLast: 'owner' },
|
||||||
|
started: started ? ['done', 'active'] : ['done'], guild: null };
|
||||||
|
}
|
||||||
|
function reorder(context: SaphoMovementOrderContext, player: string, mode: 'first' | 'last') {
|
||||||
|
return reorderSaphoMovementOrder(context, { event: saphoMovementOrderEvent(context), player, mode });
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('a fresh Sapho First relinquishes the same holder’s prior Last protection', () => {
|
||||||
|
for (const started of [false, true]) {
|
||||||
|
const context = order(started), before = structuredClone(context);
|
||||||
|
const quote = reorder(context, 'owner', 'first');
|
||||||
|
assert.deepEqual(quote.next.remaining, started ? ['active', 'owner', 'later'] : ['owner', 'active', 'later']);
|
||||||
|
assert.equal(quote.next.protectedLast, null);
|
||||||
|
assert.deepEqual(quote.next.completed, ['done']);
|
||||||
|
assert.equal(quote.afterCurrent, started ? 'active' : null);
|
||||||
|
assert.deepEqual(context, before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('another seat cannot replace an existing Last, and replay cannot duplicate it', () => {
|
||||||
|
const context = order(), before = structuredClone(context), event = saphoMovementOrderEvent(context);
|
||||||
|
for (const [player, mode] of [['later', 'first'], ['later', 'last'], ['owner', 'last']] as const)
|
||||||
|
assert.throws(() => reorderSaphoMovementOrder(context, { event, player, mode }), SaphoMovementOrderError);
|
||||||
|
assert.throws(() => reorderSaphoMovementOrder(context, { event: 'movement:4', player: 'owner', mode: 'first' }), SaphoMovementOrderError);
|
||||||
|
assert.deepEqual(context, before);
|
||||||
|
const guild = order(); guild.guild = { player: 'active', granted: true, locked: false };
|
||||||
|
assert.throws(() => reorder(guild, 'owner', 'first'), SaphoMovementOrderError);
|
||||||
|
const suspended = order(); suspended.started = ['done', 'owner'];
|
||||||
|
assert.throws(() => saphoMovementOrderEvent(suspended), SaphoMovementOrderError);
|
||||||
|
});
|
||||||
|
|
||||||
|
function movementAction(game: Game, actor: string, mode: 'first' | 'last'): Action {
|
||||||
|
const option = viewGame(game, actor).saphoOptions.find(item => item.scope === 'movement' && item.mode === mode);
|
||||||
|
assert.ok(option, `Sapho ${mode} must be available to its holder`);
|
||||||
|
return { type: 'card', card: STARTED_MOVEMENT_SAPHO, scope: 'movement', event: option.event, mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('paid Box recovers the physical Sapho for First after its own Last in a Basic movement phase', () => {
|
||||||
|
const f = createSaphoStartedMovementFixture();
|
||||||
|
let game = structuredClone(f.beforeShipment);
|
||||||
|
const owner = saphoStartedMovementPlayer(game, f.owner);
|
||||||
|
// Supplemental staging exchanges one unopened cache card for an existing held
|
||||||
|
// card. Setup, the Sapho purchase and the original movement entry are real.
|
||||||
|
const index = game.richeseCache!.findIndex(card => card.effect === 'nullentropyBox');
|
||||||
|
assert.ok(index >= 0 && owner.hand.length > 1);
|
||||||
|
const [box] = game.richeseCache!.splice(index, 1);
|
||||||
|
const exchange = owner.hand.findIndex(card => card.id !== STARTED_MOVEMENT_SAPHO);
|
||||||
|
assert.ok(exchange >= 0); game.richeseCache!.push(owner.hand.splice(exchange, 1)[0]); owner.hand.push(box);
|
||||||
|
assert.ok(owner.spice >= 2); assert.deepEqual(saphoStartedMovementInventory(game), f.originalInventory);
|
||||||
|
const beforeLast = structuredClone(game), last = movementAction(game, f.owner, 'last');
|
||||||
|
game = applyAction(game, f.owner, last);
|
||||||
|
game = advanceSaphoStartedMovement(game, saphoStartedMovementClean);
|
||||||
|
assert.equal(game.saphoMovementLast?.player, f.owner);
|
||||||
|
assert.equal(game.movementRemaining!.at(-1), f.owner);
|
||||||
|
assert.ok(game.discard.some(card => card.id === STARTED_MOVEMENT_SAPHO));
|
||||||
|
assert.equal(saphoStartedMovementPlayer(game, f.owner).spice, owner.spice);
|
||||||
|
const savedQueue = [...game.movementRemaining!], cash = saphoStartedMovementPlayer(game, f.owner).spice;
|
||||||
|
game = applyAction(game, f.owner, { type: 'card', card: box.id });
|
||||||
|
if (game.pendingNullentropy) game = applyAction(game, f.owner, { type: 'decision',
|
||||||
|
event: game.pendingNullentropy.event, card: STARTED_MOVEMENT_SAPHO });
|
||||||
|
game = advanceSaphoStartedMovement(game, saphoStartedMovementClean);
|
||||||
|
assert.equal(saphoStartedMovementPlayer(game, f.owner).spice, cash - 2);
|
||||||
|
assert.ok(saphoStartedMovementPlayer(game, f.owner).hand.some(card => card.id === STARTED_MOVEMENT_SAPHO));
|
||||||
|
assert.deepEqual(game.movementRemaining, savedQueue);
|
||||||
|
const first = movementAction(game, f.owner, 'first');
|
||||||
|
game = applyAction(game, f.owner, first);
|
||||||
|
game = advanceSaphoStartedMovement(game, saphoStartedMovementClean);
|
||||||
|
assert.equal(game.movementRemaining![0], f.owner);
|
||||||
|
assert.equal(game.saphoMovementLast, null);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === STARTED_MOVEMENT_SAPHO).length, 1);
|
||||||
|
assert.deepEqual(game.order, beforeLast.order);
|
||||||
|
assert.deepEqual(saphoStartedMovementInventory(game), f.originalInventory);
|
||||||
|
});
|
||||||
@@ -54,7 +54,7 @@ void test('started self-last needs explicit Basic admission and cannot create a
|
|||||||
reject(alone);
|
reject(alone);
|
||||||
const advanced = context();
|
const advanced = context();
|
||||||
advanced.guild = { player: 'last', granted: false, locked: false };
|
advanced.guild = { player: 'last', granted: false, locked: false };
|
||||||
reject(advanced);
|
assert.equal(request(advanced).next.protectedLast, 'active');
|
||||||
const proposed = request(input);
|
const proposed = request(input);
|
||||||
const already = { ...input, queue: proposed.next, started: ['done', 'active'] };
|
const already = { ...input, queue: proposed.next, started: ['done', 'active'] };
|
||||||
assert.throws(() => saphoMovementOrderEvent(already), SaphoMovementOrderError);
|
assert.throws(() => saphoMovementOrderEvent(already), SaphoMovementOrderError);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
|
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||||
|
import * as jsxRuntime from 'react/jsx-runtime';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import * as board from '../game/board';
|
||||||
|
import * as catalog from '../game/catalog';
|
||||||
|
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||||
|
import * as knowledge from '../game/truthtrance-knowledge';
|
||||||
|
import * as cardNames from '../game/truthtrance-card-names';
|
||||||
|
import { KnowledgeFactFields } from '../components/truthtrance-knowledge';
|
||||||
|
import { TruthFactFields, invalidTruthFact } from '../components/mixed-shipment-fields';
|
||||||
|
import { TruthFactExpressionFields } from '../components/truth-fact-expression-fields';
|
||||||
|
import type { TruthFact } from '../game/truthtrance';
|
||||||
|
|
||||||
|
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||||
|
function elements(node: ReactNode): Element[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap(elements);
|
||||||
|
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||||
|
return [node, ...elements(node.props.children)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('single and compound current facts expose the public cache auction selector', () => {
|
||||||
|
const game = createGame('CACHELOTUI', newPlayer('ask', 'Asker', 'emperor'), false, ['choam']);
|
||||||
|
joinGame(game, newPlayer('target', 'Target', 'richese'));
|
||||||
|
const view = viewGame(game, 'ask');
|
||||||
|
const fact: TruthFact = { kind: 'cacheLot', name: 'Shield' };
|
||||||
|
const single = renderToStaticMarkup(createElement(TruthFactFields, {
|
||||||
|
value: fact, game: view, target: 'target', onChange() {},
|
||||||
|
}));
|
||||||
|
assert.match(single, /value="cacheLot" selected/);
|
||||||
|
assert.match(single, /Public Basic Richese cache auction card/);
|
||||||
|
assert.match(single, /value="Shield" selected/);
|
||||||
|
assert.match(single, /If that auction is unavailable/);
|
||||||
|
const compound: TruthFact = { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] };
|
||||||
|
const grouped = renderToStaticMarkup(createElement(TruthFactExpressionFields, {
|
||||||
|
value: compound, game: view, target: 'target', disabled: false, onChange() {},
|
||||||
|
}));
|
||||||
|
assert.match(grouped, /value="cacheLot" selected/);
|
||||||
|
assert.match(grouped, /Public Basic Richese cache auction card/);
|
||||||
|
assert.match(grouped, /value="Shield" selected/);
|
||||||
|
assert.equal(invalidTruthFact(fact, view, 'target'), false);
|
||||||
|
assert.equal(invalidTruthFact({ kind: 'cacheLot', name: 'not a printed card' }, view, 'target'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('cache card control returns a selected printed name without altering the fact kind', () => {
|
||||||
|
const exports: { KnowledgeFactFields?: typeof KnowledgeFactFields } = {};
|
||||||
|
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/truthtrance-knowledge.tsx', import.meta.url), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
||||||
|
}).outputText, {
|
||||||
|
exports,
|
||||||
|
require(name: string) {
|
||||||
|
if (name === 'react/jsx-runtime') return jsxRuntime;
|
||||||
|
if (name === 'react') return { useId: () => 'cache-lot-test' };
|
||||||
|
if (name === '@/game/catalog') return catalog;
|
||||||
|
if (name === '@/game/board') return board;
|
||||||
|
if (name === '@/game/truthtrance-knowledge') return knowledge;
|
||||||
|
if (name === '@/game/truthtrance-card-names') return cardNames;
|
||||||
|
if (name === '@/components/ui/input') return { Input: 'input' };
|
||||||
|
throw new Error(`Unexpected import ${name}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(exports.KnowledgeFactFields);
|
||||||
|
let fact: knowledge.KnowledgeFact = { kind: 'cacheLot', name: 'Shield' };
|
||||||
|
const render = () => elements(exports.KnowledgeFactFields!({ value: fact, onChange(value) { fact = value; } }));
|
||||||
|
const select = render().find(element => element.type === 'select');
|
||||||
|
assert.ok(select);
|
||||||
|
const next = cardNames.TRUTH_CARD_NAMES.find(name => name !== 'Shield'); assert.ok(next);
|
||||||
|
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: next } });
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(fact)), { kind: 'cacheLot', name: next });
|
||||||
|
assert.equal(knowledge.knowledgeFactInputError(fact), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
|
||||||
|
import { knowledgeFactAnswer, knowledgeFactText, parseKnowledgeFact, truthKnowledgeOf } from '../game/truthtrance-knowledge';
|
||||||
|
import {
|
||||||
|
advanceTruthtranceRichesePromises as advance,
|
||||||
|
createTruthtranceRichesePromisesFixture as fixture,
|
||||||
|
truthtranceRichesePromisesClean as clean,
|
||||||
|
} from './fixture-truthtrance-richese-promises';
|
||||||
|
|
||||||
|
/** The original fixture orders only unopened cards and advances the actual auction. */
|
||||||
|
function liveCache() {
|
||||||
|
const f = fixture();
|
||||||
|
const game = advance(f.earlySource, state => clean(state) && state.phase === 3 &&
|
||||||
|
state.richeseAuction?.source === 'cache' && !state.richeseAuction.outcome);
|
||||||
|
const card = game.richeseCache!.find(item => item.id === game.richeseAuction!.cardId);
|
||||||
|
assert.ok(card);
|
||||||
|
return { game, card };
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('public cache question validates printed faces and answers from the active lot', () => {
|
||||||
|
const { game, card } = liveCache();
|
||||||
|
const yes = { kind: 'cacheLot', name: card.name } as const;
|
||||||
|
const absent = TRUTH_CARD_NAMES.find(name => name !== card.name); assert.ok(absent);
|
||||||
|
assert.deepEqual(parseKnowledgeFact({ ...yes, extra: true }), yes);
|
||||||
|
assert.throws(() => parseKnowledgeFact({ kind: 'cacheLot', name: 'Not a real card' }));
|
||||||
|
assert.match(knowledgeFactText(yes), /public Richese cache auction card/);
|
||||||
|
for (const player of game.players) {
|
||||||
|
const knowledge = truthKnowledgeOf(game, player);
|
||||||
|
assert.equal(knowledge.cacheLot, card.name);
|
||||||
|
assert.equal(knowledgeFactAnswer(yes, knowledge), 'yes');
|
||||||
|
assert.equal(knowledgeFactAnswer({ kind: 'cacheLot', name: absent }, knowledge), 'no');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('cache lot question becomes unknown outside the supported active Basic public lot', () => {
|
||||||
|
const { game, card } = liveCache();
|
||||||
|
const fact = { kind: 'cacheLot', name: card.name } as const;
|
||||||
|
const outside = [
|
||||||
|
(copy: typeof game) => { copy.phase = 4; },
|
||||||
|
(copy: typeof game) => { copy.advanced = true; },
|
||||||
|
(copy: typeof game) => { copy.richeseAuction = null; },
|
||||||
|
(copy: typeof game) => { copy.richeseAuction!.source = 'blackMarket'; },
|
||||||
|
(copy: typeof game) => { copy.richeseAuction!.outcome = { kind: 'unbid' }; },
|
||||||
|
(copy: typeof game) => { copy.richeseCache = copy.richeseCache!.filter(item => item.id !== card.id); },
|
||||||
|
];
|
||||||
|
for (const change of outside) {
|
||||||
|
const staged = structuredClone(game); change(staged);
|
||||||
|
for (const player of staged.players)
|
||||||
|
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(staged, player)), 'unknown');
|
||||||
|
}
|
||||||
|
assert.equal(knowledgeFactAnswer(fact), 'unknown');
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { viewGame } from '../game/engine';
|
||||||
|
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
|
||||||
|
import {
|
||||||
|
actTruthtranceRichesePromises as act,
|
||||||
|
advanceTruthtranceRichesePromises as advance,
|
||||||
|
askTruthtranceRichesePromises as ask,
|
||||||
|
createTruthtranceRichesePromisesFixture as fixture,
|
||||||
|
passTruthRicheseFreshDiscard,
|
||||||
|
truthtranceRichesePromisesClean as clean,
|
||||||
|
truthtranceRichesePromisesInventory as inventory,
|
||||||
|
} from './fixture-truthtrance-richese-promises';
|
||||||
|
|
||||||
|
/** Actual setup, auction declaration and public cache lot; only unopened deck
|
||||||
|
* order is selected by the shared fixture. */
|
||||||
|
function liveCache() {
|
||||||
|
const f = fixture();
|
||||||
|
const game = advance(f.earlySource, state => clean(state) && state.phase === 3 &&
|
||||||
|
state.richeseAuction?.source === 'cache' && !state.richeseAuction.outcome);
|
||||||
|
const card = game.richeseCache!.find(item => item.id === game.richeseAuction!.cardId);
|
||||||
|
assert.ok(card);
|
||||||
|
return { f, game, card };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const expected of ['yes', 'no'] as const) {
|
||||||
|
void test(`native Basic public cache question answers ${expected} and all bot profiles continue`, () => {
|
||||||
|
const { f, game, card } = liveCache();
|
||||||
|
for (const seat of game.players) assert.equal(viewGame(game, seat.id).richeseAuction?.card?.id, card.id);
|
||||||
|
const name = expected === 'yes' ? card.name : TRUTH_CARD_NAMES.find(face => face !== card.name);
|
||||||
|
assert.ok(name);
|
||||||
|
const originalLot = structuredClone(game.richeseAuction);
|
||||||
|
const asked = ask(structuredClone(game), f, { kind: 'fact', target: f.richese,
|
||||||
|
fact: { kind: 'cacheLot', name } });
|
||||||
|
assert.equal(viewGame(asked, f.richese).truthAnswer, expected);
|
||||||
|
const before = structuredClone(asked);
|
||||||
|
assert.throws(() => act(asked, f.richese, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' }));
|
||||||
|
assert.deepEqual(asked, before);
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const view = viewGame(asked, f.richese);
|
||||||
|
view.players.find(player => player.id === f.richese)!.bot = difficulty;
|
||||||
|
assert.deepEqual(botActions(view), [{ type: 'truthAnswer', answer: expected }]);
|
||||||
|
const done = passTruthRicheseFreshDiscard(act(structuredClone(asked), f.richese,
|
||||||
|
{ type: 'truthAnswer', answer: expected }));
|
||||||
|
assert.equal(done.truthtrance, null);
|
||||||
|
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
|
||||||
|
assert.deepEqual(done.richeseAuction, originalLot);
|
||||||
|
assert.deepEqual(inventory(done), f.originalCards);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user