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
+3
-2
@@ -27872,8 +27872,9 @@ function playSapho(g: Game, p: Player, action: Action) {
|
||||
const quote=saphoMovementRule(()=>reorderSaphoMovementOrder(context,
|
||||
{event:option.event,player:p.id,mode:option.mode}));
|
||||
g.movementRemaining = [...quote.next.remaining];
|
||||
if (option.mode === 'last')
|
||||
g.saphoMovementLast = { event: context.queue.event, turn: g.turn, player: p.id };
|
||||
g.saphoMovementLast = quote.next.protectedLast
|
||||
? { event: context.queue.event, turn: g.turn, player: quote.next.protectedLast }
|
||||
: null;
|
||||
const used = discard(g, p, 'richese-juice-of-sapho');
|
||||
log(
|
||||
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.',
|
||||
'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.',
|
||||
'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.',
|
||||
'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.',
|
||||
@@ -3277,7 +3278,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.',
|
||||
'Ask whether Atreides’ currently known next Spice Card is a sandworm, Sandtrout, or a blow at a named printed territory. If the peek was canceled or is no longer available, the answer is I don’t know.',
|
||||
'Ask whether the ordinary auction card currently inspected by Atreides has a named Treachery Card face. If the inspection was canceled or the lot is no longer available, the answer is I don’t know. Special 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.',
|
||||
'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.',
|
||||
@@ -3288,7 +3290,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks, revealed Prescience 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: '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'] },
|
||||
|
||||
@@ -27,7 +27,6 @@ function validate(context: SaphoMovementOrderContext): OrderedOpportunity {
|
||||
validateOrderedOpportunity(queue);
|
||||
requireMovement(queue.current !== null && queue.remaining.length > 0,
|
||||
'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) &&
|
||||
new Set(context.started).size === context.started.length &&
|
||||
context.started.every(player => queue.eligible.includes(player)),
|
||||
@@ -71,11 +70,18 @@ export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, re
|
||||
mode: 'first' | 'last';
|
||||
}): { event: string; afterCurrent: string | null; suspended?: string; next: OrderedOpportunity } {
|
||||
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.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.');
|
||||
// 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 &&
|
||||
context.allowStartedLast === true) {
|
||||
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: 'spicePeek'; value: string }
|
||||
| { kind: 'auctionPeek'; name: string }
|
||||
| { kind: 'cacheLot'; name: string }
|
||||
| { kind: 'ixPoolPeek'; name: string }
|
||||
| { kind: 'stormDial' | 'stormForecast'; compare: Comparison; value: number };
|
||||
|
||||
@@ -35,6 +36,8 @@ export type TruthKnowledge = {
|
||||
stormForecast: number | null;
|
||||
spicePeek: 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. */
|
||||
ixPoolPeek?: readonly string[] | 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 {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
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 {
|
||||
@@ -57,7 +60,7 @@ export function knowledgeFactInputError(value: unknown): string | null {
|
||||
return typeof value.name === 'string' && (value.name === 'None' || names.includes(value.name))
|
||||
? 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)
|
||||
? null : 'Choose a known Treachery Card name.';
|
||||
if (value.kind === 'spicePeek')
|
||||
@@ -83,7 +86,7 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
|
||||
if (value.kind === 'presciencePeek') return value.field === 'dial'
|
||||
? { kind: value.kind, field: value.field, value: value.value }
|
||||
: { 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 === 'prediction' && value.field === 'faction')
|
||||
return { kind: value.kind, field: value.field, faction: value.faction };
|
||||
@@ -93,7 +96,7 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
|
||||
}
|
||||
|
||||
export function truthKnowledgeOf(
|
||||
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | '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'>,
|
||||
): TruthKnowledge {
|
||||
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 ordinary auction view has resolved successfully for this faction.
|
||||
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
|
||||
// record describes a known group, not the currently unsold auction cards.
|
||||
const ixKnown = g.phase === 3 && !g.advanced && p.faction === 'ixians'
|
||||
@@ -150,6 +158,7 @@ export function truthKnowledgeOf(
|
||||
? g.stormCard : null,
|
||||
spicePeek: peekFace,
|
||||
auctionPeek: typeof lotName === 'string' && lotName.length > 0 ? lotName : null,
|
||||
cacheLot: typeof cacheLot === 'string' && cacheLot.length > 0 ? cacheLot : null,
|
||||
ixPoolPeek: ixPool,
|
||||
presciencePeek,
|
||||
};
|
||||
@@ -171,6 +180,9 @@ export function knowledgeFactAnswer(
|
||||
if (fact.kind === 'auctionPeek')
|
||||
return knowledge.auctionPeek == null ? 'unknown'
|
||||
: 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')
|
||||
return knowledge.ixPoolPeek == null ? 'unknown'
|
||||
: 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}`;
|
||||
if (fact.kind === 'auctionPeek')
|
||||
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')
|
||||
return `your recorded Ixian auction group includes ${fact.name}`;
|
||||
if (fact.kind === 'spicePeek') {
|
||||
|
||||
Reference in new issue
Block a user