Offer Semuta after ready battle poison discard
This commit is contained in:
1 parent
69bbb09dcf
commit
0902289592
9 files changed
+171
-12
No files matched your search
@@ -20,6 +20,13 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Semuta after a ready battle preparation — 27 October. Prototyped / Partial.**
|
||||
One prior pre-leader Ready no longer prevents a Semuta offer after a legal
|
||||
Residual Poison discard. Claim/pass preserves the actual victim and battle,
|
||||
then the original readiness suffix runs once. Inspected commitments and wider
|
||||
interactions remain separate; final/deployed acceptance is open.
|
||||
[Scope](RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md#semuta-after-a-preparation-ready--27-october-2026).
|
||||
|
||||
**Ecaz same-recipient Duke tenure — 27 October. Prototyped / Partial.** A fresh
|
||||
eligible Ambassador can provisionally convert its current recipient's other
|
||||
temporary Duke tenure to the ordinary until-battle tenure, preserving the
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Semuta after a ready battle preparation
|
||||
|
||||
The neutral Semuta opportunity now follows a legal Residual Poison discard
|
||||
when one combatant has already readied in the open pre-leader stage. The
|
||||
original selected victim and readiness are preserved through claim or pass,
|
||||
then the native Poison suffix runs once. Inspected commitments and wider
|
||||
combinations remain separate; final and deployed acceptance remain open.
|
||||
[Scope](RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md#semuta-after-a-preparation-ready--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Ecaz same-recipient Duke tenure
|
||||
|
||||
A fresh eligible Ecaz Ambassador grant can provisionally convert a current
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Residual Poison: source and engine contract
|
||||
|
||||
## Semuta after a preparation Ready — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** A legal Residual Poison play still offers its public
|
||||
fresh discard to Semuta when one combatant has already marked the current
|
||||
pre-leader preparation ready. The signed continuation retains that actual
|
||||
ready list, the single selected leader death and the physical card while the
|
||||
neutral claim/pass resolves. The opponent's ready remains; the Poison player's
|
||||
own earlier ready is reopened after the reaction, matching the native Poison
|
||||
suffix. Existing committed-plan, inspection and pending-reward limits remain.
|
||||
|
||||
The printed Semuta ability reacts to another player's discard and the Poison
|
||||
card is played before leader commitment; neither source imposes an empty
|
||||
readiness list. The shared online readiness protocol and nesting remain an
|
||||
implementation composition. [Native Basic runtime](../tests/semuta-residual-ready-runtime.test.ts)
|
||||
uses actual purchase, shipment and battle, with a conserved staged Semuta card.
|
||||
Wider interactions and final/deployed acceptance remain open.
|
||||
|
||||
Audit: 2026-09-06. The source findings below informed the bounded runtime integration recorded at the end. Historical hook and proposed-contract sections describe the pre-integration audit; they do not supersede the final checkpoint. Complete expansion compliance remains unverified.
|
||||
|
||||
The Advanced Harkonnen exclusion below is superseded by the
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Rule decision index
|
||||
|
||||
## Semuta and an already-ready Poison battle — 27 October 2026
|
||||
|
||||
In a supported open pre-leader preparation, one prior ready decision does not
|
||||
remove the printed Semuta opportunity after another player's Residual Poison
|
||||
discard. The existing one-death continuation restores the opponent's ready
|
||||
and reopens the Poison player's own ready. This is an online timing composition;
|
||||
other inspection and pending-reward boundaries remain separate.
|
||||
[Scope](RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md#semuta-after-a-preparation-ready--27-october-2026).
|
||||
|
||||
## Ecaz Ambassador same-recipient Duke tenure — 27 October 2026
|
||||
|
||||
Provisionally let a fresh eligible Ambassador grant change the current
|
||||
|
||||
+9
-4
@@ -6088,6 +6088,13 @@ function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinu
|
||||
})),
|
||||
});
|
||||
}
|
||||
/** An open shared preparation may already have one combatant ready. */
|
||||
function residualPoisonPreparationOpen(battle: Battle): boolean {
|
||||
const pre = battle.preLeader;
|
||||
return !!pre && pre.closed === false && typeof battle.event === 'string' && !!battle.event &&
|
||||
pre.event === battle.event && Array.isArray(pre.ready) &&
|
||||
pre.ready.length <= 1 && pre.ready.every(id => id === battle.attacker || id === battle.defender);
|
||||
}
|
||||
function semutaBattleKaramaResponse(g: Game, response: ResponseWindow): boolean {
|
||||
return response.kind === 'capture' || response.kind === 'choamAudit' ||
|
||||
response.kind === 'choamBattleIncome' || response.kind === 'ixSubstitution' ||
|
||||
@@ -7831,8 +7838,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
!battle.revealed && !Object.keys(battle.plans).length &&
|
||||
[battle.attacker, battle.defender].includes(c.player) &&
|
||||
c.target === (battle.attacker === c.player ? battle.defender : battle.attacker) &&
|
||||
battle.preLeader?.event === c.battleEvent && !battle.preLeader.closed &&
|
||||
battle.preLeader.ready.length === 0 &&
|
||||
residualPoisonPreparationOpen(battle) &&
|
||||
victim?.dead && !victim.capturedBy &&
|
||||
batch.cause === 'residualPoison' && batch.entries.length === 1 &&
|
||||
entry.publicFace && entry.discardedBy === c.player &&
|
||||
@@ -9079,8 +9085,7 @@ function semutaOfferSupported(
|
||||
g.pendingWinnerDiscards || g.moritaniRetention ||
|
||||
g.pendingIxSubstitution)) return false;
|
||||
if (continuation.kind === 'residualPoisonDiscard' &&
|
||||
(!g.battle?.preLeader || g.battle.preLeader.closed ||
|
||||
g.battle.preLeader.ready.length > 0 ||
|
||||
(!g.battle || !residualPoisonPreparationOpen(g.battle) ||
|
||||
g.battle.preparation || g.battle.prescience ||
|
||||
g.battle.nexusInspection || g.battle.truthPromises?.length ||
|
||||
g.pendingAuditor || g.pendingCapture || g.pendingTech ||
|
||||
|
||||
+2
-2
@@ -1320,7 +1320,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 with no readiness or inspection commitments pauses after public disposal for the neutral offer, then resumes the same preparation.'
|
||||
? '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.'
|
||||
: 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'
|
||||
@@ -1328,7 +1328,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, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, 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 or reactive La La La disposal before blocking free revival, proactive or reactive Baliset before restricting movement, proactive Trip to Gamont before its ordinary/elite return or native No-Field reveal and capped recall, 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 or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. 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, other reactive Worthless uses, inspected/partly ready Residual Poison battles, nested responses, other pending rewards and wider interactions 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, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, 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 or reactive La La La disposal before blocking free revival, proactive or reactive Baliset before restricting movement, proactive Trip to Gamont before its ordinary/elite return or native No-Field reveal and capped recall, 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 or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. 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, other reactive Worthless uses, inspected Residual Poison battles, nested responses, other pending rewards and wider interactions remain unfinished.'
|
||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||
...(definition.card.effect === 'semutaDrug' ? [
|
||||
'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.',
|
||||
|
||||
@@ -124,12 +124,17 @@ void test('clean Residual Poison death offers Semuta once before shared battle p
|
||||
assert.throws(() => send(claimed, 'e', { type: 'semutaCommit', event }));
|
||||
});
|
||||
|
||||
void test('an already-ready opponent retains the automatic Residual Poison suffix', () => {
|
||||
void test('an already-ready opponent retains readiness through the Residual Poison Semuta offer', () => {
|
||||
let g = fixture();
|
||||
g.semutaPreview = true;
|
||||
hold(g, 'e', 'Semuta Drug');
|
||||
g = poison(ready(start(g), 'g'));
|
||||
assert.equal(g.pendingTreacheryDiscard ?? null, null);
|
||||
const event = g.pendingTreacheryDiscard?.batch.event;
|
||||
assert.ok(event);
|
||||
assert.deepEqual(g.battle?.preLeader?.ready, ['g']);
|
||||
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
|
||||
for (const id of ['r', 'g', 'e']) g = send(g, id, { type: 'semutaPass', event });
|
||||
assert.equal(g.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(g.battle?.preLeader?.ready, ['g']);
|
||||
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
|
||||
assert.equal(g.discard.filter(c => c.id === 'richese-residual-poison').length, 1);
|
||||
|
||||
@@ -181,15 +181,17 @@ for (const advanced of [false, true]) {
|
||||
finishOnce(f, done, victim);
|
||||
});
|
||||
|
||||
void test(`${label}: actual opponent readiness permits native Poison but excludes Semuta's clean producer`, () => {
|
||||
void test(`${label}: actual opponent readiness survives the Poison Semuta response`, () => {
|
||||
const f = createSemutaHomeworldResidualPoisonFixture(advanced);
|
||||
const ready = applyAction(f.game, f.target, { type: 'battlePreparationReady', event: f.event });
|
||||
assert.deepEqual(ready.battle?.preLeader?.ready, [f.target]);
|
||||
assert.equal(viewGame(ready, f.owner).residualPoison?.blocked, null);
|
||||
const done = disposeSemutaHomeworldResidualPoison(f, ready);
|
||||
const victim = assertNativePoison(f, done, ready);
|
||||
const pending = disposeSemutaHomeworldResidualPoison(f, ready);
|
||||
const victim = assertNativePoison(f, pending, ready);
|
||||
assert.ok(pending.pendingTreacheryDiscard);
|
||||
assert.ok(viewGame(pending, f.holder).semutaReaction);
|
||||
const done = reactSemutaHomeworldChoam(pending, f.holder, false);
|
||||
assert.ok(!done.pendingTreacheryDiscard);
|
||||
assert.ok(!viewGame(done, f.holder).semutaReaction);
|
||||
assert.deepEqual(done.battle?.preLeader?.ready, [f.target]);
|
||||
finishOnce(f, done, victim);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actTruthFlightPoison as act, createTruthFlightPoisonFixture,
|
||||
truthFlightPoisonAction, truthFlightPoisonInventory, truthFlightPoisonPlayer as player } from './fixture-truth-flight-poison';
|
||||
|
||||
/** Genuine bare Basic setup, real cache purchase of Residual Poison, paid
|
||||
* shipment and opened battle. The only controlled supplement moves the real
|
||||
* Semuta card from the original Richese cache to Guild's hand; acquiring it
|
||||
* is not presented as an earned purchase or a separate natural journey. */
|
||||
function source() {
|
||||
const f = createTruthFlightPoisonFixture({ effect: 'residualPoison', advanced: false });
|
||||
const game = structuredClone(f.game), holder = 'guild';
|
||||
assert.equal(game.semutaPreview, true);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills);
|
||||
const index = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
||||
assert.ok(index >= 0, 'The original unpurchased Semuta remains in the cache');
|
||||
assert.ok(player(game, holder).hand.length < 4);
|
||||
player(game, holder).hand.push(game.richeseCache!.splice(index, 1)[0]);
|
||||
assert.deepEqual(truthFlightPoisonInventory(game), f.originalCards);
|
||||
assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed);
|
||||
assert.deepEqual(game.battle.preLeader.ready, []);
|
||||
return { ...f, game, holder };
|
||||
}
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
function open(f: ReturnType<typeof source>, readyActor: string) {
|
||||
const ready = act(f.game, readyActor, { type: 'battlePreparationReady', event: f.event });
|
||||
assert.deepEqual(ready.battle!.preLeader!.ready, [readyActor]);
|
||||
const before = structuredClone(ready), action = truthFlightPoisonAction(ready, f);
|
||||
const pending = act(ready, f.target, action);
|
||||
assert.deepEqual(ready, before);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.battle!.preLeader!.ready, [readyActor], 'The accepted preparation is held during the offer');
|
||||
const earlier = player(ready, f.asker).leaders.filter(leader => leader.dead).map(leader => leader.id);
|
||||
const newlyDead = player(pending, f.asker).leaders.filter(leader => leader.dead && !earlier.includes(leader.id));
|
||||
assert.equal(newlyDead.length, 1, 'Poison chooses and commits exactly one physical death');
|
||||
assert.equal(pending.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(truthFlightPoisonInventory(pending), f.originalCards);
|
||||
return { ready, 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 });
|
||||
let current = game;
|
||||
for (const seat of game.players) {
|
||||
if (!current.pendingTreacheryDiscard?.reaction) break;
|
||||
current = act(current, seat.id, { type: 'semutaPass', event });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
for (const who of ['owner', 'opponent'] as const) for (const claim of [false, true]) {
|
||||
void test(`Basic ${who}-ready Residual Poison offers Semuta and ${claim ? 'claim' : 'pass'} resumes once`, () => {
|
||||
const f = source(), readyActor = who === 'owner' ? f.target : f.asker;
|
||||
const { pending, victim, event } = open(f, readyActor);
|
||||
const resumed = react(JSON.parse(JSON.stringify(pending)) as Game, f.holder, event, claim);
|
||||
assert.equal(resumed.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(resumed.battle!.preLeader!.ready, who === 'owner' ? [] : [readyActor],
|
||||
'Native Poison reopens its own readiness without erasing its opponent’s readiness');
|
||||
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,
|
||||
player(pending, f.asker).leaders.filter(leader => leader.dead).length);
|
||||
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);
|
||||
rejected(resumed, f.holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
}
|
||||
|
||||
void test('ready Poison offer rejects stale events and corrupt battle custody without mutation', () => {
|
||||
const f = source(), { pending, event, victim } = open(f, f.asker);
|
||||
rejected(pending, f.holder, { type: 'semutaCommit', event: `${event}-stale` });
|
||||
const corrupt = structuredClone(pending);
|
||||
player(corrupt, f.asker).leaders.find(leader => leader.id === victim)!.dead = false;
|
||||
rejected(corrupt, f.holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(`${difficulty} uses a legal Semuta response after ready Poison`, () => {
|
||||
const f = source(), { pending, event } = open(f, f.asker);
|
||||
const view = viewGame(pending, 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 current = act(pending, f.holder, action);
|
||||
for (const seat of current.players) {
|
||||
if (!current.pendingTreacheryDiscard?.reaction) break;
|
||||
const offer = viewGame(current, seat.id).semutaReaction;
|
||||
if (offer && !offer.passed) current = act(current, seat.id, { type: 'semutaPass', event });
|
||||
}
|
||||
assert.equal(current.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(current.battle!.preLeader!.ready, [f.asker]);
|
||||
assert.deepEqual(truthFlightPoisonInventory(current), f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user