Connect native Guild rate cancellation and Cunning receipts
This commit is contained in:
1 parent
c10fe0b48c
commit
0db84c81b7
12 files changed
+375
-39
No files matched your search
@@ -18,9 +18,9 @@ import { quietPlan } from './fixture-nexus-duke-capture';
|
||||
* is ordered for land blows on turn one and an actual Nexus on turn two.
|
||||
* No hand, wallet, army, leader, phase, alliance or response is staged.
|
||||
*
|
||||
* The expansion/Nexus admission guard deliberately excludes the classic-only
|
||||
* Guild rate-cancellation window. Actual native half price and its separate
|
||||
* income/counter window are retained, not widened by this fixture. */
|
||||
* A one-force stronghold arrival has no rounded native-rate discount to
|
||||
* cancel (half/full both cost one). Larger native shipments retain the
|
||||
* separate prepayment rate window before this fixture's income path. */
|
||||
export function createGuildAmbassadorIncomeArrival() {
|
||||
const setup = createPairedE3NexusSetup({ roster: ['ecaz', 'moritani', 'guild'],
|
||||
advanced: true, skills: false, homeworlds: false, discovery: false,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createGuildAmbassadorIncomeArrival, allowGuildAmbassadorIncomeArrival,
|
||||
completeGuildAmbassadorIncomeArrival,
|
||||
} from './fixture-guild-ambassador-income-arrival';
|
||||
import {
|
||||
advancePairedE3Nexus, nextPairedE3NexusStep, pairedE3NexusClean,
|
||||
quietPairedE3NexusChoice,
|
||||
} from './fixture-paired-e3-nexus-modules';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
const act = (g: Game, id: string, action: Action) => applyAction(structuredClone(g), id, action);
|
||||
type NativeFixture = ReturnType<typeof createGuildAmbassadorIncomeArrival>;
|
||||
function declare(f: NativeFixture, before: Game, route: 'reserve' | 'transport', amount: number) {
|
||||
const action: Action = route === 'transport' ? { ...f.action, amount } : {
|
||||
type: 'ship', territory: f.destination, sector: f.sector, amount, elite: 0, allyPayment: 0,
|
||||
};
|
||||
let g = act(before, f.entrant, action);
|
||||
if (g.decision?.kind === 'guildShipment') {
|
||||
assert.equal(route, 'reserve');
|
||||
assert.deepEqual(player(g, f.entrant).forces, player(before, f.entrant).forces);
|
||||
g = act(g, f.guild, { type: 'decision', allow: true });
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function allowRate(state: Game) {
|
||||
let g = state;
|
||||
for (let n = 0; g.response?.kind === 'guildRate' && n < 20; n++) {
|
||||
const step = nextPairedE3NexusStep(g); assert.ok(step);
|
||||
assert.equal(step.action.type, 'passResponse');
|
||||
g = act(g, step.actor, step.action);
|
||||
}
|
||||
assert.notEqual(g.response?.kind, 'guildRate');
|
||||
return g;
|
||||
}
|
||||
function entryAfterIncome(g: Game) {
|
||||
return g.response ? allowGuildAmbassadorIncomeArrival(g).game : g;
|
||||
}
|
||||
function assertPhysical(f: NativeFixture, before: Game, after: Game, route: 'reserve' | 'transport', amount: number, cost: number) {
|
||||
const p = player(after, f.entrant), old = player(before, f.entrant);
|
||||
assert.equal(p.spice, old.spice - cost);
|
||||
assert.equal(p.reserves, old.reserves - (route === 'reserve' ? amount : 0));
|
||||
assert.equal(p.forces[f.source], old.forces[f.source] - (route === 'transport' ? amount : 0));
|
||||
assert.equal(p.forces[f.target], (old.forces[f.target] ?? 0) + amount);
|
||||
assert.equal(p.shipped, true);
|
||||
assert.equal(player(after, f.guild).spice, player(before, f.guild).spice + cost);
|
||||
assert.equal(after.pendingAmbassador?.entrant, f.entrant);
|
||||
assert.ok(!after.pendingArrivalReaction);
|
||||
}
|
||||
|
||||
for (const route of ['reserve', 'transport'] as const) {
|
||||
void test(`original expansion/Nexus ${route}: native discount waits before payment; cancellation charges the unchanged full group once`, () => {
|
||||
const f = createGuildAmbassadorIncomeArrival(), before = f.beforeAction;
|
||||
const offered = declare(f, before, route, 3);
|
||||
assert.equal(offered.response?.kind, 'guildRate');
|
||||
assert.equal(player(offered, f.entrant).spice, player(before, f.entrant).spice);
|
||||
assert.equal(player(offered, f.entrant).reserves, player(before, f.entrant).reserves);
|
||||
assert.deepEqual(player(offered, f.entrant).forces, player(before, f.entrant).forces);
|
||||
assert.ok(!offered.pendingAmbassador);
|
||||
assert.ok(viewGame(offered, f.ecaz).responseControls?.cancelCards.includes(f.karama));
|
||||
const canceled = act(offered, f.ecaz, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
const entry = entryAfterIncome(canceled);
|
||||
assertPhysical(f, before, entry, route, 3, 3);
|
||||
assert.equal(entry.discard.filter(c => c.id === f.karama).length, 1);
|
||||
const completed = completeGuildAmbassadorIncomeArrival(entry);
|
||||
const next = act(completed.after, completed.continuation.actor, completed.continuation.action);
|
||||
assert.notEqual(next.active, f.entrant);
|
||||
assert.equal(player(next, f.entrant).spice, player(before, f.entrant).spice - 3);
|
||||
assert.deepEqual(player(next, f.entrant).forces, player(entry, f.entrant).forces);
|
||||
});
|
||||
|
||||
void test(`original expansion/Nexus ${route}: allowing the rate retains rounded discount and its separate income counter`, () => {
|
||||
const f = createGuildAmbassadorIncomeArrival(), before = f.beforeAction;
|
||||
const offered = declare(f, before, route, 3);
|
||||
assert.equal(offered.response?.kind, 'guildRate');
|
||||
const paid = allowRate(offered);
|
||||
assert.equal(paid.response?.kind, 'guildIncome');
|
||||
assert.equal(player(paid, f.entrant).spice, player(before, f.entrant).spice - 2);
|
||||
assert.equal(player(paid, f.guild).spice, player(before, f.guild).spice);
|
||||
const entry = entryAfterIncome(paid);
|
||||
assertPhysical(f, before, entry, route, 3, 2);
|
||||
assert.ok(player(entry, f.ecaz).hand.some(c => c.id === f.karama));
|
||||
});
|
||||
|
||||
void test(`original expansion/Nexus ${route}: unaffordable cancellation spends only Karama and permits a full-price replacement`, () => {
|
||||
const f = createGuildAmbassadorIncomeArrival();
|
||||
const before = act(f.beforeAction, f.entrant, { type: 'bribe', target: f.ecaz,
|
||||
amount: player(f.beforeAction, f.entrant).spice - 3 });
|
||||
assert.equal(player(before, f.entrant).spice, 3);
|
||||
const offered = declare(f, before, route, 6);
|
||||
assert.equal(offered.response?.kind, 'guildRate');
|
||||
const released = act(offered, f.ecaz, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
assert.equal(player(released, f.entrant).spice, 3);
|
||||
assert.deepEqual(player(released, f.entrant).forces, player(before, f.entrant).forces);
|
||||
assert.equal(player(released, f.entrant).reserves, player(before, f.entrant).reserves);
|
||||
assert.equal(player(released, f.entrant).shipped, false);
|
||||
assert.equal(player(released, f.guild).spice, player(before, f.guild).spice);
|
||||
assert.ok(!released.pendingShipment);
|
||||
assert.ok(!released.pendingGuildTransport);
|
||||
assert.ok(!released.pendingAmbassador);
|
||||
assert.equal(released.discard.filter(c => c.id === f.karama).length, 1);
|
||||
assert.equal(viewGame(released, f.entrant).guildRateCanceled, true);
|
||||
const replacement = declare(f, released, route, 3);
|
||||
assert.notEqual(replacement.response?.kind, 'guildRate');
|
||||
const entry = entryAfterIncome(replacement);
|
||||
assertPhysical(f, before, entry, route, 3, 3);
|
||||
});
|
||||
}
|
||||
|
||||
void test('original expansion/Nexus Guild return has its own prepayment rate window and full-price physical return', () => {
|
||||
const f = createGuildAmbassadorIncomeArrival();
|
||||
const before = advancePairedE3Nexus(f.beforeAction,
|
||||
g => g.phase === 5 && g.active === f.guild && pairedE3NexusClean(g),
|
||||
[], quietPairedE3NexusChoice);
|
||||
const p = player(before, f.guild);
|
||||
const source = Object.keys(p.forces).find(key => p.forces[key] >= 3)!;
|
||||
assert.ok(source);
|
||||
const offered = act(before, f.guild, { type: 'guildShip', from: source,
|
||||
territory: 'reserves', amount: 3, elite: 0, allyPayment: 0 });
|
||||
assert.equal(offered.response?.kind, 'guildRate');
|
||||
assert.deepEqual(player(offered, f.guild).forces, p.forces);
|
||||
const done = act(offered, f.ecaz, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
assert.equal(player(done, f.guild).spice, p.spice - 3);
|
||||
assert.equal(player(done, f.guild).forces[source], p.forces[source] - 3);
|
||||
assert.equal(player(done, f.guild).reserves, p.reserves + 3);
|
||||
assert.equal(player(done, f.guild).shipped, true);
|
||||
assert.ok(!done.pendingGuildTransport);
|
||||
assert.ok(!done.pendingAmbassador);
|
||||
});
|
||||
@@ -480,3 +480,59 @@ void test('Advanced Guild Cunning finishes within a genuinely granted out-of-ord
|
||||
assert.equal(new Set(g.movementRemaining).size, g.movementRemaining!.length);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
for (const route of ['reserve', 'transport'] as const) {
|
||||
for (const outcome of ['allow', 'cancel', 'unaffordable'] as const) {
|
||||
void test(`Guild Cunning ${route} preserves its second allowance through native rate ${outcome}`, () => {
|
||||
// Existing controlled final-setup fixture; distinct from the original
|
||||
// action-only expansion/Nexus producer in native-guild-rate-karama.
|
||||
const f = nexusGuildCunningFixture({ advanced: true, karama: true });
|
||||
let g = originalGuildCunningTurn(f, false);
|
||||
g = nexusGuildCunningAllow(
|
||||
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
|
||||
);
|
||||
if (outcome === 'unaffordable')
|
||||
g = applyAction(g, f.owner, { type: 'bribe', target: f.observer,
|
||||
amount: g.players[0].spice - 2 });
|
||||
const before = structuredClone(g.players[0]);
|
||||
const shipment: Action = route === 'reserve'
|
||||
? { type: 'ship', territory: 'carthag', sector: 11, amount: 3 }
|
||||
: { type: 'guildShip', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 3 };
|
||||
g = applyAction(g, f.owner, shipment);
|
||||
if (route === 'reserve') {
|
||||
assert.equal(g.decision?.kind, 'guildShipment');
|
||||
g = applyAction(g, f.owner, { type: 'decision', allow: true });
|
||||
}
|
||||
assert.equal(g.response?.kind, 'guildRate');
|
||||
assert.equal(g.players[0].shipped, true);
|
||||
assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment');
|
||||
assert.equal(g.players[0].spice, before.spice);
|
||||
assert.equal(g.players[0].reserves, before.reserves);
|
||||
assert.deepEqual(g.players[0].forces, before.forces);
|
||||
g = outcome === 'allow' ? nexusGuildCunningAllow(g)
|
||||
: applyAction(g, f.target, { type: 'card', mode: 'cancel', card: f.karama! });
|
||||
let amount = 3, cost = outcome === 'allow' ? 2 : 3;
|
||||
if (outcome === 'unaffordable') {
|
||||
assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment');
|
||||
assert.ok(!g.nexusGuildCunningHistory!.at(-1)!.shipment);
|
||||
assert.equal(g.players[0].spice, before.spice);
|
||||
assert.equal(g.players[0].reserves, before.reserves);
|
||||
assert.deepEqual(g.players[0].forces, before.forces);
|
||||
assert.equal(viewGame(g, f.owner).guildRateCanceled, true);
|
||||
g = settleGuildCunningShipment(applyAction(g, f.owner, { ...shipment, amount: 2 }));
|
||||
amount = cost = 2;
|
||||
}
|
||||
assert.equal(g.nexusGuildCunningLast?.stage, 'extraMove');
|
||||
assert.equal(g.nexusGuildCunningHistory!.at(-1)!.shipment!.stage, 'complete');
|
||||
assert.equal(g.players[0].spice, before.spice - cost);
|
||||
assert.equal(g.players[0].reserves, before.reserves - (route === 'reserve' ? amount : 0));
|
||||
assert.equal(g.players[0].forces['arrakeen:10'],
|
||||
before.forces['arrakeen:10'] - (route === 'transport' ? amount : 0));
|
||||
assert.equal(g.players[0].forces['carthag:11'], amount);
|
||||
assert.equal(g.discard.filter(card => card.id === f.karama).length, outcome === 'allow' ? 0 : 1);
|
||||
g = applyAction(g, f.owner, { type: 'endMovement' });
|
||||
assert.notEqual(g.active, f.owner);
|
||||
assert.equal(g.nexusGuildCunningLast?.stage, 'completed');
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user