Offer Semuta after clean Residual Poison discard

This commit is contained in:
Quintine committed 2026-09-30 11:38:00 +10:00
1 parent ab5c78226a
commit 32d9ef4ec9
10 files changed
+235 -23

No files matched your search

@@ -11,6 +11,7 @@ import {
} from '../game/engine';
import { baseDeck, leaders } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { createDukeVidal, acquireDuke } from '../game/duke-vidal';
function fixture() {
@@ -88,6 +89,52 @@ const inventory = (g: Game) =>
.map((c) => c.id)
.sort();
void test('clean Residual Poison death offers Semuta once before shared battle preparation', () => {
let g = fixture();
g.semutaPreview = true;
hold(g, 'e', 'Semuta Drug');
const original = inventory(g);
g = poison(start(g));
const event = g.pendingTreacheryDiscard!.batch.event;
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(g.battle!.preLeader?.closed, false);
assert.equal(viewGame(g, 'e').semutaReaction?.canCommit, true);
assert.equal(viewGame(g, 'g').semutaReaction?.canCommit, false);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
const corrupt = reload(g);
const victim = corrupt.players[1].leaders.find(l => l.dead)!;
victim.dead = false;
const before = reload(corrupt);
assert.throws(() => send(corrupt, 'e', { type: 'semutaCommit', event }));
assert.deepEqual(corrupt, before);
let declined = reload(g);
for (const id of ['r', 'g', 'e'])
declined = send(declined, id, { type: 'semutaPass', event });
assert.equal(declined.pendingTreacheryDiscard, null);
assert.deepEqual(inventory(declined), original);
const claimed = send(reload(g), 'e', { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(claimed.players[2].hand.filter(c => c.id === 'richese-residual-poison').length, 1);
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(inventory(claimed), original);
assert.deepEqual(claimed.battle!.preLeader?.ready, []);
assert.equal(ready(claimed, 'r').battle!.preLeader?.ready.includes('r'), true);
assert.throws(() => send(claimed, 'e', { type: 'semutaCommit', event }));
});
void test('an already-ready opponent retains the automatic Residual Poison suffix', () => {
let g = fixture();
g.semutaPreview = true;
hold(g, 'e', 'Semuta Drug');
g = poison(ready(start(g), 'g'));
assert.equal(g.pendingTreacheryDiscard ?? null, 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);
});
void test('a fast opponent cannot commit before both genuine preparation declarations, independent of card possession', () => {
const withCard = start(fixture());
const noCard = fixture();
+40 -1
View File
@@ -10,6 +10,7 @@ import * as bots from '../game/bots';
import * as seatAiDelegation from '../lib/seat-ai-delegation';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import type * as Rooms from '../db/rooms';
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
@@ -94,7 +95,7 @@ function unitStore(runBots = bots.runBots) {
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture() {
async function fixture(semutaPreview = false) {
const store = unitStore();
const created = await store.rooms.createRoom('Holder', 'guild', false, []),
code = created.view.code;
@@ -145,6 +146,12 @@ async function fixture() {
target: other.playerId,
territory: 'arrakeen',
});
if (semutaPreview) {
initial.semutaPreview = true;
const semuta = initial.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID);
assert.ok(semuta >= 0);
initial.players[2].hand.push(initial.richeseCache!.splice(semuta, 1)[0]);
}
const save = (g: engine.Game) =>
store.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
@@ -343,3 +350,35 @@ void test('two simultaneous ready declarations require refreshed retry; stale ev
f.sqlite.close();
}
});
void test('authenticated Residual Poison death pauses for a neutral Semuta offer before shared preparation', async () => {
const f = await fixture(true);
try {
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const before = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.owner, before.version, f.action, clock);
let g = await f.restart().readRoom(f.code);
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(g.battle?.preLeader?.closed, false);
const event = g.pendingTreacheryDiscard!.batch.event;
const offered = g.version;
await f.restart().continueRoomAutomatic(f.code, clock);
assert.equal((await f.restart().readRoom(f.code)).version, offered);
assert.equal((await f.restart().readSeatView(f.code, f.observer)).semutaReaction?.canCommit, true);
assert.equal((await f.restart().readSeatView(f.code, f.other)).semutaReaction?.canCommit, false);
await f.restart().act(f.code, f.observer, g.version, { type: 'semutaCommit', event }, clock);
g = await f.restart().readRoom(f.code);
assert.equal(g.pendingTreacheryDiscard, null);
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(g.players[2].hand.filter(c => c.id === f.action.card).length, 1);
assert.equal(g.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(g.battle?.preLeader?.ready, []);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
await f.restart().act(f.code, f.owner, g.version, f.ready, clock);
const prepared = await f.restart().readRoom(f.code);
assert.deepEqual(prepared.battle?.preLeader?.ready, [f.owner.playerId]);
} finally {
f.sqlite.close();
}
});