Offer Semuta after clean private Distrans transfer
This commit is contained in:
1 parent
2ec0422b79
commit
162f01c160
9 files changed
+243
-40
No files matched your search
+67
-2
@@ -1134,6 +1134,15 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'distransDiscard';
|
||||
owner: string;
|
||||
recipient: string;
|
||||
transferred: string;
|
||||
card: string;
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'truthtranceDiscard';
|
||||
consumed: TruthQueueEntry;
|
||||
@@ -4658,6 +4667,20 @@ function stageOrdinaryCardDiscard(g: Game, p: Player, card: Card) {
|
||||
next,
|
||||
);
|
||||
}
|
||||
type DistransDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'distransDiscard' }
|
||||
>;
|
||||
function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) {
|
||||
const owner = getPlayer(g, c.owner), recipient = getPlayer(g, c.recipient);
|
||||
return JSON.stringify({
|
||||
owner: c.owner, recipient: c.recipient, transferred: c.transferred,
|
||||
card: c.card, parent: nullentropyParentSignature(g, c.resume, `distrans:${c.card}`),
|
||||
turn: g.turn, phase: g.phase, active: g.active,
|
||||
ownerHand: owner.hand.map(card => card.id),
|
||||
recipientHand: recipient.hand.map(card => card.id),
|
||||
});
|
||||
}
|
||||
type TruthDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'truthtranceDiscard' }
|
||||
@@ -5053,6 +5076,25 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
: true,
|
||||
'The completed ordinary-card outcome has changed.',
|
||||
);
|
||||
} else if (continuation?.kind === 'distransDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
const owner = g.players.find(p => p.id === c.owner);
|
||||
const recipient = g.players.find(p => p.id === c.recipient);
|
||||
requireRule(
|
||||
owner && recipient && owner !== recipient &&
|
||||
batch.cause === 'distrans' && batch.entries.length === 1 &&
|
||||
entry.publicFace && entry.discardedBy === c.owner &&
|
||||
entry.card.id === c.card &&
|
||||
richeseCardDefinition(entry.card)?.card.effect === 'distrans' &&
|
||||
recipient.hand.filter(card => card.id === c.transferred).length === 1 &&
|
||||
!owner.hand.some(card => card.id === c.transferred || card.id === c.card) &&
|
||||
outside.filter(card => card.id === c.transferred).length === 1 &&
|
||||
!g.discard.some(card => card.id === c.transferred) &&
|
||||
!c.resume.response && !c.resume.decision &&
|
||||
!c.resume.pendingKarama && !c.resume.phaseOpening &&
|
||||
c.stateSignature === distransDiscardSignature(g, c),
|
||||
'The completed private Distrans transfer no longer matches its fresh discard.',
|
||||
);
|
||||
} else if (continuation?.kind === 'truthtranceDiscard') {
|
||||
const c = continuation,
|
||||
entry = batch.entries[0],
|
||||
@@ -5643,8 +5685,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
} else throw new RuleError('Unknown saved discard continuation.');
|
||||
}
|
||||
/** Bounded public single-card reaction after a completed ordinary effect, paid
|
||||
* Box search, or early-ended Ornithopter flight. A completed flight's arrival
|
||||
* may open further entry windows, so that continuation remains automatic. */
|
||||
* Box search, early-ended Ornithopter flight or clean Distrans transfer. A
|
||||
* completed flight's arrival may open further entry windows and stays automatic. */
|
||||
function semutaOfferSupported(
|
||||
g: Game,
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
@@ -5652,7 +5694,11 @@ function semutaOfferSupported(
|
||||
) {
|
||||
if (continuation.kind !== 'ordinaryCardDiscard' &&
|
||||
continuation.kind !== 'nullentropyDiscard' &&
|
||||
continuation.kind !== 'distransDiscard' &&
|
||||
!(continuation.kind === 'ornithopterDiscard' && continuation.source === 'end')) return false;
|
||||
if (continuation.kind === 'distransDiscard' &&
|
||||
(g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false;
|
||||
return g.semutaPreview === true && g.status === 'playing' &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
entries.length === 1 && entries[0].publicFace &&
|
||||
@@ -5815,6 +5861,11 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
g.truthtrance = next.remaining;
|
||||
} else if (next.kind === 'distransDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
} else if (next.kind === 'ornithopterDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
@@ -23697,6 +23748,20 @@ function applyActionInner(
|
||||
`${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`,
|
||||
{ faction: p.faction, name: 'Distrans' },
|
||||
);
|
||||
if (g.semutaPreview && !g.pendingTreacheryDiscard) {
|
||||
const continuation: DistransDiscardContinuation = {
|
||||
kind: 'distransDiscard', owner: p.id, recipient: recipient.id,
|
||||
transferred: result.transferred.id, card: result.discarded.id,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
stateSignature: '',
|
||||
};
|
||||
const entries = [{ card: result.discarded, discardedBy: p.id, publicFace: true }];
|
||||
if (semutaOfferSupported(g, continuation, entries)) {
|
||||
continuation.stateSignature = distransDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, 'distrans', entries, continuation);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
if (t === 'richeseGift') {
|
||||
|
||||
+5
-5
@@ -690,7 +690,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A transfer must preserve binding battle answers, compulsory discards and both sides of a pending Harkonnen hand exchange. The recipient must retain room for its mandatory incoming return. Optional proposals that become unavailable can still finish through their existing decline or no-effect path.',
|
||||
'A different legal transfer can fill or free space for a pending Richese gift. The ordinary Distrans transfer resolves first, and the pending gift rechecks capacity before moving its own card. A canceled alliance gift does not automatically prevent using this separate card effect.',
|
||||
'All four AI profiles use their own legal projected cards. They may give an allied Bene Gesserit or CHOAM a Worthless card, or share a duplicate ordinary weapon, defense or Karama to free a full hand. They do not inspect the recipient’s private cards and preserve existing decision priority.',
|
||||
'Semuta responses to a fresh Distrans discard, mobile acceptance, the unresolved bid and self-transfer cases, and full combined Richese games remain unfinished. The separate clean public ordinary-card Semuta preview does not enable this transfer composition, all remaining Richese cards or expansion starts.',
|
||||
'The explicitly opted-in Semuta preview now offers a neutral all-seat response to the public used Distrans after a clean completed private transfer. The given card remains private and belongs to its recipient; only the used Distrans is claimable. Responses or decisions already in progress, unresolved bid and self-transfer rulings, full combined Richese games and ordinary expansion starts remain unfinished.',
|
||||
],
|
||||
related: [
|
||||
'card-richese-distrans',
|
||||
@@ -1155,7 +1155,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
: definition.card.effect === 'portableSnooper'
|
||||
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
|
||||
: definition.card.effect === 'semutaDrug'
|
||||
? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, a completed paid Box search, or early ending an Ornithopter flight. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically before the saved turn advances. A completed flight with arrival follow-up, other discard producers, full-hand sequencing and normal Richese starts remain unfinished.'
|
||||
? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, a completed paid Box search, early ending an Ornithopter flight, or completing a clean private Distrans transfer. Continue or commit your held Semuta with a free hand slot; only the freshly discarded public card transfers, never the private card given to its recipient. Completed-flight arrival, competing transactions, other discard producers, full-hand sequencing and normal Richese starts remain unfinished.'
|
||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||
],
|
||||
...(definition.card.effect === 'semutaDrug'
|
||||
@@ -1165,7 +1165,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, completed paid Box and early-ended Ornithopter discards with a neutral all-seat offer. Committing held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card, Box or movement-turn parent once. Completed-flight arrival and other typed discard producers and full-hand exchange remain unfinished.',
|
||||
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, completed paid Box, early-ended Ornithopter and completed private Distrans discards with a neutral all-seat offer. Committing held Semuta claims the sole eligible other-player discarded card and discards Semuta. The private Distrans gift remains with its recipient; the other saved effects and movement queue resume once. Completed-flight arrival, competing transactions, other typed producers and full-hand exchange remain unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls' as const,
|
||||
@@ -1183,13 +1183,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Documentation' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'The guide distinguishes bounded public ordinary-card, paid Box and early-ended Ornithopter previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.',
|
||||
'The guide distinguishes bounded public ordinary-card, paid Box, early-ended Ornithopter and clean Distrans previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.',
|
||||
},
|
||||
{
|
||||
area: 'Verification' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover clean ordinary-card, paid Box and early-ended Ornithopter reactions. Other discard producers, full games and live deployment remain unverified.',
|
||||
'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, early-ended Ornithopter and private Distrans reactions. Other discard producers, full games and live deployment remain unverified.',
|
||||
evidence: [
|
||||
'tests/semuta-engine.test.ts',
|
||||
'tests/semuta-recovery.test.ts',
|
||||
|
||||
Reference in new issue
Block a user