Offer Semuta after ready battle poison discard

This commit is contained in:
Quintine committed 2026-10-27 22:42:33 +11:00
1 parent 69bbb09dcf
commit 0902289592
9 files changed
+171 -12

No files matched your search

+7 -2
View File
@@ -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);
});
+105
View File
@@ -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);
});