Offer Semuta after proactive CHOAM Baliset discard

This commit is contained in:
Quintine committed 2026-09-30 15:23:32 +10:00
1 parent faebab9a3a
commit 756af19d18
7 files changed
+300 -16

No files matched your search

+18 -5
View File
@@ -12,8 +12,9 @@ the physical Ix deck. Clean public ordinary-card, paid Box, retired
Ornithopter, used Distrans, final Truthtrance, four bounded Sapho,
pre-leader Residual Poison, printed Karama canceling clean CHOAM
Charity, Inflation or Bene Gesserit Charity or settling a clean
normal-auction lot, a completed CHOAM sale or clean Kulon/La La La
Worthless discard, battle-card and Ix-deck Thumper/Amal discards
normal-auction lot, a completed CHOAM sale or clean
Kulon/La La La/Baliset Worthless, battle-card and
Ix-deck Thumper/Amal discards
open the same neutral offer regardless of hidden Semuta custody.
A clean **private** normal-auction Ixian ally replacement discard
also pauses before its draw; no unknown face is shown before a
@@ -22,9 +23,11 @@ before the offer and resumes the same market afterward. Karama waits
after its public cost but before cancellation or the reserved lot settles.
Kulon's public card is spent before this offer; its single movement
range bonus and same turn resume only after claim or all-seat decline.
Proactive La La La likewise spends its public card before the offer;
the selected target loses remaining free revival only after the
reaction, while paid revival remains available.
Proactive La La La likewise spends its public card before the
selected target loses remaining free revival; paid revival remains
available. Proactive Baliset spends its public card before the
selected player is barred from movement into CHOAM's occupied
territory; shipment stays legal.
Residual Poison commits one random death before the offer; only an
uninspected battle with nobody ready qualifies. Once Around commits
bidder order and frees a slot; other Sapho scopes commit chooser or
@@ -40,6 +43,16 @@ full-hand ordering, normal Richese starts and deployed acceptance
remain unfinished. Neutral passes and free-slot guard are product
choices.
Clean proactive Baliset passes focused engine and authenticated
SQLite checks, a direct engine claim/restriction smoke and
independent review without findings. `npm run check` passed
types, lint and 5,849 offline tests; the production build
passed. Isolated built-worker HTTP passed 49/55: four POSTs
returned the Wrangler-dev proxy's 503 rather than app statuses,
and two requests timed out. This is **not** a green local HTTP
suite or production deployment; proxy-free CI remains pending
for this exact checkpoint.
Clean proactive La La La passes engine, authenticated SQLite,
direct engine smoke and phone-width guide checks. `npm run check`
passed types, lint and 5,847 offline tests; the build passed.
+31
View File
@@ -1,5 +1,36 @@
# Dune implementation status
## 30 September 2026 — Semuta after proactive CHOAM Baliset cost
In the explicit Richese preview, an accepted proactive Baliset
response now discards the named public Worthless card before a
neutral Semuta offer. The chosen player's movement restriction
into the occupied CHOAM territory is installed only after claim
or all-seat decline; shipment remains possible. The saved receipt
binds target, territory, owner forces and hand, existing
restrictions, phase and public card cost. It cannot replay the
Karama response, restrict twice or remove the original mover's
forces. Declared-move responses, Nexus conversion and competing
arrival parents retain the prior automatic path.
Engine tests cover hidden-holder parity, pre-restriction timing,
card/target/territory and occupancy custody, JSON normalization,
one saved block after claim or decline, rejection without mutation,
physical card conservation and legal AI claim. Authenticated
in-memory SQLite checks cover seat projections, restart, exact
stale retry, preserved seat rows and rejected restricted movement.
A direct engine smoke observed a public offer, one physical claim
and a turn-scoped restriction. Full Semuta and expansion gates
remain open.
`npm run check` passed types, lint and 5,849 offline tests,
including 23 Baliset and 13 Semuta recovery cases. The
production build passed; independent review had no scoped
finding. Isolated built-worker HTTP passed 49/55: four
Wrangler-dev proxy 503s and two 15-second timeouts. This does
not certify the HTTP suite; proxy-free container CI for the
checkpoint is pending. No NAS deployment is claimed.
## 30 September 2026 — Semuta after proactive CHOAM La La La cost
In the explicit Richese preview, a clean proactive La La La
+9
View File
@@ -95,6 +95,15 @@ Revival rules and physical card. A neutral claim or decline applies
the one saved block; paid revival remains legal. Reactive requests,
Nexus and other Worthless effects retain their automatic paths.
Clean proactive Baliset stages `choamBalisetDiscard` after its
accepted public Worthless card cost but before restricting the
chosen player from movement into CHOAM's occupied territory. The
receipt binds the target, territory, CHOAM's current forces,
existing restrictions and physical card. A neutral claim or
decline installs the one saved restriction; shipment remains
possible. Declared moves, Nexus conversion and overlapping
arrival parents retain their automatic paths.
Completed Ornithopter movement now offers the same retired public card
before its already committed arrival suffix only in a roster without
Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response
+11 -2
View File
@@ -68,8 +68,8 @@ ordinary-card, paid Box, retired Ornithopter, used Distrans, final
Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison,
printed Karama canceling clean CHOAM Charity, Inflation or Bene
Gesserit Charity or settling a clean normal-auction lot, a completed
CHOAM sale or clean Kulon/proactive La La La Worthless discard,
battle and Ix-deck Thumper/Amal discards**, plus
CHOAM sale or clean Kulon/proactive La La La/Baliset Worthless
discard, battle and Ix-deck Thumper/Amal discards**, plus
a clean **private** normal-auction Ixian ally replacement discard
before its draw. Other
than the one explicitly suspended charity/Inflation response, the
@@ -108,6 +108,15 @@ once without replaying the response. Paid revival remains legal.
A reactive free-revival request, Nexus conversion and other
Worthless effects retain their earlier automatic paths.
A clean proactive Baliset response pauses after its public
Worthless card cost but before restricting the selected player
from moving into the CHOAM-occupied territory. The saved
receipt binds that target, territory, current CHOAM occupancy
and existing restrictions. Claim or decline installs one
restriction without replaying the response. Shipment remains
legal. Reactive declared movement, Nexus conversion and
competing arrivals retain their original automatic paths.
An early-ended Ornithopter advances the saved queue; a completed
flight resumes one committed arrival only without BG, Ecaz or
Moritani entry reactions. Distrans's gift remains private with its
+67 -9
View File
@@ -1228,6 +1228,14 @@ export type Game = {
target: string;
stateSignature: string;
}
| {
kind: 'choamBalisetDiscard';
player: string;
card: string;
target: string;
territory: string;
stateSignature: string;
}
| {
kind: 'thumperDiscard';
player: string;
@@ -4980,6 +4988,23 @@ function choamLaLaLaDiscardSignature(g: Game, c: ChoamLaLaLaDiscardContinuation)
targetSpice: target.spice,
});
}
type ChoamBalisetDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'choamBalisetDiscard' }
>;
function choamBalisetDiscardSignature(g: Game, c: ChoamBalisetDiscardContinuation) {
const owner = getPlayer(g, c.player);
return JSON.stringify({
player: c.player, card: c.card, target: c.target, territory: c.territory,
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
restrictions: g.choamBaliset, remaining: g.movementRemaining,
parent: nullentropyParentSignature(g,
{ response: null, decision: null, pendingKarama: null, phaseOpening: null },
`choam:baliset:${c.card}`),
ownerHand: owner.hand.map(card => card.id), ownerForces: owner.forces,
ownerElites: owner.elites?.forces, ownerSpice: owner.spice,
});
}
type ThumperDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'thumperDiscard' }
@@ -5638,6 +5663,22 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === choamLaLaLaDiscardSignature(g, c),
'The used La La La no longer matches its pending revival block.',
);
} else if (continuation?.kind === 'choamBalisetDiscard') {
const c = continuation, entry = batch.entries[0];
const owner = g.players.find(p => p.id === c.player);
requireRule(
g.phase === 5 && owner?.faction === 'choam' &&
c.target !== c.player && g.players.some(p => p.id === c.target) &&
at(owner, c.territory) > 0 &&
!g.pendingChoamWorthless && !g.pendingChoamMove &&
!g.response && !g.decision && !g.phaseOpening && !g.ornithopter &&
batch.cause === 'choam:baliset' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.kind === 'worthless' &&
entry.card.name === 'Baliset' &&
c.stateSignature === choamBalisetDiscardSignature(g, c),
'The used Baliset no longer matches its pending territory restriction.',
);
} else if (continuation?.kind === 'thumperDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
@@ -6306,6 +6347,7 @@ function semutaOfferSupported(
continuation.kind !== 'choamSaleDiscard' &&
continuation.kind !== 'choamKulonDiscard' &&
continuation.kind !== 'choamLaLaLaDiscard' &&
continuation.kind !== 'choamBalisetDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
@@ -6375,6 +6417,7 @@ function semutaOfferSupported(
continuation.kind === 'choamSaleDiscard' ||
continuation.kind === 'choamKulonDiscard' ||
continuation.kind === 'choamLaLaLaDiscard' ||
continuation.kind === 'choamBalisetDiscard' ||
(continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' ||
continuation.kind === 'karamaBgCharityDiscard' ||
@@ -6399,6 +6442,8 @@ function semutaOfferSupported(
g.wormRides.length === 0)) &&
(continuation.kind !== 'choamLaLaLaDiscard' ||
(!g.pendingRevival && !g.pendingChoamMove && !g.ornithopter)) &&
(continuation.kind !== 'choamBalisetDiscard' ||
(!g.pendingChoamMove && !g.ornithopter && !g.pendingArrivalOverlap)) &&
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
!g.pendingHomeworldShipment && !g.pendingRicheseGift &&
@@ -6553,6 +6598,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
completeChoamKulon(g, getPlayer(g, next.player));
} else if (next.kind === 'choamLaLaLaDiscard') {
completeChoamLaLaLa(g, next.target);
} else if (next.kind === 'choamBalisetDiscard') {
completeChoamBaliset(g, next.target, next.territory);
} else if (next.kind === 'nullentropyDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -20383,6 +20430,10 @@ function completeChoamLaLaLa(g: Game, target: string) {
(g.revivalRules.freeBlocked ??= []).push(target);
log(g, `${getPlayer(g, target).name} cannot take free force revival this phase.`);
}
function completeChoamBaliset(g: Game, target: string, location: string) {
(g.choamBaliset ??= []).push({ turn: g.turn, player: target, territory: location });
log(g, `${getPlayer(g, target).name} cannot move into CHOAM's ${territory(location).name} this phase; shipment remains possible.`);
}
function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) {
karamaConversionIntegrity(g);
currentFactionPayment(g);
@@ -20758,15 +20809,22 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
`${choam.name} protected its own forces in ${territory(pending.location!).name} for this storm movement with Jubba Cloak.`,
);
} else if (pending.effect === 'baliset') {
(g.choamBaliset ??= []).push({
turn: g.turn,
player: pending.target!,
territory: pending.location!,
});
log(
g,
`${getPlayer(g, pending.target!).name} cannot move into CHOAM's ${territory(pending.location!).name} this phase; shipment remains possible.`,
);
if (g.semutaPreview && used.name === 'Baliset' &&
!pending.storm && !pending.revival && !pending.movement &&
!pending.mentat && !pending.nexusEvent) {
const continuation: ChoamBalisetDiscardContinuation = {
kind: 'choamBalisetDiscard', player: choam.id,
card: used.id, target: pending.target!,
territory: pending.location!, stateSignature: '',
};
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = choamBalisetDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choam:baliset', entries, continuation);
return;
}
}
completeChoamBaliset(g, pending.target!, pending.location!);
if (pending.movement) {
const ambassador = g.pendingChoamMove?.source === 'ambassador';
if (ambassador)
+74
View File
@@ -5,6 +5,7 @@ import {
createGame,
newPlayer,
viewGame,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
@@ -12,6 +13,8 @@ import { baseDeck } from '../game/cards';
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { richeseCards } from '../game/richese-cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const send = (g: Game, id: string, a: Action) => applyAction(g, id, a);
function hold(g: Game, id: string, name: string) {
@@ -183,6 +186,77 @@ void test('proactive Baliset restricts only the selected player and territory',
g.active = 'e';
assert.throws(() => move(g), /Baliset prevents/);
});
void test('clean proactive Baliset discard offers Semuta before one territory restriction', () => {
const initial = fixture();
initial.players[1] = newPlayer('e', 'Richese', 'richese');
p(initial, 'e').forces = { 'red_chasm:7': 3 };
p(initial, 'e').reserves = 17;
initial.richeseCache = richeseCards();
initial.semutaPreview = true;
const index = initial.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(index >= 0);
const semuta = initial.richeseCache.splice(index, 1)[0];
p(initial, 'e').hand.push(semuta);
contest(initial, 'b');
const card = p(initial, 'c').hand.find(card => card.name === 'Baliset')!.id;
const territory = dest(initial).territory;
const physical = (state: Game) => [
...state.deck, ...state.discard, ...state.richeseCache!,
...state.players.flatMap(player => player.hand),
].map(card => card.id).sort();
const original = physical(initial);
const absent = structuredClone(initial);
p(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]];
absent.richeseCache!.push(semuta);
const pending = allow(play(initial)), neutral = allow(play(absent));
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamBalisetDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.choamBaliset, undefined);
assert.equal(pending.discard.filter(used => used.id === card).length, 1);
assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c'));
assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b'));
assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true);
assert.equal(botActions(viewGame(pending, 'e'))[0]?.type, 'semutaCommit');
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending);
for (const change of [
(state: Game) => {
const c = state.pendingTreacheryDiscard!.continuation;
if (c.kind === 'choamBalisetDiscard') c.territory = 'red_chasm';
},
(state: Game) => {
const c = state.pendingTreacheryDiscard!.continuation;
if (c.kind === 'choamBalisetDiscard') c.target = 'b';
},
(state: Game) => { p(state, 'c').forces = {}; },
(state: Game) => { state.choamBaliset = [{ turn: state.turn, player: 'e', territory }]; },
(state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; },
]) {
const corrupt: Game = JSON.parse(JSON.stringify(pending));
change(corrupt);
const before = JSON.stringify(corrupt);
assert.throws(() => send(corrupt, 'e', { type: 'semutaCommit', event }));
assert.equal(JSON.stringify(corrupt), before);
}
let declined: Game = JSON.parse(JSON.stringify(pending));
for (const id of ['c', 'b', 'e'])
declined = send(declined, id, { type: 'semutaPass', event });
const claimed = send(JSON.parse(JSON.stringify(pending)), 'e',
{ type: 'semutaCommit', event });
for (const state of [declined, claimed]) {
assert.equal(state.pendingTreacheryDiscard, null);
assert.deepEqual(state.choamBaliset, [{ turn: state.turn, player: 'e', territory }]);
assert.equal(viewGame(state, 'e').balisetRestrictions.length, 1);
assert.throws(() => move(state), /Baliset prevents/);
assert.deepEqual(physical(state), original);
assert.throws(() => send(state, 'e', { type: 'semutaCommit', event }));
}
assert.equal(declined.discard.filter(used => used.id === card).length, 1);
assert.equal(p(claimed, 'e').hand.filter(used => used.id === card).length, 1);
assert.equal(claimed.discard.filter(used => used.id === semuta.id).length, 1);
});
void test('Baliset permits ordinary shipment into the restricted territory', () => {
let g = allow(play(fixture()));
g = send(g, 'e', { type: 'ship', amount: 1, ...dest(g) });
+90
View File
@@ -9,6 +9,7 @@ import { richeseCards } from '../game/richese-cards';
import { newRevivalRules } from '../game/revival';
import { unitStore } from './fixture-nexus-room-store';
import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor';
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
import { takeSaphoBattleCard } from './fixture-sapho-battle-order';
const clock = { now: () => 10000, sleep: async () => {} };
@@ -955,6 +956,95 @@ void test('authenticated Kulon Semuta offer restores one movement bonus across r
} finally { f.sqlite.close(); }
});
void test('authenticated proactive Baliset Semuta claim restores only the selected movement restriction', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Baliset', 'choam', false, ['choam']);
const code = made.view.code;
const richese = await f.rooms.joinRoom(code, 'Richese', 'richese');
const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit');
const auths = await Promise.all([made.token!, richese.token!, bg.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
const g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18,
active: auths[1].playerId, order: auths.map(auth => auth.playerId),
movementRemaining: auths.map(auth => auth.playerId), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true });
for (const p of g.players) {
p.spice = 10;
p.hand = [];
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const semuta = take(g, SEMUTA_DRUG_ID), karama = take(g, 'karama');
const index = g.deck.findIndex(card => card.name === 'Baliset');
assert.ok(index >= 0);
const used = g.deck.splice(index, 1)[0];
const key = TERRITORIES.flatMap(t => t.sectors.map(s => `${t.id}:${s}`))
.find(key => splitLocation(key).territory !== 'red_chasm' &&
splitLocation(key).sector !== 18 &&
gameDistance(g, 'red_chasm:7', key,
k => splitLocation(k).sector === 18) === 1)!;
assert.ok(key);
const location = splitLocation(key);
g.players[0].hand.push(used);
g.players[0].forces = { [key]: 1 };
g.players[0].reserves = 19;
g.players[1].hand.push(semuta);
g.players[1].forces = { 'red_chasm:7': 3 };
g.players[1].reserves = 17;
g.players[2].hand.push(karama);
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const current = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], current.version, action, clock);
return f.restart().readRoom(code);
};
let current = await act(0, {
type: 'card', mode: 'choam', card: used.id,
target: auths[1].playerId, territory: location.territory,
});
assert.equal(current.response?.kind, 'choamWorthless');
for (let i = 0; current.response?.kind === 'choamWorthless' && i < 3; i++) {
const index = auths.findIndex(auth =>
!viewGame(current, auth.playerId).responseControls?.hasPassed &&
!!viewGame(current, auth.playerId).responseControls?.cancelCards.length);
assert.ok(index >= 0);
current = await act(index, { type: 'passResponse' });
}
const event = current.pendingTreacheryDiscard!.batch.event;
assert.equal(current.pendingTreacheryDiscard?.continuation.kind, 'choamBalisetDiscard');
assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(current.choamBaliset, undefined);
assert.equal(current.discard.filter(card => card.id === used.id).length, 1);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, current.version);
assert.equal((await f.restart().readSeatView(code, auths[1])).semutaReaction?.canCommit, true);
const claimed = await act(1, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.deepEqual(claimed.choamBaliset,
[{ turn: 2, player: auths[1].playerId, territory: location.territory }]);
assert.equal(claimed.players[1].hand.filter(card => card.id === used.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
await assert.rejects(f.restart().act(code, auths[1], current.version,
{ type: 'semutaCommit', event }, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
const before = await f.restart().readRoom(code);
await assert.rejects(f.restart().act(code, auths[1], before.version, {
type: 'move', from: 'red_chasm:7', amount: 1, ...location,
}, clock), /Baliset prevents/);
assert.deepEqual(await f.restart().readRoom(code), before);
} finally { f.sqlite.close(); }
});
void test('authenticated CHOAM sale retains paid spice and market after a saved Semuta claim', async () => {
const f = unitStore();
try {