Offer Semuta before proactive La La La revival block

This commit is contained in:
Quintine committed 2026-09-30 15:00:45 +10:00
1 parent 88bcb33342
commit b130cffe53
8 files changed
+272 -14

No files matched your search

+13 -2
View File
@@ -12,7 +12,7 @@ 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 a clean Kulon
normal-auction lot, a completed CHOAM sale or clean Kulon/La La La
Worthless discard, 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
@@ -22,6 +22,9 @@ 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.
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
@@ -37,13 +40,21 @@ full-hand ordering, normal Richese starts and deployed acceptance
remain unfinished. Neutral passes and free-slot guard are product
choices.
Clean proactive La La La now passes engine, authenticated SQLite,
direct engine smoke and phone-width guide inspection. `npm run check`
passed types, lint and 5,847 offline tests; the build passed. Direct
Wrangler-dev HTTP passed 50/55; four tests saw HTTP 503 and one
discussion request timed out at 15 seconds. The proxy-free container
run for this new code remains to be verified after push.
Clean Kulon-before-bonus continuation passed engine, authenticated
SQLite and genuine-setup targeted smoke checks. `npm run check`
passed types, lint and 5,845 offline tests; build and phone-width
guide inspection passed. Direct Wrangler-dev HTTP passed 48/55;
six tests saw HTTP 503 instead of their expected status, and one
Advanced-lobby request timed out at 15 seconds. The proxy-free
container run for this change remains to be verified after push.
container workflow `36670493579` for `88bcb33` passed the full
HTTP suite and published its image; this is not a NAS deployment.
The completed CHOAM card-sale reaction passed two- and three-spice
engine cases, canceled-sale exclusion, authenticated SQLite recovery,
+29
View File
@@ -1,5 +1,34 @@
# Dune implementation status
## 30 September 2026 — Semuta after proactive CHOAM La La La cost
In the explicit Richese preview, a clean proactive La La La
Worthless response now discards its public physical card before
a neutral Semuta offer. The selected target's remaining free
force revival is blocked only after a claim or all-seat decline.
A claimant may take the spent card; the block still applies
once without a second response or card cost. Paid revival
remains legal. The saved receipt binds the target, original
Revival rules, owner/card custody and current phase. Reactive
pending-revival requests, Nexus conversion and other Worthless
effects remain outside this bounded reaction.
Focused engine checks cover hidden Semuta-holder parity,
pre-block timing, target and Revival-rule corruption rejection,
physical custody, legal AI claim, decline and paid revival.
Authenticated SQLite checks cover restart, exact stale retry,
seat/private-view continuity and a later paid revival. A
direct engine smoke observed the accepted public cost, one
claim and target block. Expansion and full Semuta gates remain open.
`npm run check` passed types, lint and 5,847 offline tests; the
production build and phone-width guide inspection passed.
Independent review found no scoped defect. Direct Wrangler-dev
HTTP passed 50/55: four tests saw HTTP 503 and one discussion
request exceeded its 15-second limit. The proxy-free
container/full-HTTP gate for this revision and live NAS
acceptance remain open.
## 30 September 2026 — Semuta after clean CHOAM Kulon cost
In the explicit Richese preview, a clean accepted Kulon Worthless
+7
View File
@@ -88,6 +88,13 @@ unspent movement. A neutral claim or decline restores the same
one-range increment without replaying the response. Reactive,
Nexus and overlapping movement parents retain their automatic path.
Clean proactive La La La stages `choamLaLaLaDiscard` after its
accepted public Worthless card cost but before blocking a selected
target's free force revival. The receipt binds the target, old
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.
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
+10 -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 Worthless discard, battle and Ix-deck
Thumper/Amal discards**, plus
CHOAM sale or clean Kulon/proactive La La La 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
@@ -100,6 +100,14 @@ The same one-range effect then applies exactly once. Fixed-range
Ornithopter combinations, reactive movement parents and other
Worthless powers retain their original automatic paths.
A clean proactive La La La response also pauses after its named
public Worthless card cost but before blocking the selected
target's remaining free force revival. The saved receipt binds the
target and old Revival rules; claim or decline installs the block
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.
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
+66 -6
View File
@@ -1221,6 +1221,13 @@ export type Game = {
before: number;
stateSignature: string;
}
| {
kind: 'choamLaLaLaDiscard';
player: string;
card: string;
target: string;
stateSignature: string;
}
| {
kind: 'thumperDiscard';
player: string;
@@ -4956,6 +4963,23 @@ function choamKulonDiscardSignature(g: Game, c: ChoamKulonDiscardContinuation) {
shipped: owner.shipped, spice: owner.spice,
});
}
type ChoamLaLaLaDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'choamLaLaLaDiscard' }
>;
function choamLaLaLaDiscardSignature(g: Game, c: ChoamLaLaLaDiscardContinuation) {
const owner = getPlayer(g, c.player), target = getPlayer(g, c.target);
return JSON.stringify({
player: c.player, card: c.card, target: c.target,
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
revivalRules: g.revivalRules, pendingRevival: g.pendingRevival,
parent: nullentropyParentSignature(g,
{ response: null, decision: null, pendingKarama: null, phaseOpening: null },
`choam:laLaLa:${c.card}`),
ownerHand: owner.hand.map(card => card.id), targetRevived: target.revived,
targetSpice: target.spice,
});
}
type ThumperDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'thumperDiscard' }
@@ -5598,6 +5622,22 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === choamKulonDiscardSignature(g, c),
'The used Kulon no longer matches its pending movement bonus.',
);
} else if (continuation?.kind === 'choamLaLaLaDiscard') {
const c = continuation, entry = batch.entries[0];
const owner = g.players.find(p => p.id === c.player);
requireRule(
g.phase === 4 && owner?.faction === 'choam' &&
g.players.some(p => p.id === c.target) &&
!g.revivalRules?.freeBlocked?.includes(c.target) &&
!g.pendingChoamWorthless && !g.pendingRevival &&
!g.response && !g.decision && !g.phaseOpening &&
batch.cause === 'choam:laLaLa' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.kind === 'worthless' &&
entry.card.name === 'La La La' &&
c.stateSignature === choamLaLaLaDiscardSignature(g, c),
'The used La La La no longer matches its pending revival block.',
);
} else if (continuation?.kind === 'thumperDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
@@ -6265,6 +6305,7 @@ function semutaOfferSupported(
continuation.kind !== 'ixAllyCard' &&
continuation.kind !== 'choamSaleDiscard' &&
continuation.kind !== 'choamKulonDiscard' &&
continuation.kind !== 'choamLaLaLaDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
@@ -6333,6 +6374,7 @@ function semutaOfferSupported(
continuation.kind === 'ixAllyCard' ||
continuation.kind === 'choamSaleDiscard' ||
continuation.kind === 'choamKulonDiscard' ||
continuation.kind === 'choamLaLaLaDiscard' ||
(continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' ||
continuation.kind === 'karamaBgCharityDiscard' ||
@@ -6355,6 +6397,8 @@ function semutaOfferSupported(
(continuation.kind !== 'choamKulonDiscard' ||
(!g.ornithopter && !g.pendingChoamMove && !g.summonedWorm &&
g.wormRides.length === 0)) &&
(continuation.kind !== 'choamLaLaLaDiscard' ||
(!g.pendingRevival && !g.pendingChoamMove && !g.ornithopter)) &&
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
!g.pendingHomeworldShipment && !g.pendingRicheseGift &&
@@ -6507,6 +6551,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
resumeChoamMarket(g);
} else if (next.kind === 'choamKulonDiscard') {
completeChoamKulon(g, getPlayer(g, next.player));
} else if (next.kind === 'choamLaLaLaDiscard') {
completeChoamLaLaLa(g, next.target);
} else if (next.kind === 'nullentropyDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -20332,6 +20378,11 @@ function completeChoamKulon(g: Game, owner: Player) {
};
log(g, `${owner.name} used Kulon to move one extra territory this turn.`);
}
function completeChoamLaLaLa(g: Game, target: string) {
g.revivalRules ??= newRevivalRules();
(g.revivalRules.freeBlocked ??= []).push(target);
log(g, `${getPlayer(g, target).name} cannot take free force revival this phase.`);
}
function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) {
karamaConversionIntegrity(g);
currentFactionPayment(g);
@@ -20747,12 +20798,21 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
: `${choam.name} used Trip to Gamont to reveal ${target.name}’s No-Field. No force was present to return; the card is still used.`,
);
} else {
g.revivalRules ??= newRevivalRules();
(g.revivalRules.freeBlocked ??= []).push(pending.target!);
log(
g,
`${getPlayer(g, pending.target!).name} cannot take free force revival this phase.`,
);
if (g.semutaPreview && used.name === 'La La La' &&
!pending.storm && !pending.revival && !pending.movement &&
!pending.mentat && !pending.nexusEvent) {
const continuation: ChoamLaLaLaDiscardContinuation = {
kind: 'choamLaLaLaDiscard', player: choam.id,
card: used.id, target: pending.target!, stateSignature: '',
};
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = choamLaLaLaDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choam:laLaLa', entries, continuation);
return;
}
}
completeChoamLaLaLa(g, pending.target!);
if (pending.revival) {
g.pendingRevival = null;
log(
+4 -4
View File
@@ -1156,7 +1156,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, 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, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing 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, 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, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive La La La, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.'
: 'Reference component only: this Richese card’s game actions are not enabled.',
],
...(definition.card.effect === 'semutaDrug'
@@ -1166,7 +1166,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 Worthless use, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement pauses before its draw with a neutral, face-hidden offer. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds its one movement range only after the offer. 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. Other Karama uses, private discards, nested responses, pending rewards and full-hand exchange 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 Worthless use, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement pauses before its draw with a neutral, face-hidden offer. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range and La La La blocks one target’s free revival 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. Other Karama uses, reactive Worthless uses, private discards, nested responses, pending rewards and full-hand exchange remain unfinished.',
},
{
area: 'Player controls' as const,
@@ -1184,13 +1184,13 @@ 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, 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 Worthless, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, 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 Worthless, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
},
{
area: 'Verification' as const,
status: 'Partial' as const,
detail:
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, 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 Worthless, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, pending rewards, full games and live deployment remain unverified.',
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, 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 Worthless, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, reactive Worthless uses, pending rewards, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
+67
View File
@@ -296,6 +296,73 @@ void test('La La La proactively removes only remaining ordinary free revivals; p
assert.equal(player(g, 'e').spice, 6);
assert.equal(player(g, 'e').revived, 3);
});
void test('proactive La La La discard offers Semuta before one free-revival block', () => {
const initial = fixture();
initial.players[1] = newPlayer('e', 'Richese', 'richese');
player(initial, 'e').spice = 10;
player(initial, 'e').tanks = 6;
player(initial, 'e').reserves = 14;
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];
player(initial, 'e').hand.push(semuta);
contest(initial, 'b');
const card = named(initial, 'La La La');
const physical = (state: Game) => [
...state.deck, ...state.discard, ...state.richeseCache!,
...state.players.flatMap(p => p.hand),
].map(c => c.id).sort();
const original = physical(initial);
const absent = structuredClone(initial);
player(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]];
absent.richeseCache!.push(semuta);
const offered = (state: Game) => allow(play(state, 'La La La', 'e'));
const pending = offered(initial), neutral = offered(absent);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamLaLaLaDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.ok(!pending.revivalRules?.freeBlocked?.includes('e'));
assert.equal(pending.discard.filter(c => c.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 === 'choamLaLaLaDiscard') c.target = 'b';
},
(state: Game) => { state.revivalRules!.freeBlocked = ['e']; },
(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(() => applyAction(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 = applyAction(declined, id, { type: 'semutaPass', event });
const claimed = applyAction(JSON.parse(JSON.stringify(pending)), 'e',
{ type: 'semutaCommit', event });
for (const state of [declined, claimed]) {
assert.equal(state.pendingTreacheryDiscard, null);
assert.deepEqual(state.revivalRules?.freeBlocked, ['e']);
assert.equal(viewGame(state, 'e').revival.freeRemaining, 0);
assert.equal(forceRevivalQuote(state, player(state, 'e'), 1).cost, 2);
assert.deepEqual(physical(state), original);
assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event }));
}
assert.equal(declined.discard.filter(c => c.id === card).length, 1);
assert.equal(claimed.players[1].hand.filter(c => c.id === card).length, 1);
assert.equal(claimed.discard.filter(c => c.id === semuta.id).length, 1);
});
void test('every CHOAM table offers the same public free-revival response regardless of its private hand', () => {
const a = fixture(),
b = structuredClone(a);
+76
View File
@@ -6,6 +6,7 @@ import { applyAction, createGame, newPlayer, viewGame, type Game, type Action }
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { baseDeck, spiceDeck, treacheryDeck } from '../game/cards';
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 { takeSaphoBattleCard } from './fixture-sapho-battle-order';
@@ -808,6 +809,81 @@ void test('authenticated private Ixian ally discard waits for Semuta before its
} finally { f.sqlite.close(); }
});
void test('authenticated proactive La La La Semuta offer retains the paid-revival block', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta La La La', '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: 4, turn: 2, active: auths[0].playerId,
order: auths.map(auth => auth.playerId), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true,
revivalRules: newRevivalRules() });
for (const p of g.players) {
p.spice = 10;
p.tanks = 6;
p.reserves = 14;
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 === 'La La La');
assert.ok(index >= 0);
const used = g.deck.splice(index, 1)[0];
g.players[0].hand.push(used);
g.players[1].hand.push(semuta);
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,
});
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, 'choamLaLaLaDiscard');
assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.ok(!current.revivalRules?.freeBlocked?.includes(auths[1].playerId));
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.revivalRules?.freeBlocked, [auths[1].playerId]);
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 revived = await act(1, { type: 'revive', amount: 1 });
assert.equal(revived.players[1].spice, 8);
assert.equal(revived.players[1].revived, 1);
} finally { f.sqlite.close(); }
});
void test('authenticated Kulon Semuta offer restores one movement bonus across restart', async () => {
const f = unitStore();
try {