Extend Basic Semuta poison continuation
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 22:56:03 +11:00
1 parent 9c6b2d884b
commit ea3b21f92c
7 files changed
+134 -26

No files matched your search

+14
View File
@@ -17,6 +17,20 @@ open mode gates.
## Current checkpoint and work
**Basic independent cards: Residual Poison and Semuta — 28 October.
Prototyped / Partial.** The existing clean pre-leader continuation offers the
public Poison discard to Semuta while preserving the committed death and the
native preparation. Other inspections, competing parents and module profiles
retain their prior guards. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#residual-poison-in-basic-independent-cards--28-october-2026).
The related twelve-file batch passes 84 cases, including claim/pass and all
four legal bot responses. The new real-action fixture transfers Semuta from
the original cache to a hand; that acquisition is staged, not a played purchase.
**Larger-alliance integration groundwork — 28 October.** Movement-end warnings
and losses for current games now use the shared group-capable separation quote.
The three-member option remains unexposed: consent, faction benefits, funding,
movement and battle paths are not yet integrated for groups.
**Basic allied No-Field transit — 28 October. Prototyped / Partial.** Richese
may offer its ally an immediately revealed shipment into a Richese-occupied
territory. The recipient may depart or accept the existing provisional loss
+6
View File
@@ -1,5 +1,11 @@
# Rule decision index
## Residual Poison and Semuta with Basic independent cards — 28 October 2026
The existing provisional public-discard reaction also applies to the clean
Richese/classic independent-card preview, after the random death commits and
before battle preparation resumes. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#residual-poison-in-basic-independent-cards--28-october-2026).
## Basic allied No-Field transit — 28 October 2026
The FAQ's allied shipment and the Richese alliance ability are provisionally
+10
View File
@@ -1,5 +1,15 @@
# Semuta discard producers and continuation inventory
## Residual Poison in Basic independent cards — 28 October 2026
**Prototyped / Partial.** In the existing Richese/classic independent-card
preview, a clean pre-leader Residual Poison death now offers its public
discard to Semuta. The selected leader's death is committed once before the
offer; claiming or passing resumes the original preparation, retaining the
opponent's readiness and reopening the Poison player's readiness. Existing
inspection, competing-parent and module guards remain. The ordering is a
provisional digital interpretation; other profiles remain separately scoped.
## Richese specials and simple Truthtrance in Basic independent cards — 28 October 2026
**Prototyped / Partial.** In the existing Richese/classic independent-card
+7 -20
View File
@@ -434,6 +434,7 @@ import {
import {
quoteSpiceCollection,
quoteBattlePhaseAdvance,
quoteCoalitionAlliedSeparation,
BoardResolutionError,
type AdvisorRelease,
type CollectionQuote,
@@ -747,7 +748,7 @@ import {
settleAdvisors,
arrivalAsAdvisor,
} from './advisors';
import { quoteAlliedSeparation, isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment,
import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment,
type BasicAlliedShipment } from './allied-separation';
import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat';
import { FACTIONS, faction, type FactionId } from './catalog';
@@ -9531,6 +9532,7 @@ function semutaOfferSupported(
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) ||
continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleCleanup' && continuation.source === 'winner' ||
continuation.kind === 'residualPoisonDiscard' ||
continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' ||
continuation.kind === 'truthtranceDiscard' && continuation.promise === null &&
(continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform') ||
@@ -23093,25 +23095,10 @@ function finishMovedGroup(g: Game, move: CompletedMovement) {
}
/** Native board classification is shared by the consequence and its own-seat preview. */
function alliedSeparationTerritories(g: Game, p: Player) {
const ally = p.ally ? getPlayer(g, p.ally) : null;
return quoteAlliedSeparation({
advanced: g.advanced,
turn: g.turn,
player: p.id,
ally: ally?.id ?? null,
playerAllySinceTurn: p.allySinceTurn,
allySinceTurn: ally?.allySinceTurn,
return boardResolution(() => quoteCoalitionAlliedSeparation({
...g,
remaining: g.movementRemaining ?? g.order.slice(g.order.indexOf(p.id)),
territories: gameTerritories(g).map(terr => ({
territory: terr.id,
polar: terr.type === 'polar',
ownPresent: at(p, terr.id) > 0,
allyPresent: !!ally && at(ally, terr.id) > 0,
ownAdvisors: isAdvisor(p, terr.id),
allyAdvisors: !!ally && isAdvisor(ally, terr.id),
ecazCoexist: !!ally && sharesEcazOccupation(g, p, terr.id),
})),
});
}, p.id));
}
function alliedSeparationView(g: Game, p: Player): {territories: string[]; noField?: true} | null {
if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id ||
@@ -29999,7 +29986,7 @@ function playResidualPoison(
`${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`,
{ faction: p.faction, name: 'Residual Poison' },
);
if (g.semutaPreview) {
if (g.semutaPreview || g.semutaRecruitsPreview && basicIndependentSemutaProfile(g)) {
const continuation: ResidualPoisonDiscardContinuation = {
kind: 'residualPoisonDiscard', player: p.id, target: target.id,
victim: victim.id, battleEvent: b.event!, card: used.id,
+1 -1
View File
@@ -1328,7 +1328,7 @@ export const RULE_TOPICS: RuleTopic[] = [
: definition.card.effect === 'ornithopter'
? 'Use the Ornithopter controls with one unused normal/Hajr movement and a selected group/destination. Two card groups stay distinct, while Hajr may reuse their forces afterward. A genuinely recovered Hajr may be legally replayed for another grant. Advanced needs a fresh immediate-after-movement opportunity. Kulon retains the fixed maximum three and extends both normal groups. Advisor/card and wider module combinations remain unfinished.'
: definition.card.effect === 'residualPoison'
? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling. In the explicit Semuta preview, a clean death pauses after public disposal for the neutral offer even if one combatant is already ready. The opponent’s readiness remains; the Poison player must ready again. Inspected battle commitments remain outside this reaction.'
? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling. In the explicit Semuta preview, including the supported Basic independent-card profile, a clean death pauses after public disposal for the neutral offer even if one combatant is already ready. The opponent’s readiness remains; the Poison player must ready again. Inspected battle commitments remain outside this reaction.'
: definition.card.effect === 'stoneBurner'
? 'Use Stone Burner in the weapon selector and review public dial/support feasibility before sealing. Clean selected CHOAM/Ix now uses visibly provisional original Stone-first then uncopied Tooth order; copy-first and physical allocation guards remain.'
: definition.card.effect === 'mirrorWeapon'
+9 -5
View File
@@ -67,8 +67,10 @@ export function finishTruthFlightPoison(game: Game, actions: TruthFlightPoisonSt
/** Fresh bare R/A/E/G original setup in its actual requested mode. Native
* cache1 acquisition, funded physical shipment and real battle preparation
* supply every current role; no Nexus/profile is removed from a played game. */
export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPoisonEffect; advanced?: boolean } = {}) {
export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPoisonEffect; advanced?: boolean; independentCards?: boolean } = {}) {
const effect = options.effect ?? 'ornithopter', advanced = options.advanced ?? false;
const independentCards = options.independentCards ?? false;
assert.ok(!advanced || !independentCards, 'This independent-card source is Basic only.');
const target = effect === 'ornithopter' ? 'emperor' : 'atreides', asker = 'richese', recipient = asker;
const canonical = richeseCards().find(card => card.effect === effect); assert.ok(canonical); const card = canonical.id;
let game = createGame('TRUTHFLIGHTPOISON', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
@@ -93,6 +95,7 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo
}
throw Error('The actual cache acquisition/source boundary must be reached finitely');
};
if (independentCards) act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => player.hand.length === 0));
@@ -132,7 +135,8 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo
assert.deepEqual(game.battle!.plans, {}); assert.ok(residualPoisonCandidates(truthFlightPoisonPlayer(game, asker).leaders, 'arrakeen').length > 1);
const quote = viewGame(game, target).residualPoison; assert.ok(quote && !quote.blocked && quote.target === asker);
}
assert.ok(truthFlightPoisonClean(game)); assert.equal(!!game.semutaPreview, !advanced);
assert.ok(truthFlightPoisonClean(game)); assert.equal(!!game.semutaPreview, !advanced && !independentCards);
assert.equal(!!game.semutaRecruitsPreview, independentCards);
assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards);
assert.deepEqual(truthFlightPoisonInventory(game), originalCards); assert.deepEqual(truthFlightPoisonSpice(game), originalSpice);
assert.deepEqual(truthFlightPoisonTraitors(game), originalTraitors); assert.equal(game.turnActionPromises?.length ?? 0, 0);
@@ -141,7 +145,7 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthFlightPoisonPlayer(game, target).spice };
const mixed = truthFlightPoisonGroup('and', truthFlightPoisonUse(effect), truthFlightPoisonPayment(recipient));
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed };
return { game, source: structuredClone(game), effect, advanced, target, asker, recipient, card, truth: truths[0], truths, truthHolders,
return { game, source: structuredClone(game), effect, advanced, independentCards, target, asker, recipient, card, truth: truths[0], truths, truthHolders,
initial, lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, spiceOrder: structuredClone(front), acquisition, shipment, choice,
origin: effect === 'ornithopter' ? 'imperial_basin:9' : null, poisonTarget: effect === 'residualPoison' ? asker : null,
event: game.battle?.event ?? null, spiceFact, mixed, question, originalCards, originalSpice, originalTraitors,
@@ -150,12 +154,12 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo
acquisition: acquisition.steps.length, shipment: shipment.steps.length },
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
end: { type: 'endMovement' } satisfies Action,
sourceDisclosure: 'Genuine public bare Richese/Atreides/Emperor/Guild selected-CHOAM setup in its original Basic or Advanced preview mode. ' +
sourceDisclosure: `Genuine public Richese/Atreides/Emperor/Guild selected-CHOAM setup in its original ${advanced ? 'Advanced preview' : 'Basic'} mode${independentCards ? ' with independent Ecaz Treachery Cards selected in the lobby' : ''}. ` +
'Only two original unopened Richese/Guild Truth deals, original unopened first-turn land Spice positions and scalar lotteries are selected. ' +
'The holder actually buys its canonical cache card for1. ' + (effect === 'ornithopter'
? 'Emperor actually pays4 to ship two ordinary counters to Imperial Basin, retaining5 and its unused ground movement. '
: 'Richese actually pays1 to ship one original counter into original Atreides10 in Arrakeen; actual chooseBattle and native preparation create the preleader window while Atreides retains9. ') +
'Basic retains its original Semuta marker and native discard passes. No module, mode, played hand, wallet, army, battle, victim, movement, clock or receipt is installed. Source counts derive from actual arrays.',
'Basic retains its original Semuta marker and native discard passes. No module, mode, played hand, wallet, army, battle, victim, movement, clock or receipt is installed after setup. Source counts derive from actual arrays.',
};
}
export type TruthFlightPoisonFixture = ReturnType<typeof createTruthFlightPoisonFixture>;
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actTruthFlightPoison as act, createTruthFlightPoisonFixture,
truthFlightPoisonAction, truthFlightPoisonInventory, truthFlightPoisonPlayer as player,
} from './fixture-truth-flight-poison';
/** Actual public Basic independent-card setup, paid cache purchase, shipment
* and pre-leader battle. Only Semuta is transferred from the original cache
* to Guild's hand; it is not claimed as a real purchase. */
function fixture() {
const source = createTruthFlightPoisonFixture({ effect: 'residualPoison', independentCards: true });
const game = structuredClone(source.game), holder = 'guild';
assert.equal(game.semutaRecruitsPreview, true);
const index = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(index >= 0);
assert.ok(player(game, holder).hand.length < 4);
player(game, holder).hand.push(game.richeseCache!.splice(index, 1)[0]);
assert.deepEqual(truthFlightPoisonInventory(game), source.originalCards);
assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed);
return { ...source, game, holder };
}
function open(source: ReturnType<typeof fixture>, who: 'owner' | 'opponent') {
const readyActor = who === 'owner' ? source.target : source.asker;
const ready = act(source.game, readyActor, { type: 'battlePreparationReady', event: source.event });
assert.deepEqual(ready.battle!.preLeader!.ready, [readyActor]);
const beforeDead = player(ready, source.asker).leaders.filter(leader => leader.dead).map(leader => leader.id);
const pending = act(ready, source.target, truthFlightPoisonAction(ready, source));
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.battle!.preLeader!.ready, [readyActor]);
const newlyDead = player(pending, source.asker).leaders.filter(leader => leader.dead && !beforeDead.includes(leader.id));
assert.equal(newlyDead.length, 1);
assert.equal(viewGame(pending, source.holder).semutaReaction?.canCommit, true);
return { pending, victim: newlyDead[0].id, event: pending.pendingTreacheryDiscard!.batch.event };
}
function react(game: Game, holder: string, event: string, claim: boolean): Game {
if (claim) return act(game, holder, { type: 'semutaCommit', event });
for (const seat of game.players) {
if (!game.pendingTreacheryDiscard?.reaction) break;
game = act(game, seat.id, { type: 'semutaPass', event });
}
return game;
}
for (const who of ['owner', 'opponent'] as const) for (const claim of [false, true]) {
void test(`independent-card ${who}-ready Residual Poison ${claim ? 'claim' : 'pass'} resumes the original preparation once`, () => {
const f = fixture(), { pending, victim, event } = open(f, who);
const dead = player(pending, f.asker).leaders.filter(leader => leader.dead).length;
const resumed = react(pending, f.holder, event, claim);
assert.equal(resumed.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(resumed.battle!.preLeader!.ready, who === 'owner' ? [] : [f.asker]);
assert.deepEqual(resumed.battle!.plans, pending.battle!.plans);
assert.equal(player(resumed, f.asker).leaders.find(leader => leader.id === victim)?.dead, true);
assert.equal(player(resumed, f.asker).leaders.filter(leader => leader.dead).length, dead);
assert.equal(player(resumed, f.holder).hand.some(card => card.id === f.card), claim);
assert.equal(resumed.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
assert.equal(resumed.discard.some(card => card.id === f.card), !claim);
assert.deepEqual(truthFlightPoisonInventory(resumed), f.originalCards);
assert.throws(() => act(resumed, f.holder, { type: 'semutaCommit', event }));
});
}
void test('all profiles can answer an independent-card Residual Poison offer legally', () => {
const f = fixture(), { pending, event } = open(f, 'opponent');
for (const difficulty of DIFFICULTIES) {
const game = structuredClone(pending);
const view = viewGame(game, f.holder);
view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass'));
let continued = act(game, f.holder, action);
for (const seat of continued.players) {
if (!continued.pendingTreacheryDiscard?.reaction) break;
const offer = viewGame(continued, seat.id).semutaReaction;
if (offer && !offer.passed) continued = act(continued, seat.id, { type: 'semutaPass', event });
}
assert.equal(continued.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(continued.battle!.preLeader!.ready, [f.asker]);
assert.deepEqual(truthFlightPoisonInventory(continued), f.originalCards);
}
});