Offer Semuta on a Truthtrance shipment promise

This commit is contained in:
Quintine committed 2026-10-12 00:22:28 +11:00
1 parent a487f3e1b3
commit 871e932319
10 files changed
+137 -18

No files matched your search

+6 -7
View File
@@ -13,7 +13,6 @@ import { useId, useState } from 'react';
import { Button } from '@/components/ui/button';
import { shipmentAvailable as targetShipmentAvailable } from '@/game/shipment-opportunity';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { shipmentPromiseModeSupported } from '@/game/shipment-promises';
import {
truthQuestionText,
type TruthFact,
@@ -69,7 +68,7 @@ export function Truthtrance({
const invalidMixed = mixedShipmentInputError(mixed, g, target);
const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause);
const shipmentAvailable =
shipmentPromiseModeSupported(g) &&
g.truthShipmentSupported &&
g.phase === 5 &&
g.active === target &&
targetShipmentAvailable(g, {id:target,shipped:g.players.find(p => p.id === target)?.shipped ?? false}) &&
@@ -358,11 +357,11 @@ export function Truthtrance({
{!shipmentAvailable && (
<p className="notice">
Automatic enforcement currently supports the active
player’s unused shipment in base Basic games and base
Advanced games without Guild or optional modules. Select
that player and finish pending decisions first. Earlier
questions and other rules configurations remain
unfinished.
player’s unused ordinary shipment in base rules or a
classic payer in the Richese Semuta profile. Select
that player and finish pending decisions first.
Native No-Field shipments and other rules configurations
remain unfinished.
</p>
)}
<p className="notice" aria-label="Public grouped question preview">
+13
View File
@@ -29,6 +29,19 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Semuta / Truthtrance shipment promise — 11 October.** A classic payer's ordinary
Truthtrance shipment answer in the Richese Semuta profile now offers the consumed
card; the binding promise and its enforcement are unchanged. The structured
shipment-question scope gained that profile for a non-Richese active payer, and the
availability boundary is now server-projected (`truthShipmentSupported`) so the
human Truthtrance panel and validation share it. Native No-Field shipments and
other module combinations stay automatic. Types/lint pass and 125 affected cases
pass. Eight isolated runtime scenarios cover both answers across four legal AI
profiles; a real 390px control check confirms the shipment question is selectable
for a classic payer and refused for a Richese target, with no horizontal overflow.
No saved store or deployed game was targeted. See
[the source/runtime boundary](SEMUTA_SOURCE_UPDATE_20260907.md).
**Guidance accuracy — 11 October.** Three stale player/developer records were
corrected: the card-inspector Semuta availability text now lists every connected
producer and the approved full-hand exchange; `verification.runtime` marks all ten
+26
View File
@@ -1,5 +1,31 @@
# Dune implementation status
## 11 October 2026 — Semuta with a bound Truthtrance shipment promise
The typed `truthtranceDiscard` receipt already carried a bound promise and the
integrity validator already required it; the Semuta admission check excluded every
promise. Admitting only `kind === 'shipment'` lets a classic payer's ordinary
shipment answer offer its consumed card while leaving battle-plan promises and
every other promise path automatic.
The structured shipment question was still limited to base games, so the Richese
Semuta profile could not even ask one. `shipmentPromiseModeSupported` now admits
that profile for a non-Richese active payer, and the result is projected as
`truthShipmentSupported` so the human Truthtrance panel and the engine validation
use one boundary instead of the client re-deriving it.
Both regressions fail before the change (no offer; question rejected) and pass
after, covering claim and decline, promise preservation and enforcement, public
history, and inventory conservation. Types/lint pass and the 125-case affected
union passes. Eight isolated CLI scenarios cover both answers across four legal AI
profiles. A real 390px check confirms the shipment question is selectable for the
Emperor target and refused for a Richese target, with no horizontal overflow.
Native No-Field shipments and other module combinations remain outside this
boundary. This is local changed-path proof, not complete Semuta or deployed
acceptance. Contracts: [source boundary](SEMUTA_SOURCE_UPDATE_20260907.md),
[discard continuations](SEMUTA_DISCARD_CONTINUATIONS.md).
## 11 October 2026 — Semuta before a reserved Truthtrance question resumes
The typed `truthtranceDiscard` receipt already captured the remaining queue and
+5 -4
View File
@@ -15,10 +15,11 @@ behavior without this Semuta reaction. The table below remains the
broader producer/continuation inventory, not a claim of complete coverage.
A clean definite Truthtrance answer now offers its consumed card even when another
question remains reserved, provided no promise bound this answer. Claim or
all-seat decline leaves public history and the consumed card's custody settled,
then restores the same remaining queue at `ask` once. Shrine-source, queued
shipment and queued battle-plan answers retain their automatic continuation.
question remains reserved, and also when the answer bound a shipment promise in
the supported Richese Semuta profile. Claim or all-seat decline leaves public
history, the consumed card's custody and any bound promise settled, then restores
the same remaining queue at `ask` once. Shrine-source, battle-plan and unsupported
native No-Field shipment answers retain their automatic continuation.
The same preview also pauses a final definite Truthtrance after its
public answer, history and physical card disposal are committed. An
+16 -1
View File
@@ -234,7 +234,7 @@ policy**, not a newly discovered publisher timing rule. Full-hand
Semuta uses the explicit user-approved atomic exchange; its completed
hand count is unchanged. Pending gifts, competing responses,
completed Ornithopter arrivals with Ecaz Ambassador or Moritani Terror,
promise-bound Truthtrance answers, pending battle rewards and other
battle-plan Truthtrance answers, pending battle rewards and other
typed producers retain their automatic continuation; Semuta cannot
claim those events yet.
The completed Box's selected
@@ -245,6 +245,21 @@ separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why
auto-closing only when nobody secretly holds the card would leak
possession. The main game and public expansion gates remain closed.
**11 October 2026 — shipment-promise answer.** A classic payer's ordinary
Truthtrance shipment answer in the Richese Semuta profile now offers the consumed
card. The binding promise, public history and enforcement are unchanged; claim or
all-seat decline leaves the same obligation enforceable. To admit it, the
structured shipment-question scope gained the Richese Semuta profile for a
non-Richese active payer; the availability boundary is now server-projected
(`truthShipmentSupported`) so human controls and validation share it. Native
No-Field shipments and other module combinations stay automatic.
The permanent regressions fail before and pass after. The affected union passes
125 cases and typecheck/lint pass. Eight isolated CLI scenarios cover both answers
across four legal AI profiles, plus a real 390px control check that the shipment
question is selectable for a classic payer and refused for a Richese target. This
is local changed-path evidence, not complete Semuta or deployed acceptance.
**11 October 2026 — queued Truthtrance answer.** An ordinary definite answer now
offers its consumed card while another question remains reserved. Only the
`remaining === null` software restriction changed; promise-bound shipment and
+2 -1
View File
@@ -6678,7 +6678,7 @@ function semutaOfferSupported(
!g.pendingArrivalOverlap))) &&
!(continuation.kind === 'truthtranceDiscard' &&
continuation.consumed.source === undefined &&
continuation.promise === null)) return false;
(continuation.promise === null || continuation.promise.kind === 'shipment'))) return false;
if (continuation.kind === 'battleResolved' &&
!g.lastBattleContext?.mandatoryDiscard) return false;
if ((continuation.kind === 'battleCleanup' ||
@@ -32063,6 +32063,7 @@ export function viewGame(state: Game, id: string) {
battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [],
battleOrder: projectedBattleOrder(g),
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
truthShipmentSupported: shipmentPromiseModeSupported(g),
truthShipmentAnswers:
g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.target === id &&
+3 -3
View File
@@ -1169,7 +1169,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 the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance including a queued ordinary answer, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved; promise-bound shipment or battle-plan answers do not. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive Worthless uses except printed Jubba, elite or concealed Trip returns, inspected/partly ready battles, nested responses, pending rewards and public starts remain unfinished.'
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance including a queued ordinary answer or a supported shipment promise, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment promise; battle-plan answers do not. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive Worthless uses except printed Jubba, elite or concealed Trip returns, inspected/partly ready battles, nested responses, pending rewards and public starts remain unfinished.'
: 'Reference component only: this Richese card’s game actions are not enabled.',
],
...(definition.card.effect === 'semutaDrug'
@@ -1179,7 +1179,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const,
status: 'Partial' as const,
detail:
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, while promise-bound shipment or battle-plan answers stay automatic. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, private discards, nested responses and pending rewards remain unfinished.',
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment promise; battle-plan answers stay automatic. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, private discards, nested responses and pending rewards remain unfinished.',
},
{
area: 'Player controls' as const,
@@ -1197,7 +1197,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation' as const,
status: 'Partial' as const,
detail:
'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance (including a queued ordinary answer), four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources.',
'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance (including a queued ordinary answer or a supported shipment promise), four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources.',
},
{
area: 'Verification' as const,
+17 -1
View File
@@ -150,12 +150,28 @@ export function shipmentClaimText(
export function shipmentPromiseModeSupported(game: {
advanced: boolean;
expansions: readonly unknown[];
players: readonly { faction: string }[];
players: readonly { id?: string; faction: string }[];
techTokens?: unknown;
strongholdCards?: unknown;
nexusCards?: unknown;
homeworlds?: unknown;
active?: string | null;
semutaPreview?: unknown;
leaderSkills?: unknown;
discoveryEnabled?: unknown;
}): boolean {
if (game.semutaPreview === true && game.expansions.length === 1 &&
game.expansions[0] === 'choam' && !game.techTokens &&
!game.strongholdCards && !game.nexusCards && !game.homeworlds &&
!game.leaderSkills && !game.discoveryEnabled &&
game.players.some(player => player.faction === 'richese') &&
game.players.every(player => player.faction === 'richese' ||
player.faction === 'atreides' || player.faction === 'harkonnen' ||
player.faction === 'emperor' || player.faction === 'beneGesserit' ||
player.faction === 'fremen')) {
const target = game.players.find(player => player.id === game.active);
return !!target && target.faction !== 'richese';
}
return (
!game.expansions.length &&
(!game.advanced ||
+1 -1
View File
@@ -201,7 +201,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
!g.decision &&
!g.phaseOpening &&
!g.pendingShipment,
'Structured shipment promises currently support base Basic games and base Advanced games without Guild or optional modules, during the active unused shipment. Finish any pending decision first.',
'Structured shipment promises require a supported active unused shipment: base rules or a classic payer in the Richese Semuta profile. Finish pending decisions first.',
);
try {
if (v.kind === 'mixedShipment') {
+48
View File
@@ -736,6 +736,54 @@ void test('a queued definite Truthtrance answer offers its card before the next
assert.equal(claimed.discard.some(card => card.id === semuta.id), true);
});
for (const answer of ['yes', 'no'] as const) void test(
`Semuta preserves a ${answer} Truthtrance shipment promise through claim and decline`, () => {
const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [];
g.deck.push(hajr);
const truth = take(g, 'truthtrance');
player(g, 'a').hand.push(truth);
Object.assign(g, { active: 'e', order: ['e', 'a', 'r'], movementRemaining: ['e', 'a', 'r'] });
const original = inventory(g);
let state = applyAction(g, 'a', { type: 'card', card: truth.id });
while (state.truthtrance?.stage === 'priority') {
const actor = state.players.find(p => !state.truthtrance!.passed.includes(p.id))!;
state = applyAction(state, actor.id, { type: 'truthPass' });
}
state = applyAction(state, 'a', { type: 'truthAsk',
question: { kind: 'shipment', target: 'e', territory: 'carthag', minimum: 2 } });
const pending = applyAction(state, 'e', { type: 'truthAnswer', answer });
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.shipmentPromises?.length, 1);
assert.equal(pending.shipmentPromises![0].answer, answer === 'yes');
const event = pending.pendingTreacheryDiscard!.batch.event;
let declined = reload(pending);
for (const id of ['e', 'a', 'r'])
declined = applyAction(declined, id, { type: 'semutaPass', event });
const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
for (const resumed of [declined, claimed]) {
assert.deepEqual(resumed.shipmentPromises, pending.shipmentPromises);
assert.deepEqual(resumed.truthHistory, pending.truthHistory);
assert.deepEqual(inventory(resumed), original);
const wrong = answer === 'yes'
? { type: 'endMovement' }
: { type: 'ship', territory: 'carthag', sector: 11, amount: 2 };
assert.throws(() => applyAction(resumed, 'e', wrong));
const legal = answer === 'yes'
? { type: 'ship', territory: 'carthag', sector: 11, amount: 2 }
: { type: 'endMovement' };
const fulfilled = applyAction(resumed, 'e', legal);
assert.equal(fulfilled.shipmentPromises!.length, 1);
assert.equal(fulfilled.shipmentPromises![0].fulfilled, true);
assert.equal(fulfilled.truthHistory!.length, 1);
if (answer === 'yes')
assert.equal(player(fulfilled, 'e').forces['carthag:11'], 2);
assert.deepEqual(inventory(fulfilled), original);
}
assert.equal(player(claimed, 'r').hand.some(card => card.id === truth.id), true);
assert.equal(claimed.discard.some(card => card.id === semuta.id), true);
});
void test('a completed Sapho first-movement reorder offers its public card before changing the active turn', () => {
const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [];