Connect Tleilaxu revival payments to Bureaucrat
This commit is contained in:
1 parent
3b118dd845
commit
e6bb9809f7
10 files changed
+252
-18
No files matched your search
@@ -39,6 +39,11 @@ export function BureaucratPayment({
|
||||
It cannot be spent before then.
|
||||
</p>
|
||||
)}
|
||||
{pending.kind === 'revival' && (
|
||||
<p className="text-base leading-7">
|
||||
This is the actual revival price. A separate free-revival award, if any, stays with Tleilaxu.
|
||||
</p>
|
||||
)}
|
||||
<p className="fine">
|
||||
Allowing the full payment preserves Bureaucrat for later use this phase.
|
||||
</p>
|
||||
|
||||
@@ -33,11 +33,12 @@ A verbal promise alone transfers no spice.
|
||||
|
||||
## Connected boundary and explicit interpretations
|
||||
|
||||
The target paths are ordinary paid auctions, Guild shipment income, direct
|
||||
Richese auction proceeds and actual bribe transfers in the existing base or
|
||||
CHOAM-only skill profile. Other optional modules retain their existing gates.
|
||||
Tleilaxu revival, other expansion payment sources and direct gifts remain outside
|
||||
this connected batch. Their absence does not establish a rule prohibition.
|
||||
The connected paths are ordinary paid auctions, Guild shipment income, direct
|
||||
Richese auction proceeds and actual bribe transfers in the base or CHOAM-only
|
||||
skill profile. Basic Tleilaxu with Leader Skills and base opponents additionally
|
||||
supports a single payer's actual paid revival price to Tleilaxu. Other expansion
|
||||
payment sources and direct gifts remain outside this batch; omission is not a
|
||||
printed prohibition.
|
||||
|
||||
### 21 September gift classification review
|
||||
|
||||
@@ -64,7 +65,20 @@ composition inference. The rulebook's skill-before-faction sentence occurs in
|
||||
its battle paragraph and does not expressly order this economic reaction.
|
||||
The implementation does not settle Bureaucrat-before-Karama consumption.
|
||||
|
||||
Qualifying single-payer payments have a public amount. Qualifying allied funding,
|
||||
In the Basic Tleilaxu skill profile, a paid revival first commits its force or
|
||||
leader custody and deducts the actual price. Its native income response still
|
||||
allows Karama cancellation. Only an allowed, single-payer payment of at least
|
||||
five spice then offers Bureaucrat before Tleilaxu receives that price. A separate
|
||||
one-spice free-revival award is not a payment and cannot make a smaller price
|
||||
qualify; if both occur, the award stays with Tleilaxu after any redirection.
|
||||
The signed response and saved Bureaucrat choice retain the original payer and
|
||||
price through JSON/SQLite continuation, while unrelated observers do not see
|
||||
the private negotiated amount. The trainer's declined opportunity remains
|
||||
available for a later qualifying payment in that phase.
|
||||
|
||||
Existing auction, shipment and bribe paths have public payment amounts; the
|
||||
negotiated revival price remains limited to the participating seats and trainer.
|
||||
Qualifying allied funding,
|
||||
including an aggregate such as three plus three and a payment funded entirely by
|
||||
the ally, is guarded before costs or commitment. It needs separate payment-unit
|
||||
and privacy work, as does split-recipient Homeworld income. Independent Karama
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Current development status
|
||||
|
||||
Updated 27 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
Updated 28 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
is playable and the six classic factions have a user-authorized Advanced preview.
|
||||
Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
@@ -83,6 +83,14 @@ attack under the user's interpretation, not a GF9 clarification. Combined
|
||||
modes and full Richese starts remain gated. Neither new path is in the
|
||||
verified live `c9a3625` image.
|
||||
|
||||
The bounded [Bureaucrat/Tleilaxu revival payment](BUREAUCRAT_PAYMENTS.md)
|
||||
path adds the Basic Tleilaxu/Leader Skills profile's actual single-payer
|
||||
revival prices to the skill's post-Karama redirection. Paid custody, separate
|
||||
free-revival income, private negotiated amounts, owner controls, four legal
|
||||
AI policies and concurrent SQLite continuation are connected. Other expansion
|
||||
payment families, gifts and combined modules remain gated; this is not a
|
||||
complete Leader Skills or Tleilaxu certification.
|
||||
|
||||
The main page
|
||||
labels its build's Git commit in the lobby and active-table masthead; local
|
||||
Git HEAD does not represent uncommitted work. The main-branch container
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 28 September 2026 — Basic Tleilaxu revival/Bureaucrat composition
|
||||
|
||||
A genuine Basic Tleilaxu/Leader Skills game now offers a living native
|
||||
Bureaucrat's once-per-phase redirection on another player's actual paid revival
|
||||
price of at least five spice. The payer's cost and revived physical leader or
|
||||
forces are committed once; native Tleilaxu income retains its existing Karama
|
||||
window, and a cancellation does not offer a player-directed payment. A separate
|
||||
one-spice free-revival award cannot lift a small price over the threshold and
|
||||
is not diverted. The decision binds the payer, trainer, payee, amount, turn and
|
||||
phase to the saved response; the declined opportunity remains available.
|
||||
Negotiated price is available to its participants and trainer but omitted
|
||||
from unrelated observers' pending view and the public redirection log.
|
||||
|
||||
The owner panel shows the original price, both possible recipient amounts and
|
||||
the separate free-award explanation. Four legal bot policies use the projected
|
||||
choice. Genuine setup, Karama allow/cancel, negotiated leader and small force
|
||||
revival, JSON reload, authenticated SQLite restart and competing-version CAS
|
||||
have focused regressions. A read-only intercepted local browser room displayed
|
||||
the actual seven-spice payment and both controls; no room write occurred. This
|
||||
is one bounded payment family, not complete Tleilaxu, Leader Skills or
|
||||
combined-module acceptance; the verified live image remains `c9a3625`.
|
||||
|
||||
Independent rules and privacy/recovery reviews passed after correcting a public
|
||||
log that initially disclosed the negotiated price. Scoped lint, the production
|
||||
build, all 52 local HTTP integration tests and the standalone 716-file,
|
||||
5,652-case offline suite pass. The required
|
||||
`npm run check` cannot complete: concurrent independent work in
|
||||
`game/nexus-fremen-betrayal.ts:38` reads an undeclared
|
||||
`Player.fremenNexusMovementBlockedTurn` field. This checkpoint does not alter
|
||||
that draft or claim a green typecheck.
|
||||
|
||||
## 28 September 2026 — Ecaz Duke revival and bounded Mirror Weapon
|
||||
|
||||
The user chose two explicit product interpretations after bounded publisher
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { basicExpansionLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe';
|
||||
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
|
||||
export type BureaucratPaymentSource = {
|
||||
event: string;
|
||||
turn: number;
|
||||
|
||||
+18
-8
@@ -6254,7 +6254,8 @@ function bureaucratPaymentFrame(g: Game): string {
|
||||
}
|
||||
function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) {
|
||||
requireRule(source && typeof source === 'object' && typeof source.event === 'string' && source.event.length > 0 &&
|
||||
source.turn === g.turn && source.phase === g.phase && ['auction','shipment','bribe'].includes(source.kind) &&
|
||||
source.turn === g.turn && source.phase === g.phase && ['auction','shipment','bribe','revival'].includes(source.kind) &&
|
||||
(source.kind !== 'revival' || g.phase === 4) &&
|
||||
g.players.some(p => p.id === source.payer) && g.players.some(p => p.id === source.payee) && source.payer !== source.payee &&
|
||||
Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source),
|
||||
'The saved Bureaucrat payment changed its actual payer, payee, amount or event.');
|
||||
@@ -6292,7 +6293,10 @@ function bureaucratPaymentIntegrity(g: Game) {
|
||||
'The Bureaucrat source no longer matches its paid auction sale.');
|
||||
else requireRule(source.payee === carrier.owner &&
|
||||
(carrier.kind === 'guildIncome' && source.kind === 'shipment' && source.amount === carrier.amount ||
|
||||
carrier.kind === 'emperorIncome' && source.kind === 'auction' && g.currentAuctionSale?.bureaucratPaymentEvent === source.event),
|
||||
carrier.kind === 'emperorIncome' && source.kind === 'auction' && g.currentAuctionSale?.bureaucratPaymentEvent === source.event ||
|
||||
carrier.kind === 'revivalIncome' && source.kind === 'revival' && byFaction(g,'tleilaxu')?.id === source.payee &&
|
||||
source.amount <= (carrier.amount ?? -1) && (carrier.amount ?? 0) <= source.amount + 1 &&
|
||||
source.payer === carrier.recipient),
|
||||
'The Bureaucrat source no longer matches its original native income.');
|
||||
}
|
||||
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'bureaucratPayment');
|
||||
@@ -6328,9 +6332,11 @@ function offerBureaucratPayment(g: Game,source:BureaucratPaymentSource|undefined
|
||||
g.decision = {kind:'bureaucratPayment',player:assignment.owner,event:source.event};
|
||||
return true;
|
||||
}
|
||||
function projectedBureaucrat(g: Game): BureaucratPaymentView {
|
||||
function projectedBureaucrat(g: Game,viewer: string): BureaucratPaymentView {
|
||||
const pending = g.bureaucratPayments?.pending;
|
||||
return {pending:pending ? {event:pending.source.event,owner:pending.owner,payer:pending.source.payer,payee:pending.source.payee,
|
||||
const privateRevival = pending?.source.kind === 'revival' &&
|
||||
![pending.owner,pending.source.payer,pending.source.payee,pending.resume.response?.recipient].includes(viewer);
|
||||
return {pending:pending && !privateRevival ? {event:pending.source.event,owner:pending.owner,payer:pending.source.payer,payee:pending.source.payee,
|
||||
amount:pending.source.amount,kind:pending.source.kind,redirect:2} : null,
|
||||
usedThisPhase:bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase)};
|
||||
}
|
||||
@@ -6343,7 +6349,7 @@ function actBureaucratPayment(g: Game,action:Action) {
|
||||
const use:BureaucratPaymentUse = {event:pending.source.event,owner:pending.owner,turn:g.turn,phase:g.phase,signature:''};
|
||||
use.signature = bureaucratPaymentSignature(use);
|
||||
g.bureaucratPayments!.used.push(use);(g.bureaucratUseEvents ??= []).push(use.event);
|
||||
log(g,`${getPlayer(g,pending.owner).name} used Bureaucrat to redirect 2 spice of the ${pending.source.amount}-spice payment to the Bank.`);
|
||||
log(g,`${getPlayer(g,pending.owner).name} used Bureaucrat to redirect 2 spice of ${pending.source.kind === 'revival' ? 'a qualifying revival payment' : `the ${pending.source.amount}-spice payment`} to the Bank.`);
|
||||
}
|
||||
delete g.bureaucratPayments!.pending;delete g.bureaucratPaymentEvent;
|
||||
g.response = pending.resume.response;g.decision = pending.resume.decision;
|
||||
@@ -18557,7 +18563,11 @@ function finishRevival(
|
||||
g.revivalFreeIncome ??= {};
|
||||
g.revivalFreeIncome[quote.freeIncome.player] = quote.freeIncome.turn;
|
||||
}
|
||||
if (quote.nextResponse) g.response = quote.nextResponse;
|
||||
if (quote.nextResponse) {
|
||||
g.response = quote.nextResponse;
|
||||
if (quote.nextResponse.kind === 'revivalIncome')
|
||||
Object.assign(g.response, stampBureaucratPayment(g,'revival',payer.id,quote.nextResponse.owner,revival.cost));
|
||||
}
|
||||
}
|
||||
function beginRevival(g: Game, revival: PendingRevival) {
|
||||
if (revival.kind === 'leader') requireLeaderSkillRevival(g,revival.player);
|
||||
@@ -19384,7 +19394,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
g.revivalRules ??= newRevivalRules();
|
||||
if (g.pendingRevival) finishRevival(g);
|
||||
} else if (response.kind === 'revivalIncome') {
|
||||
if (!canceled) getPlayer(g, response.owner).spice += response.amount!;
|
||||
if (!canceled) getPlayer(g, response.owner).spice += response.amount! - (bureaucratDiversion ?? 0);
|
||||
if (g.pendingChoamMarketGhola && g.pendingChoamMarketGhola.event === response.intent)
|
||||
g.pendingChoamMarketGhola.stage = 'complete';
|
||||
} else if (response.kind === 'faceDancerReplacement') {
|
||||
@@ -26304,7 +26314,7 @@ export function viewGame(state: Game, id: string) {
|
||||
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
|
||||
setupPending: setupPending(g),
|
||||
leaderSkills: projectedLeaderSkills(g, id),
|
||||
bureaucrat: projectedBureaucrat(g),
|
||||
bureaucrat: projectedBureaucrat(g, id),
|
||||
bribeOptions: projectedBribes(g, me),
|
||||
mentat: projectedMentat(g, id),
|
||||
rihani: projectedRihani(g, id),
|
||||
|
||||
+1
-1
@@ -4178,7 +4178,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income remains unfinished.',
|
||||
'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. A surviving trained leader that loses a supported battle may retreat selected undialed physical counters up to its printed/copied disc strength into one empty adjacent non-stronghold territory before ordinary loser losses; modified defenses remain unfinished.',
|
||||
'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
|
||||
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income and Richese sales have connected payment paths; split funding, other payment families and combined modules remain unfinished.',
|
||||
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in the Basic Tleilaxu skill profile have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.',
|
||||
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
|
||||
'Other special Sandmaster relocation and Spice Banker normal income remain missing. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
|
||||
@@ -27,11 +27,13 @@ export function takeBureaucratCard(g: Game, id: string, name = 'Karama') {
|
||||
export function bureaucratPaymentGame({
|
||||
advanced = false,
|
||||
choam = false,
|
||||
tleilaxu = false,
|
||||
phase = 5,
|
||||
advisor = false,
|
||||
}: {
|
||||
advanced?: boolean;
|
||||
choam?: boolean;
|
||||
tleilaxu?: boolean;
|
||||
phase?: number;
|
||||
advisor?: boolean;
|
||||
} = {}): Game {
|
||||
@@ -39,7 +41,7 @@ export function bureaucratPaymentGame({
|
||||
'BUROTEST',
|
||||
newPlayer('b', 'Bureaucrat', 'atreides'),
|
||||
advanced,
|
||||
choam ? ['choam'] : [],
|
||||
choam ? ['choam'] : tleilaxu ? ['ix'] : [],
|
||||
);
|
||||
for (const [id, name, faction] of [
|
||||
['p', 'Payer', 'harkonnen'],
|
||||
@@ -47,6 +49,7 @@ export function bureaucratPaymentGame({
|
||||
['g', 'Guild', 'guild'],
|
||||
...(choam ? [['r', 'Richese', 'richese']] : []),
|
||||
...(advisor ? [['bg', 'Bene Gesserit', 'beneGesserit']] : []),
|
||||
...(tleilaxu ? [['t', 'Tleilaxu', 'tleilaxu']] : []),
|
||||
] as [string, string, Parameters<typeof newPlayer>[2]][])
|
||||
joinGame(g, newPlayer(id, name, faction));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { bureaucratPaymentGame, bureaucratPlayer as player, takeBureaucratCard } from './bureaucrat-payment-fixture';
|
||||
|
||||
const clock = { now: () => 82_000, sleep: async () => {} };
|
||||
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
void test('saved Tleilaxu revival payment survives restart and racing choices settle one actual recipient share', async (t) => {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
let game = bureaucratPaymentGame({ tleilaxu: true, phase: 4 });
|
||||
game.active = null;
|
||||
game.revivalRules = newRevivalRules();
|
||||
player(game, 'p').leaders[1].dead = true;
|
||||
takeBureaucratCard(game, 'e');
|
||||
game = applyAction(game, 'p', { type: 'requestLeaderRevival', leader: 'harkonnen-1' });
|
||||
game = applyAction(game, 't', { type: 'quoteLeaderRevival', target: 'p', amount: 7 });
|
||||
game = applyAction(game, 'p', { type: 'acceptLeaderRevival' });
|
||||
for (let step = 0; game.response && step < 30; step++) {
|
||||
const responder = game.players.find((seat) => !game.response!.passed.includes(seat.id));
|
||||
assert.ok(responder);
|
||||
game = applyAction(game, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(game.bureaucratPayments?.pending?.source.amount, 7);
|
||||
game.version = 41;
|
||||
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
|
||||
.run(game.code, JSON.stringify(game), game.version, 80_000);
|
||||
const tokens = Object.fromEntries(game.players.map((seat) => [seat.id, hash(`revival-payment:${seat.id}`)]));
|
||||
for (const seat of game.players)
|
||||
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)')
|
||||
.run(hash(tokens[seat.id]), game.code, seat.id);
|
||||
const auth = await store.rooms.authenticate(game.code, tokens.b);
|
||||
assert.deepEqual(await store.restart().readSeatView(game.code, auth), viewGame(game, 'b'));
|
||||
const outsider = await store.rooms.authenticate(game.code, tokens.g);
|
||||
assert.equal((await store.restart().readSeatView(game.code, outsider)).bureaucrat.pending, null);
|
||||
const event = game.decision.event!;
|
||||
const results = await Promise.allSettled([
|
||||
store.rooms.act(game.code, auth, 41, { type: 'decision', event, redirect: true }, clock),
|
||||
store.restart().act(game.code, auth, 41, { type: 'decision', event, redirect: false }, clock),
|
||||
]);
|
||||
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
|
||||
const after = await store.restart().readRoom(game.code);
|
||||
assert.equal(after.version, 42);
|
||||
assert.equal(player(after, 'p').spice, 23);
|
||||
assert.ok([35, 37].includes(player(after, 't').spice));
|
||||
assert.equal(after.bureaucratPayments?.used.length, player(after, 't').spice === 35 ? 1 : 0);
|
||||
assert.equal(after.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(after.response, null);
|
||||
assert.equal(player(after, 'p').leaders[1].dead, false);
|
||||
assert.deepEqual(after.players.map((seat) => seat.hand), game.players.map((seat) => seat.hand));
|
||||
await assert.rejects(store.restart().act(game.code, auth, 41,
|
||||
{ type: 'decision', event, redirect: true }, clock), /table changed/);
|
||||
assert.deepEqual(await store.restart().readSeatView(game.code, outsider), viewGame(after, 'g'));
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
bureaucratPaymentGame,
|
||||
bureaucratPlayer as player,
|
||||
bureaucratReload as reload,
|
||||
takeBureaucratCard,
|
||||
} from './bureaucrat-payment-fixture';
|
||||
|
||||
function paidRevival(kind: 'leader' | 'forces', cancelIncome = false): Game {
|
||||
let game = bureaucratPaymentGame({ tleilaxu: true, phase: 4 });
|
||||
game.active = null;
|
||||
player(game, 'p').leaders[1].dead = true;
|
||||
player(game, 'p').leaderRevived = false;
|
||||
player(game, 'p').tanks = 3;
|
||||
player(game, 'p').reserves = 17;
|
||||
const karama = takeBureaucratCard(game, 'e');
|
||||
if (kind === 'leader') {
|
||||
game = applyAction(game, 'p', { type: 'requestLeaderRevival', leader: 'harkonnen-1' });
|
||||
game = applyAction(game, 't', { type: 'quoteLeaderRevival', target: 'p', amount: 7 });
|
||||
game = applyAction(game, 'p', { type: 'acceptLeaderRevival' });
|
||||
} else game = applyAction(game, 'p', { type: 'revive', amount: 3 });
|
||||
for (let step = 0; game.response && step < 30; step++) {
|
||||
if (cancelIncome && game.response.kind === 'revivalIncome') {
|
||||
game = applyAction(game, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
|
||||
continue;
|
||||
}
|
||||
const responder = game.players.find((seat) => !game.response!.passed.includes(seat.id));
|
||||
assert.ok(responder);
|
||||
game = applyAction(game, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('negotiated paid leader revival opens Bureaucrat after Tleilaxu income response, then redirects exactly two', () => {
|
||||
const before = paidRevival('leader');
|
||||
assert.equal(before.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(before.bureaucratPayments?.pending?.source.kind, 'revival');
|
||||
assert.equal(before.bureaucratPayments?.pending?.source.amount, 7);
|
||||
assert.equal(player(before, 'p').spice, 23);
|
||||
assert.equal(player(before, 't').spice, 30);
|
||||
assert.equal(player(before, 'p').leaders[1].dead, false);
|
||||
const event = before.decision!.event!;
|
||||
assert.throws(() => applyAction(reload(before), 'p', { type: 'decision', event, redirect: true }));
|
||||
const after = applyAction(reload(before), 'b', { type: 'decision', event, redirect: true });
|
||||
assert.equal(player(after, 'p').spice, 23);
|
||||
assert.equal(player(after, 't').spice, 35);
|
||||
assert.equal(after.bureaucratPayments?.used.length, 1);
|
||||
assert.equal(after.response, null);
|
||||
assert.equal(after.decision, null);
|
||||
const notice = after.log.findLast((entry) => entry.text.includes('used Bureaucrat'));
|
||||
assert.match(notice?.text ?? '', /qualifying revival payment/);
|
||||
assert.doesNotMatch(notice!.text, /7.spice/);
|
||||
assert.deepEqual(after.players.map((seat) => seat.hand), before.players.map((seat) => seat.hand));
|
||||
assert.throws(() => applyAction(reload(after), 'b', { type: 'decision', event, redirect: true }));
|
||||
});
|
||||
|
||||
void test('Karama-stopped native revival income goes to the bank without a Bureaucrat offer', () => {
|
||||
const after = paidRevival('leader', true);
|
||||
assert.equal(after.decision?.kind, undefined);
|
||||
assert.equal(after.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(after.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(player(after, 'p').spice, 23);
|
||||
assert.equal(player(after, 't').spice, 30);
|
||||
assert.equal(after.discard.filter((card) => card.effect === 'karama').length, 1);
|
||||
});
|
||||
|
||||
void test('owner alone sees negotiated revival price in pending Bureaucrat choice; decline preserves the skill', () => {
|
||||
const before = paidRevival('leader');
|
||||
assert.equal(viewGame(before, 'g').bureaucrat.pending, null);
|
||||
assert.equal(viewGame(before, 'b').bureaucrat.pending?.amount, 7);
|
||||
const decision = before.decision;
|
||||
assert.ok(decision?.kind === 'bureaucratPayment');
|
||||
const after = applyAction(reload(before), 'b', {
|
||||
type: 'decision', event: decision.event, redirect: false,
|
||||
});
|
||||
assert.equal(player(after, 't').spice, 37);
|
||||
assert.equal(after.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(after.decision, null);
|
||||
});
|
||||
|
||||
void test('a small force-revival payment and separate free-revival reward cannot trigger Bureaucrat', () => {
|
||||
const game = paidRevival('forces');
|
||||
assert.equal(game.decision?.kind, undefined);
|
||||
assert.equal(game.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(player(game, 'p').spice, 28);
|
||||
assert.equal(player(game, 't').spice, 33);
|
||||
});
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(`${difficulty} resolves paid revival Bureaucrat through the projected legal choice`, () => {
|
||||
const game = paidRevival('leader');
|
||||
player(game, 'b').bot = difficulty;
|
||||
const actions = botActions(viewGame(game, 'b'));
|
||||
const decision = game.decision;
|
||||
assert.ok(decision?.kind === 'bureaucratPayment');
|
||||
assert.ok(actions.some((action) => action.type === 'decision' && action.event === decision.event));
|
||||
const after = applyAction(reload(game), 'b', actions[0]);
|
||||
assert.equal(after.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(after.response, null);
|
||||
});
|
||||
Reference in new issue
Block a user