Offer Semuta on native Ecaz and Moritani cancellation costs
This commit is contained in:
1 parent
12e3a448c6
commit
56d55a1565
11 files changed
+1669
-11
No files matched your search
@@ -16,6 +16,48 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Semuta on Ecaz/Moritani cancellation costs — 26 October. Prototyped / Partial.**
|
||||
Six native powers now offer their printed-role Karama cost through
|
||||
`karamaEcazMoritaniDiscard`: Ecaz/Moritani placement, Moritani Duke acquisition,
|
||||
Ecaz Occupy and Advanced Collection, and Enemy of My Enemy. Pure native quotes
|
||||
retain original declarations, earlier payments, physical tokens, battle lead,
|
||||
paid entry and collection source. Claim/pass restores the original response and
|
||||
applies denial once; the existing persistent controls and minimal bots serve it.
|
||||
No placement, collection, preparation, pact, reveal or phase is pre-applied.
|
||||
|
||||
Three disjoint source lanes and root integration pass **15 files,236/236**,
|
||||
types/lint/build and **18 native engine/GameTable SSR/actual Semuta-callback
|
||||
journeys** on unchanged source. Thirty-two new cases cover nine original sources,
|
||||
claim/pass, allowance, native suffixes, relevant rejection and four minimal
|
||||
profiles. Real cache1 Semuta and originally dealt Karama supply every cost.
|
||||
Ecaz first56/second61 preserve earlier placement1 before the unpaid2 opportunity;
|
||||
Moritani placement67 keeps its free attempt; Duke62 earns two real stronghold
|
||||
conflicts and retains CHOAM's closing choice before Battle6. Occupy87 exercises
|
||||
both actual leads and a legal own-force plan after combined dial3 becomes2.
|
||||
Enemy of My Enemy150/151 preserves real city1 or desert2/move entry before the
|
||||
original Terror chooser and actual Robbery5/4. Collection77 uses a fresh original
|
||||
Advanced R/C/Ecaz/E setup: native alliance and paid shared city groups earn the
|
||||
Ecaz2 bonus; actual CHOAM1 Funeral15 shipment costs2 and collects2. Denial preserves
|
||||
Richese city2 and ordinary desert2, withholding only Ecaz's bonus once. All45
|
||||
cards remain physical; no played hand, force, wallet, alliance, token, control,
|
||||
income or clock is installed.
|
||||
|
||||
Basic receives no Advanced Collection bonus. Only the explicit fresh development
|
||||
initializer adds bare Advanced Ecaz/Richese with exact CHOAM+Ecaz decks, native
|
||||
classic opponents and no optional modules; public Advanced Semuta admission and
|
||||
other E3/module envelopes remain guarded. Feedback corrects Duke's native CHOAM
|
||||
market suffix, explicitly passes Richese's optional allied No-Field opportunity,
|
||||
and uses the quote's stronghold-income field and actual public preview contract.
|
||||
Native cancellation legality remains intact.
|
||||
|
||||
Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-e3-cost-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-e3-cost-runtime/`. No saved-room, D1,
|
||||
server, reset/restart, deployment or recovery operation occurs. These results
|
||||
cover engine, SSR and actual component callbacks; DOM/hydration, browser/mobile,
|
||||
wider arrival parents/combinations, complete modes and deployed/final assurance
|
||||
remain open. [Source and exact scope](SEMUTA_SOURCE_UPDATE_20260907.md#ecaz-and-moritani-cancellation-costs--26-october-2026).
|
||||
|
||||
**Semuta on bonus, No-Field and allied Traitor costs — 26 October. Prototyped / Partial.**
|
||||
Printed-role Karama stopping Harkonnen's bonus card, Richese No-Field or the
|
||||
Harkonnen spectator-ally Traitor call now offers its exact used cost. Three
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Semuta on native Ecaz/Moritani power costs
|
||||
|
||||
**Prototyped / Partial.** One typed used-cost continuation connects native
|
||||
Ecaz/Moritani placement, earned Moritani Duke, Ecaz Occupy/Advanced Collection
|
||||
and Enemy of My Enemy. Pure native validation retains physical declarations,
|
||||
prior payments, selected lead, paid entry and Collection source. Claim/pass
|
||||
restores and denies once; native controls own the next chooser, preparation,
|
||||
income and phase. Basic receives no Advanced bonus. A restricted fresh Advanced
|
||||
Ecaz/Richese development entry uses original setup; public Advanced Semuta and
|
||||
other E3/module gates remain. Existing persistent controls/minimal bots serve it.
|
||||
[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#ecaz-and-moritani-cancellation-costs--26-october-2026).
|
||||
|
||||
Three disjoint source lanes and root integration pass **15 files,236/236**,
|
||||
types/lint/build and **18 native engine/GameTable SSR/actual Semuta-callback
|
||||
journeys** on unchanged source. Thirty-two new cases cover nine native sources,
|
||||
claim/pass, allowance, original suffixes, relevant rejection and four profiles.
|
||||
Real cache1 Semuta and original dealt Karama supply all45-card sources. Ecaz
|
||||
first56/second61 preserve earlier placement1 before unpaid2; native Moritani
|
||||
placement67 keeps its free attempt; Duke62 earns actual two-stronghold conflict
|
||||
and retains CHOAM's market before Battle6. Occupy87 covers both selected leads,
|
||||
combined3 versus own2 and real own-plan submission. Terror150/151 preserves
|
||||
city1 or desert2/movement entry then actual Robbery5/4. Advanced Collection77
|
||||
uses original R/C/Ecaz/E4, native alliance, paid2 city cohorts and actual CHOAM1
|
||||
Funeral15 shipment2/collection2. Denial retains Richese city2 and CHOAM desert2 and
|
||||
removes only Ecaz2. No played state, income or result is installed.
|
||||
|
||||
Feedback corrects Duke's native market suffix, passes the real Richese optional
|
||||
No-Field offer, uses stronghold rather than desert receipt fields and tests
|
||||
actual existing public preview behavior. Native cancellation rules stay intact.
|
||||
Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-e3-cost-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-e3-cost-runtime/`. No room/D1/server,
|
||||
reset/restart, deployment or recovery operation occurs. DOM/hydration,
|
||||
browser/mobile, broader parents/combinations, complete modes and deployed/final
|
||||
assurance remain open.
|
||||
|
||||
## 26 October 2026 — Semuta claims bonus, No-Field and allied Traitor costs
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping Harkonnen's bonus card,
|
||||
|
||||
@@ -2,6 +2,83 @@
|
||||
|
||||
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
|
||||
|
||||
## Ecaz and Moritani cancellation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** `karamaEcazMoritaniDiscard` offers the fresh printed-role
|
||||
Karama cost of native Ecaz/Moritani placement, Moritani Duke acquisition, Ecaz
|
||||
Occupy/Advanced Collection and Enemy of My Enemy. Existing neutral persistent
|
||||
controls and minimal legal profiles serve claim/pass. This composes the existing
|
||||
fresh-discard timing with native powers; it adds no publisher ruling.
|
||||
|
||||
The native [placement cancellation](../game/placement-cancellation.ts),
|
||||
[Terror-alliance cancellation](../game/moritani-alliance-cancellation.ts),
|
||||
[Occupy profile](../game/ecaz-occupy-battle.ts) and
|
||||
[Collection quote](../game/board-resolution-quote.ts) remain authoritative.
|
||||
Preflight supplies no already-spent card as an unspent cost and executes no
|
||||
placement, phase opening, collection, combat preparation, Terror effect or pact.
|
||||
The subtype signature binds original declarations, inventories, paid entry,
|
||||
allied physical board, selected lead, Duke/attempt and Collection source; older
|
||||
continuation formats retain their bytes. Claim/pass retires the fresh group,
|
||||
restores its response and invokes the native denied continuation once.
|
||||
|
||||
- Ecaz denial closes only the remaining placement opportunity. First cost1 is
|
||||
unpaid; canceling the second cost2 preserves the actual first placement/payment1.
|
||||
Moritani's free Mentat attempt is consumed with the token still available.
|
||||
- Moritani earns Duke through two real stronghold conflicts at committed Movement
|
||||
end. Denial leaves the original disc unacquired and attempt consumed. The native
|
||||
CHOAM closing market precedes Battle; its actual Done action completes that suffix.
|
||||
- Occupy retains either actually selected Ecaz/ally lead and both physical armies.
|
||||
Denial disables the coalition contribution; native preparation resumes and the
|
||||
same lead submits a legal own-force plan. No merged army or plan is installed.
|
||||
- Enemy of My Enemy retains the paid shipment or actual ground entry and hidden
|
||||
Terror token. Denial restores Moritani's original reveal/decline chooser with
|
||||
that alliance offer blocked. Actual reveal then chooses native Robbery; a real
|
||||
decline retains the hidden token. Allowance lets the entrant actually accept
|
||||
the pact and returns the token through native logic.
|
||||
- Collection is Advanced-only. Denial applies the original ordinary receipts and
|
||||
map collection once, withholding only the quoted Ecaz shared-stronghold bonus.
|
||||
Basic has no such bonus. A positive ordinary desert receipt proves this path
|
||||
beyond a zero-income case; later acknowledgement cannot collect again.
|
||||
|
||||
The existing explicit development initializer now admits fresh bare Advanced
|
||||
Ecaz/Richese, exact CHOAM+Ecaz decks, two–six original seats, only native
|
||||
Ecaz/Richese/CHOAM/classic factions and no optional modules. Native fresh-setup
|
||||
validation still applies. This is separate from public start admission and never
|
||||
converts a played Basic game. The demonstrated Advanced source is R/C/Ecaz/E4;
|
||||
Advanced Moritani, other selected families/overlays and wider parents retain their
|
||||
separate envelopes. Public ordinary Advanced preview still requires its existing
|
||||
opt-in and does not silently gain Semuta.
|
||||
|
||||
Three disjoint source lanes, bounded native/diff review and root integration pass
|
||||
**15 files,236/236**, types/lint/build and **18 native engine/GameTable SSR/actual
|
||||
Semuta-callback journeys** on unchanged source. Thirty-two new cases cover nine
|
||||
native sources, both outcomes, allowance, actual suffixes, relevant rejection and
|
||||
four minimal profiles. Prefixes: Ecaz first56/second61, Moritani placement67,
|
||||
Duke62, both Occupy leads87, Terror shipment150/movement151 and Advanced
|
||||
Collection77. Every source uses original setup/deal and actual cache1 Semuta.
|
||||
Occupy max3 becomes own2 with actual plan entry. Real Terror entries pay1/2 before
|
||||
Robbery5/4. Advanced R/Ecaz each pay2 for two city counters after a native alliance;
|
||||
CHOAM pays2 for Funeral15 counter1 and really collects2. Denial retains Richese
|
||||
city2 and CHOAM desert2 while removing Ecaz2. All45 cards remain physical. No played hand,
|
||||
wallet, force, alliance, token, leader, control, income or clock is installed.
|
||||
|
||||
Feedback preserves Duke's CHOAM closing market, declines the actual optional
|
||||
Richese allied No-Field offer before ordinary Ecaz shipment, uses the quote's
|
||||
stronghold-income field and tests the existing public preview behavior. Native
|
||||
power legality remains unchanged. Private report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-e3-cost-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-e3-cost-runtime/`. No room/D1/server,
|
||||
reset/restart, deployment or recovery operation occurs. DOM/hydration,
|
||||
browser/mobile, broader arrival parents/combinations, complete modes and
|
||||
production/final assurance remain open.
|
||||
|
||||
Evidence: [placement sources](../tests/fixture-semuta-e3-placement.ts),
|
||||
[placement cases](../tests/semuta-e3-placement-engine.test.ts),
|
||||
[Occupy/Collection sources](../tests/fixture-semuta-ecaz-economy.ts),
|
||||
[economy cases](../tests/semuta-ecaz-economy-engine.test.ts),
|
||||
[Terror sources](../tests/fixture-semuta-moritani-alliance.ts) and
|
||||
[Terror cases](../tests/semuta-moritani-alliance-engine.test.ts).
|
||||
|
||||
## Bonus, No-Field and allied Traitor cancellation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Three source-specific used-cost continuations now
|
||||
|
||||
+85
-11
@@ -1577,7 +1577,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -6009,9 +6009,42 @@ function semutaBonusNoFieldTraitorResponse(g: Game, response: ResponseWindow, pl
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Keep native E3 declarations live while their printed cancellation cost is
|
||||
* offered. Quotes validate denial without placing, collecting or opening combat. */
|
||||
function semutaEcazMoritaniKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean {
|
||||
if (!['ecazPlacement','moritaniPlacement','moritaniDuke','ecazCollection','ecazOccupy','moritaniAlliance'].includes(response.kind) ||
|
||||
g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance ||
|
||||
g.pendingExchange || g.pendingNullentropy || g.pendingRicheseGift || g.pendingRichesePurchaseIncome ||
|
||||
g.pendingRevival || g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
|
||||
g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove ||
|
||||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingArrivalOverlap ||
|
||||
g.pendingArrivalReaction?.length || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
g.auction || g.richeseAuction || g.currentAuctionSale || g.choamMarket || g.pendingChoamMarketGhola ||
|
||||
(!!g.battle !== (response.kind === 'ecazOccupy')) ||
|
||||
(!!g.pendingTerrorEntry !== (response.kind === 'moritaniAlliance'))) return false;
|
||||
const context: Game = {...g,pendingTreacheryDiscard:null,response};
|
||||
try {
|
||||
// The physical cost is already spent; do not demand it in the hand again.
|
||||
validateKaramaUse(context,getPlayer(context,player),{kind:'cancel',response});
|
||||
if (response.kind === 'ecazOccupy') {
|
||||
const battle = context.battle!, occupy = battle.ecazOccupy;
|
||||
if (!occupy?.lead || occupy.ecaz !== response.owner || occupy.canceled ||
|
||||
battle.revealed || Object.keys(battle.plans).length > 0) return false;
|
||||
return !!ecazBattleProfile({...context,battle:{...battle,ecazOccupy:{...occupy,canceled:true}}});
|
||||
}
|
||||
if (response.kind === 'moritaniAlliance') {
|
||||
const entry = context.pendingTerrorEntry!;
|
||||
if (!['shipment','movement'].includes(entry.cause) || entry.resume !== 'none') return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof RuleError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
type KaramaResponseDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard' }
|
||||
>;
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
@@ -6030,6 +6063,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
const combat = c.kind === 'karamaCombatDiscard';
|
||||
const benefit = c.kind === 'karamaBonusDiscard' || c.kind === 'karamaNoFieldDiscard' ||
|
||||
c.kind === 'karamaAlliedTraitorDiscard';
|
||||
const e3 = c.kind === 'karamaEcazMoritaniDiscard';
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
@@ -6202,12 +6236,26 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
noField:p.noField, noFieldEvent:p.noFieldEvent, noFieldBlocked:p.noFieldBlockedTurn,
|
||||
advisors:p.advisors, specialUsed:p.specialKaramaUsed, allySince:p.allySinceTurn})),
|
||||
} : undefined,
|
||||
e3: e3 ? {
|
||||
placement:g.pendingEcazPlacement, ambassadors:g.ecazAmbassadors,
|
||||
terrorPlacement:g.pendingMoritaniPlacement, terror:g.moritaniTerror,
|
||||
entry:g.pendingTerrorEntry, duke:g.dukeVidal, dukeAttempt:g.dukeAcquisitionTurn,
|
||||
collection:g.ecazCollection, spice:g.spice, battle:g.battle,
|
||||
active:g.active, ready:g.ready, remaining:g.movementRemaining,
|
||||
order:g.battleOrder, last:g.lastBattleContext, aid:g.aid,
|
||||
revival:g.revivalRules, requests:g.revivalRequests, freeIncome:g.revivalFreeIncome,
|
||||
choamMentat:g.choamMentatPending, market:g.choamMarket,
|
||||
homeworlds:g.homeworlds, skills:g.leaderSkills, tech:g.techTokens,
|
||||
players:g.players.map(p => ({id:p.id, leaders:p.leaders, kwisatz:p.kwisatz,
|
||||
moved:p.moved, advisors:p.advisors, noField:p.noField, noFieldEvent:p.noFieldEvent,
|
||||
allySince:p.allySinceTurn, battleLosses:p.battleLosses})),
|
||||
} : undefined,
|
||||
order: g.order,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, ally: p.ally,
|
||||
hand: p.hand.map(card => card.id), spice: p.spice,
|
||||
shipped: income || shipment || conversion || terminal || movement || disaster || combat || benefit ? p.shipped : undefined,
|
||||
physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster || combat || benefit
|
||||
shipped: income || shipment || conversion || terminal || movement || disaster || combat || benefit || e3 ? p.shipped : undefined,
|
||||
physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster || combat || benefit || e3
|
||||
? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, leaders: battle ? p.leaders : undefined,
|
||||
noField: shipment ? p.noField : undefined,
|
||||
@@ -7132,6 +7180,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
continuation?.kind === 'karamaBonusDiscard' ||
|
||||
continuation?.kind === 'karamaNoFieldDiscard' ||
|
||||
continuation?.kind === 'karamaAlliedTraitorDiscard' ||
|
||||
continuation?.kind === 'karamaEcazMoritaniDiscard' ||
|
||||
continuation?.kind === 'karamaShipmentDiscard' ||
|
||||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
@@ -7176,6 +7225,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(c.kind === 'karamaNoFieldDiscard' && c.use.response.kind === 'richeseNoField') ||
|
||||
(c.kind === 'karamaAlliedTraitorDiscard' && c.use.response.kind === 'harkonnenTraitor')) &&
|
||||
semutaBonusNoFieldTraitorResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaEcazMoritaniDiscard' &&
|
||||
semutaEcazMoritaniKaramaResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaShipmentDiscard' &&
|
||||
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
|
||||
(c.kind === 'karamaBgConversionCancelDiscard' &&
|
||||
@@ -8088,6 +8139,9 @@ function semutaOfferSupported(
|
||||
const noFieldDiscard = continuation.kind === 'karamaNoFieldDiscard';
|
||||
const alliedTraitorDiscard = continuation.kind === 'karamaAlliedTraitorDiscard';
|
||||
const benefitDiscard = bonusDiscard || noFieldDiscard || alliedTraitorDiscard;
|
||||
const e3Discard = continuation.kind === 'karamaEcazMoritaniDiscard';
|
||||
const occupyDiscard = e3Discard && continuation.use.response.kind === 'ecazOccupy';
|
||||
const terrorAllianceDiscard = e3Discard && continuation.use.response.kind === 'moritaniAlliance';
|
||||
const ambassadorBenefitDiscard = (bonusDiscard && continuation.use.response.source === 'ambassador') ||
|
||||
(noFieldDiscard && continuation.use.response.source === 'ambassadorNoField');
|
||||
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
|
||||
@@ -8128,7 +8182,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
|
||||
continuation.kind !== 'karamaEmperorShareDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentPriceDiscard' &&
|
||||
continuation.kind !== 'specialKaramaDiscard' &&
|
||||
@@ -8165,7 +8219,7 @@ function semutaOfferSupported(
|
||||
continuation.promise.kind === 'shipment' ||
|
||||
continuation.promise.kind === 'movement' ||
|
||||
continuation.promise.kind === 'battle'))) return false;
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard && continuation.kind !== 'kaitainDiscard' &&
|
||||
continuation.kind !== 'tupileCleanupDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
@@ -8275,6 +8329,7 @@ function semutaOfferSupported(
|
||||
disasterDiscard ||
|
||||
combatDiscard ||
|
||||
benefitDiscard ||
|
||||
e3Discard ||
|
||||
continuation.kind === 'karamaShipmentDiscard'
|
||||
? (continuation.kind === 'karamaBattleDiscard'
|
||||
? semutaBattleKaramaResponse(g, continuation.use.response)
|
||||
@@ -8298,6 +8353,8 @@ function semutaOfferSupported(
|
||||
? semutaDisasterKaramaResponse(g,continuation.use.response,continuation.player)
|
||||
: combatDiscard
|
||||
? semutaCombatKaramaResponse(g,continuation.use.response,continuation.player,continuation.resume.decision)
|
||||
: e3Discard
|
||||
? semutaEcazMoritaniKaramaResponse(g,continuation.use.response,continuation.player)
|
||||
: benefitDiscard
|
||||
? semutaBonusNoFieldTraitorResponse(g,continuation.use.response,continuation.player)
|
||||
: continuation.kind === 'karamaShipmentDiscard'
|
||||
@@ -8341,6 +8398,7 @@ function semutaOfferSupported(
|
||||
disasterDiscard ||
|
||||
combatDiscard ||
|
||||
benefitDiscard ||
|
||||
e3Discard ||
|
||||
continuation.kind === 'karamaShipmentDiscard' ||
|
||||
((continuation.kind === 'ordinaryCardDiscard'
|
||||
? ordinaryDiscardIncomeMatches(g, continuation)
|
||||
@@ -8371,7 +8429,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'ixAllyCard' ||
|
||||
(continuation.kind === 'karamaIncomeDiscard' &&
|
||||
continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) &&
|
||||
(richeseGiftDiscard || combatDiscard || alliedTraitorDiscard || continuation.kind === 'saphoAggressorDiscard' ||
|
||||
(richeseGiftDiscard || combatDiscard || alliedTraitorDiscard || occupyDiscard || continuation.kind === 'saphoAggressorDiscard' ||
|
||||
bgDiscard ||
|
||||
continuation.kind === 'specialKaramaDiscard' ||
|
||||
continuation.kind === 'residualPoisonDiscard' ||
|
||||
@@ -8395,7 +8453,7 @@ function semutaOfferSupported(
|
||||
!g.pendingArrivalOverlap && !g.pendingRevival &&
|
||||
!g.ornithopter && !g.ecologicalStorm)) &&
|
||||
(richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
|
||||
!g.pendingAmbassador && !g.pendingTerrorEntry)) &&
|
||||
!g.pendingAmbassador && (terrorAllianceDiscard || !g.pendingTerrorEntry))) &&
|
||||
(richeseGiftDiscard || noFieldDiscard || continuation.kind === 'karamaShipmentDiscard' ||
|
||||
continuation.kind === 'specialKaramaDiscard' ||
|
||||
bgDiscard ||
|
||||
@@ -8705,6 +8763,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
next.kind === 'karamaBonusDiscard' ||
|
||||
next.kind === 'karamaNoFieldDiscard' ||
|
||||
next.kind === 'karamaAlliedTraitorDiscard' ||
|
||||
next.kind === 'karamaEcazMoritaniDiscard' ||
|
||||
next.kind === 'karamaShipmentDiscard' ||
|
||||
next.kind === 'karamaBgConversionCancelDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard' ||
|
||||
@@ -11562,15 +11621,28 @@ function bareBasicSemutaProfile(g: Game): boolean {
|
||||
!g.strongholdCards && !g.ecazTreachery && !g.discoveryEnabled &&
|
||||
!g.discoveries && !g.discoveryStash && !g.greatMaker;
|
||||
}
|
||||
/** Collection's printed bonus is Advanced-only. This fresh development entry
|
||||
* retains original Ecaz/Richese setup without admitting unrelated E3 overlays. */
|
||||
function bareAdvancedEcazSemutaProfile(g: Game): boolean {
|
||||
return g.advanced === true && g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.expansions.length === 2 && g.expansions.includes('choam') && g.expansions.includes('ecaz') &&
|
||||
!!byFaction(g,'richese') && !!byFaction(g,'ecaz') &&
|
||||
g.players.every(p => ['richese','choam','ecaz'].includes(p.faction) || faction(p.faction).expansion === 'base') &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
|
||||
!g.strongholdCards && !g.ecazTreachery && !g.discoveryEnabled &&
|
||||
!g.discoveries && !g.discoveryStash && !g.greatMaker;
|
||||
}
|
||||
/** Explicit development entry for original native modules/skills, separate
|
||||
* Homeworld occupation or bare Basic selected native families. */
|
||||
* Homeworld occupation, bare Basic families or bare Advanced Ecaz. */
|
||||
export function initializeSemutaGameForAudit(state: Game, skills = false): Game {
|
||||
const basicNative = bareBasicSemutaProfile(state);
|
||||
const advancedEcaz = bareAdvancedEcazSemutaProfile(state);
|
||||
requireRule(!advancedEcaz || !skills, 'Advanced Ecaz Semuta excludes Leader Skills and other optional modules.');
|
||||
requireRule(!state.semutaPreview &&
|
||||
state.expansions.includes('choam') &&
|
||||
(state.expansions.every(id => id === 'choam' || id === 'ix') || basicNative) &&
|
||||
(state.expansions.every(id => id === 'choam' || id === 'ix') || basicNative || advancedEcaz) &&
|
||||
state.players.some(p => p.faction === 'richese'),
|
||||
'Semuta needs a fresh Richese faction game: bare Basic selected native families, or CHOAM with optional Ix and supported original modules/skills; separate exact-CHOAM Homeworld occupation has no other modules. Other overlays remain excluded.');
|
||||
'Semuta needs a fresh Richese faction game: bare Basic selected native families, bare Advanced Ecaz/Richese, or CHOAM with optional Ix and supported original modules/skills; separate exact-CHOAM Homeworld occupation has no other modules. Other overlays remain excluded.');
|
||||
if (state.discoveryEnabled) requireRule(!basicNative && !skills && !state.homeworlds &&
|
||||
!state.nexusCards && discoveryModuleProfile(state),
|
||||
'Semuta Discovery keeps original Richese/CHOAM and optional Ix setup, without Skills, Homeworlds, Nexus or E3 overlays.');
|
||||
@@ -16065,6 +16137,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
semutaDisasterKaramaResponse(g,use.response,p.id) ||
|
||||
semutaCombatKaramaResponse(g,use.response,p.id) ||
|
||||
semutaBonusNoFieldTraitorResponse(g,use.response,p.id) ||
|
||||
semutaEcazMoritaniKaramaResponse(g,use.response,p.id) ||
|
||||
guildShipmentDiscard || bgConversionCancelDiscard)) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
|
||||
@@ -16080,6 +16153,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
: use.response.kind === 'harkonnenBonus' ? 'karamaBonusDiscard'
|
||||
: use.response.kind === 'richeseNoField' ? 'karamaNoFieldDiscard'
|
||||
: use.response.kind === 'harkonnenTraitor' ? 'karamaAlliedTraitorDiscard'
|
||||
: ['ecazPlacement','moritaniPlacement','moritaniDuke','ecazCollection','ecazOccupy','moritaniAlliance'].includes(use.response.kind) ? 'karamaEcazMoritaniDiscard'
|
||||
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
|
||||
: use.response.kind === 'choamInflation'
|
||||
? 'karamaInflationDiscard'
|
||||
|
||||
@@ -1295,6 +1295,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Printed-role Karama stopping native Fremen Storm protection, worm survival, allied worm protection or Advanced additional-worm placement offers its exact used cost before prevention. Claim or Continue finishes that cancellation once. Storm entry keeps its already-deposited cohort and queued arrival through the offer; denial then kills that original group without shipping again. Storm traversal, devouring, later survival responses, Spice draws and rides remain native. A summoned worm retains its own saved parent until native completion. Other unfinished parents retain their separate boundaries.',
|
||||
'Printed-role Karama stopping native Voice, ordinary Prescience, Kwisatz Haderach, elite strength, Fremen support or CHOAM battle aid offers its exact used cost while the original battle waits. Claim or Continue finishes that prevention once, then native preparation or the next power check resumes. The offer does not answer Prescience, seal or reveal plans, charge support, spend a reserved pledge or change the forces. Taking Karama does not restore the canceled benefit. The separate live Nexus borrowed-Voice parent retains its existing cancellation path; wider combinations remain unfinished.',
|
||||
'Karama stopping Harkonnen’s extra card after a purchase, a Richese No-Field declaration or Harkonnen’s Traitor call for an ally also offers its used cost. Claim or Continue keeps the original purchase paid and prevents only the bonus draw. A denied No-Field moves no token or forces and charges no shipment; the original ordinary shipment or independent Ambassador reserve grant remains available. An allied Traitor cancellation preserves revealed plans and earlier votes, changes only Harkonnen’s effective call, then finishes the original battle once. The physical Traitor is never a Semuta target. Supported Ambassador cases are Basic; broader combinations remain unfinished.',
|
||||
'Karama stopping native Ecaz or Moritani placement, Moritani’s earned Duke acquisition, Ecaz Occupy or Enemy of My Enemy also offers its used cost. Claim or Continue keeps earlier payments, physical tokens, the selected battle lead and paid entry intact, then applies the original denial once. Moritani may still reveal or decline its Terror token. Ecaz’s shared-stronghold Collection bonus is Advanced-only: a restricted fresh development setup offers this cost before ordinary collection settles, withholding only the canceled bonus. Basic collection has no such bonus; public Advanced Semuta starts and wider combinations remain guarded.',
|
||||
'Printed-role Karama stopping ordinary Basic Spiritual Advisors, Atreides Movement foresight, Advanced Fremen Storm knowledge or native Face Dancer replacement also offers its exact used cost. Claim or Continue finishes that original prevention once. The paid shipment and remaining turn stay intact; the stopped free packet is not placed. Foresight gains no new knowledge or draw. An original Bene Gesserit stance or Guild opening choice waits unchanged until the Atreides cost finishes. A stopped replacement retains the original Face Dancers and reserve, with its attempt still used. Storm opening acknowledgements must finish before cancellation; they are never bypassed or repeated. Complex Spiritual and arrival parents remain separate.',
|
||||
'Printed-role Karama stopping ordinary native Fremen range, Cyborg range, Advanced Guild timing or HMS movement/collection also offers its exact used cost. Claim or Continue completes the original denial once. Fremen and Ixian forces remain in place with their move unused and current-use restriction intact; choose a legal replacement. Guild follows the original remaining queue under its native timing lock. A canceled HMS route neither relocates nor collects; only after the fresh cost finishes does the original Storm turn begin, including its normal Advanced card handling. No declaration, payment, movement, collection or turn opening repeats. Card-flight and other complex parents retain their separate boundaries.',
|
||||
'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.',
|
||||
@@ -1454,6 +1455,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-no-field-cost-engine.test.ts',
|
||||
'tests/fixture-semuta-allied-traitor.ts',
|
||||
'tests/semuta-allied-traitor-engine.test.ts',
|
||||
'tests/fixture-semuta-e3-placement.ts',
|
||||
'tests/semuta-e3-placement-engine.test.ts',
|
||||
'tests/fixture-semuta-ecaz-economy.ts',
|
||||
'tests/semuta-ecaz-economy-engine.test.ts',
|
||||
'tests/fixture-semuta-moritani-alliance.ts',
|
||||
'tests/semuta-moritani-alliance-engine.test.ts',
|
||||
'tests/fixture-semuta-spiritual-foresight.ts',
|
||||
'tests/semuta-spiritual-foresight-engine.test.ts',
|
||||
'tests/fixture-semuta-storm-dancer.ts',
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { quotePlacementCancellation } from '../game/placement-cancellation';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import {
|
||||
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
|
||||
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
import { passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement } from './fixture-semuta-fremen-guild-movement';
|
||||
|
||||
export type SemutaE3PlacementKind = 'ecazPlacement' | 'moritaniPlacement' | 'moritaniDuke';
|
||||
export type SemutaE3PlacementStep = { actor: string; action: Action };
|
||||
export const actSemutaE3Placement = actSemutaRicheseCacheKarama;
|
||||
export const passSemutaE3Placement = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaE3Placement = claimSemutaFremenGuildMovement;
|
||||
export const semutaE3PlacementPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaE3PlacementInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaE3PlacementClean = semutaRicheseCacheKaramaClean;
|
||||
|
||||
/** Fresh declarations stay explicit. Mori's Duke source really starts in an
|
||||
* empty Arrakeen; its other source uses the native ordinary Oh Gap placement. */
|
||||
export function nextSemutaE3Placement(game: Game,
|
||||
kind: SemutaE3PlacementKind = 'ecazPlacement'): SemutaE3PlacementStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance ||
|
||||
['ecazPlacement', 'moritaniPlacement', 'moritaniDuke'].includes(game.response?.kind ?? '')) return null;
|
||||
if (!game.response && !game.phaseOpening && game.status === 'setup' && !game.setupStage &&
|
||||
game.decision?.kind === 'moritaniSetup')
|
||||
return { actor: game.decision.player, action: { type: 'decision',
|
||||
territory: kind === 'moritaniDuke' ? 'arrakeen' : 'oh_gap', sector: kind === 'moritaniDuke' ? 10 : 9 } };
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'moritaniPlacement') return null;
|
||||
const step = nextSemutaRicheseCacheKarama(game);
|
||||
if (step?.action.type === 'stormDial' && game.turn === 1)
|
||||
return { actor: step.actor, action: { ...step.action, amount: 0 } };
|
||||
return step;
|
||||
}
|
||||
export function advanceSemutaE3Placement(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaE3PlacementStep[] = [], kind: SemutaE3PlacementKind = 'ecazPlacement'): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaE3Placement(game, kind); assert.ok(step, 'Choose the actual E3 declaration or fresh-cost reaction');
|
||||
game = actSemutaE3Placement(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The genuine E3 placement source did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaE3Placement(game: Game, actions: SemutaE3PlacementStep[] = []): Game {
|
||||
const kind = game.response?.kind;
|
||||
assert.ok(kind === 'ecazPlacement' || kind === 'moritaniPlacement' || kind === 'moritaniDuke');
|
||||
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaE3Placement(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
/** Close only the still-owned positive Ecaz chooser or the Duke suffix's real
|
||||
* CHOAM phase-closing market. No new movement or battle choice is made. */
|
||||
export function finishSemutaE3Placement(game: Game, kind: SemutaE3PlacementKind,
|
||||
actions: SemutaE3PlacementStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.notEqual(game.response?.kind, kind);
|
||||
if (kind === 'ecazPlacement' && game.decision?.kind === 'ecazPlacement')
|
||||
game = actSemutaE3Placement(game, game.decision.player, { type: 'decision', decline: true }, actions);
|
||||
if (kind === 'moritaniDuke' && game.phase === 5) {
|
||||
assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.decision?.kind, 'choamMarket');
|
||||
assert.equal(game.choamMarket?.resume, 'phase');
|
||||
assert.equal(game.choamMarket?.owner, game.decision!.player);
|
||||
game = actSemutaE3Placement(game, game.decision!.player, { type: 'decision', done: true }, actions);
|
||||
}
|
||||
assert.equal(game.phase, kind === 'ecazPlacement' ? 5 : kind === 'moritaniPlacement' ? 8 : 6);
|
||||
assert.equal(game.turn, 1);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Bare public Basic E3 source. All setup, cache purchase, placement, shipment,
|
||||
* ready/end controls are actual native actions, without played-state staging. */
|
||||
export function createSemutaE3PlacementFixture(options: { kind: SemutaE3PlacementKind; second?: boolean }) {
|
||||
const kind = options.kind, second = options.second ?? false;
|
||||
assert.ok(!second || kind === 'ecazPlacement');
|
||||
const richese = 'richese', holder = 'choam', owner = kind === 'ecazPlacement' ? 'ecaz' : 'moritani', spender = 'emperor';
|
||||
let game = createGame('SEMUTAE3PLACEMENT', newPlayer(richese, 'Richese', 'richese'), false, ['choam', 'ecaz']);
|
||||
for (const faction of [holder, owner, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaE3PlacementStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaE3Placement(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaE3Placement(game, until, actions, kind); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup, entry: 'public-bare-basic-native-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaE3PlacementInventory(game); assert.equal(originalInventory.length, 45);
|
||||
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3);
|
||||
assert.equal(ordinary.length, 3);
|
||||
const deal = [...ordinary.map(card => card.id), printed.id], cost = printed.id;
|
||||
const dealOrder = deal.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card, from, position };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
||||
assert.ok(semutaE3PlacementPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery &&
|
||||
['hagga_basin', 'rock_outcroppings', 'broken_land'].includes(card.territory)); assert.ok(from >= 0);
|
||||
const firstSpice = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(firstSpice);
|
||||
const spiceOrder = [{ position: 0, from, card: structuredClone(firstSpice) }];
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaE3PlacementStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaE3PlacementPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaE3Placement(game, kind); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaE3PlacementPlayer(beforeBid, holder).spice - semutaE3PlacementPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaE3PlacementPlayer(game, richese).spice - semutaE3PlacementPlayer(beforeBid, richese).spice, 1);
|
||||
let firstPlacement: ReturnType<typeof transition> | null = null;
|
||||
const shipments: ReturnType<typeof transition>[] = [];
|
||||
let nativeAction: Action, declaration: ReturnType<typeof transition>, token: string | null = null;
|
||||
let territory: string | null = null;
|
||||
if (kind === 'ecazPlacement') {
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
if (second) {
|
||||
const first = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply'); assert.ok(first);
|
||||
firstPlacement = transition(() => {
|
||||
act(owner, { type: 'decision', token: first.id, territory: 'sietch_tabr' });
|
||||
assert.equal(game.pendingEcazPlacement?.cost, 1);
|
||||
game = allowSemutaE3Placement(game, actions);
|
||||
});
|
||||
assert.equal(semutaE3PlacementPlayer(firstPlacement.before, owner).spice - semutaE3PlacementPlayer(game, owner).spice, 1);
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === first.id)?.location, 'sietch_tabr');
|
||||
assert.equal(game.decision?.kind, 'ecazPlacement');
|
||||
}
|
||||
const selected = game.ecazAmbassadors!.tokens.find(token => token.zone === 'supply' &&
|
||||
(second ? token.effect !== 'ecaz' : token.effect === 'ecaz')); assert.ok(selected);
|
||||
token = selected.id; territory = second ? 'habbanya_ridge_sietch' : 'sietch_tabr';
|
||||
nativeAction = { type: 'decision', token, territory };
|
||||
declaration = transition(() => { act(owner, nativeAction); });
|
||||
assert.equal(game.pendingEcazPlacement?.cost, second ? 2 : 1);
|
||||
} else if (kind === 'moritaniPlacement') {
|
||||
reach(state => state.phase === 8 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
|
||||
const selected = game.moritaniTerror!.tokens.find(token => token.kind === 'sabotage' && token.status === 'available'); assert.ok(selected);
|
||||
token = selected.id; territory = 'carthag';
|
||||
nativeAction = { type: 'decision', token, territory };
|
||||
declaration = transition(() => { act(owner, nativeAction); });
|
||||
} else {
|
||||
assert.equal(semutaE3PlacementPlayer(game, owner).forces['arrakeen:10'], 6);
|
||||
for (const [actor, destination, sector] of [[richese, 'carthag', 11], [owner, 'carthag', 11], [spender, 'arrakeen', 10]] as const) {
|
||||
reach(state => state.phase === 5 && state.active === actor && semutaE3PlacementClean(state));
|
||||
shipments.push(transition(() => {
|
||||
act(actor, { type: 'ship', territory: destination, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaE3PlacementClean);
|
||||
}));
|
||||
if (actor !== spender) act(actor, { type: 'endMovement' });
|
||||
}
|
||||
nativeAction = { type: 'endMovement' };
|
||||
declaration = transition(() => { act(spender, nativeAction); });
|
||||
assert.deepEqual(game.movementRemaining, []); assert.equal(game.dukeAcquisitionTurn, game.turn);
|
||||
}
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response!);
|
||||
const originalPending = structuredClone(kind === 'ecazPlacement' ? game.pendingEcazPlacement : game.pendingMoritaniPlacement ?? null);
|
||||
const originalAmbassadors = structuredClone(game.ecazAmbassadors ?? null), originalTerror = structuredClone(game.moritaniTerror ?? null);
|
||||
const originalDuke = structuredClone(game.dukeVidal ?? null), expected = quotePlacementCancellation(game, game.response!);
|
||||
assert.ok(expected);
|
||||
assert.ok(semutaE3PlacementPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaE3PlacementPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaE3PlacementInventory(game), originalInventory);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source, beforeCost: source, kind, second, advanced: false, richese, holder, spender, owner,
|
||||
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, token, territory,
|
||||
originalResponse, originalPending, originalAmbassadors, originalTerror, originalDuke, expected,
|
||||
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder,
|
||||
originalSpice, spiceOrder, acquired, acquisition, firstPlacement, shipments, declaration,
|
||||
originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Public bare Basic R/C/Ecaz/E or R/C/Moritani/E ready/start uses its existing Semuta E3 gate. Original main35/cache10 remain physical; only unopened starting/Spice positions and scalar lotteries are selected. Emperor really receives printed Karama and CHOAM buys cache Semuta for1. Advanced E3 admission is unchanged.',
|
||||
kind === 'ecazPlacement' ? second
|
||||
? 'At native end-Revival Ecaz really declares/permits reusable Ecaz at Tabr14 and pays1. It then declares a real remaining supply token at Habbanya17 for next native price2. Stop before pending second cost/payment/placement; the first physical placement stays committed.'
|
||||
: 'At native end-Revival Ecaz declares its actual reusable supply token at Tabr14 for1. Stop before pending payment/physical placement.' :
|
||||
kind === 'moritaniPlacement' ? 'Moritani really chooses Oh Gap9 for its six setup counters. Actual first-turn phases finish without a battle. At native Mentat it declares its actual available Sabotage token at Carthag; no Terror token has moved.' :
|
||||
'Moritani really chooses empty Arrakeen10 for its six setup counters. During original combined turns Richese ships1 to Carthag, Moritani ships1 there, then Emperor ships1 to Arrakeen. Actual final endMovement earns the native Duke opportunity from two unresolved stronghold battles; no board, Duke or qualification is installed.',
|
||||
'Stop before separate printed cancellation cost. Native pure placement quote supplies denial receipts; original continuation closes Revival, finishes Mentat placement, or opens the real CHOAM closing market at completed Movement. Its actual Done action then advances to Battle once. No prior payment is refunded and no pending placement fee is charged on denial.',
|
||||
] };
|
||||
}
|
||||
export type SemutaE3PlacementFixture = ReturnType<typeof createSemutaE3PlacementFixture>;
|
||||
@@ -0,0 +1,341 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from '../game/ecaz-occupy-battle';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaDisasterWorm, nextSemutaDisasterWorm, passSemutaDisasterWorm, claimSemutaDisasterWorm,
|
||||
semutaDisasterWormPlayer, semutaDisasterWormInventory, semutaDisasterWormClean,
|
||||
} from './fixture-semuta-disaster-worm';
|
||||
|
||||
export type SemutaEcazEconomyKind = 'ecazCollection' | 'ecazOccupy';
|
||||
export type SemutaEcazEconomyStep = { actor: string; action: Action };
|
||||
export const actSemutaEcazEconomy = actSemutaDisasterWorm;
|
||||
export const passSemutaEcazEconomy = passSemutaDisasterWorm;
|
||||
export const claimSemutaEcazEconomy = claimSemutaDisasterWorm;
|
||||
export const semutaEcazEconomyPlayer = semutaDisasterWormPlayer;
|
||||
export const semutaEcazEconomyInventory = semutaDisasterWormInventory;
|
||||
export const semutaEcazEconomyClean = semutaDisasterWormClean;
|
||||
export const semutaEcazEconomyPhysical = (game: Game) => ({
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, elites: p.elites,
|
||||
reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, ally: p.ally })),
|
||||
spice: game.spice, aid: game.aid, ambassadors: game.ecazAmbassadors,
|
||||
});
|
||||
|
||||
/** Original declarations and private plans remain caller-owned. */
|
||||
export function nextSemutaEcazEconomy(game: Game): SemutaEcazEconomyStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const ecaz = game.players.find(p => p.faction === 'ecaz');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20 &&
|
||||
viewGame(game, ecaz.id).setupPending.includes(ecaz.id))
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'richeseAllyOpportunity')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador' ||
|
||||
decision?.kind === 'ecazBattleLead' || decision?.kind === 'ecazSpice') return null;
|
||||
}
|
||||
return nextSemutaDisasterWorm(game);
|
||||
}
|
||||
export const nextSemutaEcazEconomyStep = nextSemutaEcazEconomy;
|
||||
export function advanceSemutaEcazEconomy(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaEcazEconomyStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaEcazEconomy(game);
|
||||
assert.ok(step, `Choose the original Ecaz/private control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaEcazEconomy(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Ecaz economy programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaEcazEconomy(game: Game, actions: SemutaEcazEconomyStep[] = []): Game {
|
||||
const kind = game.response?.kind; assert.ok(kind === 'ecazOccupy' || kind === 'ecazCollection');
|
||||
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaEcazEconomy(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
/** Finish only original checks/preparation; no private plan is sealed. A
|
||||
* Collection caller must explicitly settle any earned shared allocation. */
|
||||
export function finishSemutaEcazEconomy(game: Game, actions: SemutaEcazEconomyStep[] = []): Game {
|
||||
return advanceSemutaEcazEconomy(game, state => state.phase === 6 && !!state.battle &&
|
||||
!state.battle.revealed && semutaEcazEconomyClean(state) && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false || state.phase === 7 && semutaEcazEconomyClean(state) &&
|
||||
state.ecazCollection?.stage === 'complete', actions);
|
||||
}
|
||||
|
||||
/** Native physical profile for this fixed single-sector, untyped Basic source.
|
||||
* It reads the earned original groups, never merges or installs an army. */
|
||||
export function semutaEcazEconomyProfile(game: Game): EcazOccupyBattleProfile {
|
||||
const b = game.battle, occupy = b?.ecazOccupy; assert.ok(b && occupy?.lead);
|
||||
const forces = (id: string) => ({ normal: Object.entries(semutaEcazEconomyPlayer(game, id).forces)
|
||||
.filter(([key]) => key.startsWith(`${b.territory}:`)).reduce((sum, [, amount]) => sum + amount, 0),
|
||||
elite: 0, eliteStrength: 1 as const, freeSupport: true });
|
||||
const ecaz = semutaEcazEconomyPlayer(game, occupy.ecaz), ally = semutaEcazEconomyPlayer(game, occupy.ally);
|
||||
return quoteEcazOccupyBattle({ advanced: false, battleOrderActor: b.chooser ?? b.attacker,
|
||||
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: forces(ecaz.id) },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: forces(ally.id) },
|
||||
lead: occupy.lead, canceled: occupy.canceled });
|
||||
}
|
||||
|
||||
/** Actual Basic Ecaz/Richese shared stronghold and chosen Occupy lead. The
|
||||
* Advanced-only Collection bonus cannot produce a Basic cancellation source. */
|
||||
export function createSemutaEcazOccupyFixture(options: {
|
||||
lead?: 'ecaz' | 'ally';
|
||||
} = {}) {
|
||||
const kind = 'ecazOccupy' as const, leadChoice = options.lead ?? 'ecaz';
|
||||
const owner = 'ecaz', ally = 'richese', holder = 'choam', opponent = 'emperor', spender = 'guild';
|
||||
const lead = leadChoice === 'ecaz' ? owner : ally, territory = 'carthag', sector = 11, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTAECAZECONOMY', newPlayer(owner, 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
|
||||
for (const faction of ['richese', 'choam', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaEcazEconomyStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaEcazEconomy(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaEcazEconomy(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
assert.equal(game.semutaPreview, true); assert.equal(game.ecazOccupyPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaEcazEconomyInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'), shield = game.deck.find(card => card.kind === 'shield');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3);
|
||||
assert.ok(printed && shield); assert.equal(ordinary.length, 3);
|
||||
const selected = [...ordinary, shield, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.deepEqual(semutaEcazEconomyPlayer(game, owner).forces, { 'imperial_basin:9': 6 });
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && card.territory === 'funeral_plain' && !card.discovery);
|
||||
assert.ok(from >= 0); const spiceCard = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(spiceCard);
|
||||
const spiceOrder = [{ position: 0, from, card: structuredClone(spiceCard) }];
|
||||
let beforeBid: Game | undefined, bid: SemutaEcazEconomyStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaEcazEconomyPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaEcazEconomy(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaEcazEconomyPlayer(beforeBid, holder).spice - semutaEcazEconomyPlayer(game, holder).spice, 1);
|
||||
reach(state => state.phase === 4 && state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
const selfToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
|
||||
const placement = transition(() => {
|
||||
act(owner, { type: 'decision', token: selfToken.id, territory });
|
||||
reach(state => state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
act(owner, { type: 'decision', decline: true });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaEcazEconomyClean(state));
|
||||
assert.equal(game.storm, 1);
|
||||
const ecazShipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory, sector, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(semutaEcazEconomyClean); act(owner, { type: 'endMovement' });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === ally && semutaEcazEconomyClean(state));
|
||||
const alliance = transition(() => {
|
||||
act(ally, { type: 'ship', territory, sector, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
act(owner, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: owner, choice: 'alliance' });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
|
||||
act(ally, { type: 'decision', event: game.pendingAmbassador!.event, accept: true });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'loan');
|
||||
act(owner, { type: 'decision', event: game.pendingAmbassador!.event, loan: false });
|
||||
reach(semutaEcazEconomyClean);
|
||||
});
|
||||
assert.equal(semutaEcazEconomyPlayer(game, owner).ally, ally); assert.equal(semutaEcazEconomyPlayer(game, ally).ally, owner);
|
||||
assert.equal(semutaEcazEconomyPlayer(game, owner).forces[location], 2); assert.equal(semutaEcazEconomyPlayer(game, ally).forces[location], 2);
|
||||
const sharedStronghold = structuredClone(game), basicCollectionQuote = quoteSpiceCollection(game);
|
||||
assert.equal(basicCollectionQuote.collectionBonus, null, 'Native Basic has no Advanced shared-stronghold bank income');
|
||||
act(ally, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === opponent && semutaEcazEconomyClean(state));
|
||||
const opponentShipment = transition(() => {
|
||||
act(opponent, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaEcazEconomyClean); act(opponent, { type: 'endMovement' });
|
||||
});
|
||||
reach(state => state.phase === 6 && semutaEcazEconomyClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(choice =>
|
||||
choice.territory === territory && [choice.attacker, choice.defender].includes(opponent)); assert.ok(choice);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
|
||||
act(choice.chooser, battleAction);
|
||||
assert.equal(game.decision?.kind, 'ecazBattleLead'); assert.equal(game.decision.player, owner);
|
||||
const beforeLead = structuredClone(game), leadAction: Action = { type: 'decision', event: game.decision.event, lead };
|
||||
act(game.decision.player, leadAction);
|
||||
assert.ok(game.response && game.battle); assert.equal(game.response.kind, 'ecazOccupy');
|
||||
assert.equal(game.battle.ecazOccupy?.lead, lead); assert.equal(game.battle.ecazOccupy?.canceled, false);
|
||||
assert.deepEqual(game.battle.plans, {}); assert.equal(game.battle.revealed, false);
|
||||
const allowedProfile = semutaEcazEconomyProfile(game), canceledProfile = quoteEcazOccupyBattle({
|
||||
advanced: false, battleOrderActor: allowedProfile.battleOrderActor,
|
||||
ecaz: { id: owner, faction: 'ecaz', ally, forces: allowedProfile.ecazForces },
|
||||
ally: { id: ally, faction: 'richese', ally: owner, forces: allowedProfile.allyForces }, lead, canceled: true });
|
||||
const ownLeader = semutaEcazEconomyPlayer(game, lead).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
||||
const otherLeader = semutaEcazEconomyPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
const allowedPlan: Action = { type: 'battlePlan', leader: ownLeader.id, dial: allowedProfile.maxDial, support: 0, weapon: null, defense: null };
|
||||
const canceledPlan: Action = { ...allowedPlan, dial: canceledProfile.maxDial };
|
||||
const opponentPlan: Action = { type: 'battlePlan', leader: otherLeader.id, dial: 0, support: 0, weapon: null, defense: null };
|
||||
const source = structuredClone(game), originalBattle = structuredClone(game.battle), originalResponse = structuredClone(game.response);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.deepEqual(semutaEcazEconomyInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, kind, advanced: false as const, owner, ecaz: owner, ally, richese: ally,
|
||||
holder, spender, guild: spender, opponent, recipient: lead, lead, leadChoice, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
action, originalResponse, originalBattle, collection: structuredClone(game.ecazCollection ?? null),
|
||||
originalPhysical: structuredClone(semutaEcazEconomyPhysical(game)), allowedProfile, canceledProfile,
|
||||
allowedPlan, canceledPlan, opponentPlan, territory, sector, location, basicCollectionQuote,
|
||||
initial, lobby, setup, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder,
|
||||
originalInventory, originalSpice, spiceOrder, acquisition, acquired, placement, selfToken: selfToken.id,
|
||||
ecazShipment, alliance, sharedStronghold, opponentShipment, beforeBattle, choice, battleAction, beforeLead, leadAction,
|
||||
quoteHistory: [{ stage: 'shared-stronghold-basic', quote: basicCollectionQuote },
|
||||
{ stage: 'occupy-allowed', quote: allowedProfile }, { stage: 'occupy-canceled', quote: canceledProfile }],
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual public Basic Ecaz/Richese/CHOAM/Emperor/Guild ready/start with original main35/cache10 and no modules. Only unopened starting-card/Spice positions and scalar lotteries are selected. Guild really receives printed Karama; CHOAM really buys cache Semuta for one.',
|
||||
'Ecaz really places its native six in Imperial Basin9, pays for its reusable self-Ambassador at Carthag, then pays two to ship two original reserves to Carthag11. Richese really pays two for its own two-counter entry. Ecaz triggers its printed alliance option, Richese actually accepts, and Ecaz declines the optional Duke loan. The real alliance leaves both native groups in the shared stronghold.',
|
||||
'Emperor really pays one to enter that shared coalition stronghold. Original chooseBattle creates the native lead chooser; Ecaz actually selects the requested Ecaz/Richese lead. Stop at its original Occupy counter before Guild spends printed Karama; no plan is sealed and no combined dial is pre-applied.',
|
||||
'No played hand, wallet, forces, alliance, token, battle, clock or response is installed. The native Basic Collection quote has no Advanced bank bonus. The separate Advanced Collection source uses its explicit original fresh development entry; this Basic source gains no Advanced income.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaEcazOccupyFixture = ReturnType<typeof createSemutaEcazOccupyFixture>;
|
||||
|
||||
/** Explicit fresh Advanced development source, never a public Semuta start or
|
||||
* played Basic conversion. Original R/C/Ecaz/E setup, paid cache, paid city
|
||||
* entries and Ambassador alliance earn the actual shared bank-income source. */
|
||||
export function createSemutaEcazCollectionFixture() {
|
||||
const owner = 'ecaz', ally = 'richese', holder = 'choam', spender = 'emperor';
|
||||
const territory = 'carthag', sector = 11, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTAECAZCOLLECTION', newPlayer(ally, 'Richese', 'richese'), true, ['choam', 'ecaz']);
|
||||
for (const faction of ['choam', 'ecaz', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaEcazEconomyStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaEcazEconomy(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaEcazEconomy(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: 'explicit-original-advanced-ecaz-semuta-initializer' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.equal(game.ecazOccupyPreview, true); assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaEcazEconomyInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3);
|
||||
assert.ok(printed); assert.equal(ordinary.length, 3);
|
||||
const selected = [...ordinary, printed], dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), cost = printed.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (const [position, face] of ['funeral_plain', 'hagga_basin'].entries()) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && card.territory === face && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
let beforeBid: Game | undefined, bid: SemutaEcazEconomyStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaEcazEconomyPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaEcazEconomy(game); assert.ok(step);
|
||||
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaEcazEconomyPlayer(beforeBid, holder).spice - semutaEcazEconomyPlayer(game, holder).spice, 1);
|
||||
reach(state => state.phase === 4 && state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
const selfToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
|
||||
const placement = transition(() => {
|
||||
act(owner, { type: 'decision', token: selfToken.id, territory });
|
||||
reach(state => state.decision?.kind === 'ecazPlacement' && !state.response && !state.phaseOpening);
|
||||
act(owner, { type: 'decision', decline: true });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === ally && semutaEcazEconomyClean(state));
|
||||
const alliance = transition(() => {
|
||||
act(ally, { type: 'ship', territory, sector, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
act(owner, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: owner, choice: 'alliance' });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
|
||||
act(ally, { type: 'decision', event: game.pendingAmbassador!.event, accept: true });
|
||||
reach(semutaEcazEconomyClean);
|
||||
});
|
||||
assert.equal(semutaEcazEconomyPlayer(game, owner).ally, ally); assert.equal(semutaEcazEconomyPlayer(game, ally).ally, owner);
|
||||
const afterAlliance = structuredClone(game); act(ally, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === holder && semutaEcazEconomyClean(state));
|
||||
const ordinaryShipment = transition(() => {
|
||||
act(holder, { type: 'ship', territory: 'funeral_plain', sector: 15, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaEcazEconomyClean); act(holder, { type: 'endMovement' });
|
||||
});
|
||||
assert.equal(semutaEcazEconomyPlayer(ordinaryShipment.before, holder).spice - semutaEcazEconomyPlayer(game, holder).spice, 2);
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaEcazEconomyClean(state));
|
||||
const ecazShipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory, sector, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(semutaEcazEconomyClean); act(owner, { type: 'endMovement' });
|
||||
});
|
||||
const sharedStronghold = structuredClone(game);
|
||||
assert.equal(semutaEcazEconomyPlayer(game, owner).forces[location], 2); assert.equal(semutaEcazEconomyPlayer(game, ally).forces[location], 2);
|
||||
reach(state => state.phase === 7 && state.response?.kind === 'ecazCollection');
|
||||
assert.ok(game.ecazCollection && game.response); assert.equal(game.ecazCollection.stage, 'response');
|
||||
assert.equal(game.response.owner, owner); assert.ok(!game.battle && !game.pendingAmbassador && !game.decision && !game.phaseOpening);
|
||||
const allowedQuote = quoteSpiceCollection(game), canceledQuote = quoteSpiceCollection(game, true);
|
||||
assert.ok(allowedQuote.collectionBonus); assert.equal(allowedQuote.collectionBonus.owner, owner);
|
||||
assert.equal(allowedQuote.collectionBonus.ally, ally);
|
||||
assert.ok(allowedQuote.collectionBonus.amount > 0); assert.deepEqual(allowedQuote.collectionBonus.strongholds, [territory]);
|
||||
assert.deepEqual(allowedQuote.shared, []); assert.deepEqual(canceledQuote.shared, []);
|
||||
assert.ok(canceledQuote.receipts.find(receipt => receipt.player === holder)!.desert > 0);
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response), collection = structuredClone(game.ecazCollection);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.deepEqual(semutaEcazEconomyInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, kind: 'ecazCollection' as const, advanced: true as const, owner, ecaz: owner,
|
||||
ally, richese: ally, holder, spender, opponent: spender, recipient: owner, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
action, originalResponse, originalBattle: null, collection, originalCollection: collection,
|
||||
originalPhysical: structuredClone(semutaEcazEconomyPhysical(game)), allowedQuote, canceledQuote,
|
||||
territory, sector, location, initial, lobby, setup, initialization, beforeStartingDeal, afterSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder, acquisition, acquired,
|
||||
placement, selfToken: selfToken.id, alliance, afterAlliance, ordinaryShipment, ecazShipment, sharedStronghold,
|
||||
quoteHistory: [{ stage: 'collection-allowed', quote: allowedQuote }, { stage: 'collection-canceled', quote: canceledQuote }],
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Explicit original fresh Advanced Ecaz Semuta development initializer on the ready undealt R/C/Ecaz/E lobby, exact CHOAM+Ecaz, original main35/cache10 and no modules. Public start/publication gates remain separate; no played Basic conversion occurs.',
|
||||
'Only unopened original starting-card/Spice positions and scalar lotteries are selected. Emperor really receives printed Karama; CHOAM really purchases cache Semuta for one. Ecaz actually places native six in Basin9 and pays for its self-Ambassador at Carthag.',
|
||||
'Richese really pays two for two original counters entering Carthag11. Ecaz actually triggers its alliance option and Richese accepts; native Advanced logic automatically grants the available Duke without a fabricated Basic loan reply. CHOAM really pays two for one counter at the original Funeral Plain15 spice blow, then Ecaz really pays two for its own two-counter city entry. Their actual shared stronghold earns the special bonus; CHOAM’s real desert cohort separately earns positive ordinary Collection.',
|
||||
'Other real movement and empty Battle controls complete. The native Advanced Collection quote opens its positive shared-stronghold response before credit. Both allowed/canceled quotes are read from that original source; ordinary Collection receipts stay native. No hand, wallet, force, token, ally, leader, phase, income or pending frame is installed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaEcazCollectionFixture = ReturnType<typeof createSemutaEcazCollectionFixture>;
|
||||
export type SemutaEcazEconomyFixture = SemutaEcazOccupyFixture | SemutaEcazCollectionFixture;
|
||||
export function createSemutaEcazEconomyFixture(options: { kind: 'ecazCollection'; lead?: 'ecaz' | 'ally' }): SemutaEcazCollectionFixture;
|
||||
export function createSemutaEcazEconomyFixture(options?: { kind?: 'ecazOccupy'; lead?: 'ecaz' | 'ally' }): SemutaEcazOccupyFixture;
|
||||
export function createSemutaEcazEconomyFixture(options: { kind?: SemutaEcazEconomyKind; lead?: 'ecaz' | 'ally' }): SemutaEcazEconomyFixture;
|
||||
export function createSemutaEcazEconomyFixture(options: { kind?: SemutaEcazEconomyKind; lead?: 'ecaz' | 'ally' } = {}): SemutaEcazEconomyFixture {
|
||||
return options.kind === 'ecazCollection' ? createSemutaEcazCollectionFixture() : createSemutaEcazOccupyFixture(options);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { territory as boardTerritory } from '../game/board';
|
||||
import { reserveShipmentCost } from '../game/shipment-price';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import {
|
||||
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
|
||||
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
|
||||
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
|
||||
} from './fixture-semuta-combat-capabilities';
|
||||
|
||||
export type SemutaMoritaniAllianceStep = { actor: string; action: Action };
|
||||
export const actSemutaMoritaniAlliance = actSemutaCombatCapabilities;
|
||||
export const semutaMoritaniAlliancePlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaMoritaniAllianceInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaMoritaniAllianceClean = semutaCombatCapabilitiesClean;
|
||||
export const passSemutaMoritaniAlliance = passSemutaCombatCapabilities;
|
||||
export const claimSemutaMoritaniAlliance = claimSemutaCombatCapabilities;
|
||||
|
||||
/** Placement, Terror revelation and the entrant's owned group remain explicit
|
||||
* caller actions; other controls finish only through real native choices. */
|
||||
export function nextSemutaMoritaniAlliance(game: Game): SemutaMoritaniAllianceStep | null {
|
||||
if (!game.response && !game.phaseOpening &&
|
||||
(game.decision?.kind === 'moritaniPlacement' || game.decision?.kind === 'moritaniTerror')) return null;
|
||||
return nextSemutaCombatCapabilities(game);
|
||||
}
|
||||
export const nextSemutaMoritaniAllianceStep = nextSemutaMoritaniAlliance;
|
||||
export function advanceSemutaMoritaniAlliance(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaMoritaniAllianceStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaMoritaniAlliance(game);
|
||||
assert.ok(next, `Supply the native Moritani placement/entry choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaMoritaniAlliance(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Enemy of My Enemy programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaMoritaniAlliance(game: Game, actions: SemutaMoritaniAllianceStep[] = []): Game {
|
||||
assert.equal(game.response?.kind, 'moritaniAlliance');
|
||||
for (let count = 0; game.response?.kind === 'moritaniAlliance' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaMoritaniAlliance(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'moritaniAlliance'); return game;
|
||||
}
|
||||
/** Cancellation restores the original hidden-token chooser. Leave that actual
|
||||
* revelation/decline, or an allowed offer's entrant reply, to the caller. */
|
||||
export function finishSemutaMoritaniAlliance(game: Game, actions: SemutaMoritaniAllianceStep[] = []): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
return advanceSemutaMoritaniAlliance(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'moritaniTerror', actions);
|
||||
}
|
||||
|
||||
/** Original public Basic R/C/M/E/G setup, paid cache1, real turn1 Mentat
|
||||
* Robbery placement and committed turn2 entry. No played state is installed. */
|
||||
export function createSemutaMoritaniAllianceFixture(options: { entry?: 'shipment' | 'movement' } = {}) {
|
||||
const entryKind = options.entry ?? 'shipment';
|
||||
const richese = 'richese', holder = 'choam', owner = 'moritani', entrant = 'emperor', spender = 'guild';
|
||||
const territory = 'arrakeen', sector = 10, location = `${territory}:${sector}`, origin = 'imperial_basin:10';
|
||||
let game = createGame('SEMUTAMORITANIALLIANCE', newPlayer(richese, 'Richese', 'richese'), false, ['choam', 'ecaz']);
|
||||
for (const faction of ['choam', 'moritani', 'emperor', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaMoritaniAllianceStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaMoritaniAlliance(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaMoritaniAlliance(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), at = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaMoritaniAllianceInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 4);
|
||||
assert.equal(ordinary.length, 4);
|
||||
const selected = [...ordinary, printed];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { card: card.id, position, from };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), cost = printed.id;
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.ok(semutaMoritaniAlliancePlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.equal(semutaMoritaniAlliancePlayer(game, owner).forces['oh_gap:9'], 6);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
|
||||
!card.discovery && !['oh_gap', 'arrakeen', 'imperial_basin', 'tueks_sietch'].includes(card.territory));
|
||||
assert.ok(from >= position);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaMoritaniAllianceStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaMoritaniAlliancePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaMoritaniAlliance(game); assert.ok(next);
|
||||
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
|
||||
}
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaMoritaniAlliancePlayer(beforeBid, holder).spice - semutaMoritaniAlliancePlayer(game, holder).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 8 && !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'moritaniPlacement');
|
||||
const robbery = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.status === 'available');
|
||||
assert.ok(robbery); const token = robbery.id;
|
||||
const placementAction: Action = { type: 'decision', token, territory };
|
||||
const placement = transition(() => {
|
||||
act(owner, placementAction);
|
||||
reach(state => !state.response && state.moritaniTerror!.tokens.some(row => row.id === token && row.status === 'placed'));
|
||||
});
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && semutaMoritaniAllianceClean(state));
|
||||
assert.ok(!semutaMoritaniAlliancePlayer(game, owner).ally && !semutaMoritaniAlliancePlayer(game, entrant).ally);
|
||||
const shipmentTerritory = entryKind === 'shipment' ? territory : 'imperial_basin';
|
||||
const price = reserveShipmentCost({ faction: 'emperor', halfRate: false }, boardTerritory(shipmentTerritory).type, 1);
|
||||
const shipmentAction: Action = { type: 'ship', territory: shipmentTerritory, sector, amount: 1, elite: 0, allyPayment: 0 };
|
||||
let paidShipment: { before: Game; steps: SemutaMoritaniAllianceStep[]; after: Game };
|
||||
let entry: { before: Game; steps: SemutaMoritaniAllianceStep[]; after: Game };
|
||||
const chooser = (state: Game) => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror' &&
|
||||
state.pendingTerrorEntry?.stage === 'offer';
|
||||
let entryAction: Action;
|
||||
if (entryKind === 'shipment') {
|
||||
entryAction = shipmentAction;
|
||||
entry = transition(() => { act(entrant, shipmentAction); reach(chooser); });
|
||||
paidShipment = entry;
|
||||
} else {
|
||||
paidShipment = transition(() => { act(entrant, shipmentAction); reach(semutaMoritaniAllianceClean); });
|
||||
entryAction = { type: 'move', from: origin, amount: 1, elite: 0, territory, sector };
|
||||
entry = transition(() => { act(entrant, entryAction); reach(chooser); });
|
||||
}
|
||||
assert.equal(semutaMoritaniAlliancePlayer(paidShipment.before, entrant).spice -
|
||||
semutaMoritaniAlliancePlayer(paidShipment.after, entrant).spice, price);
|
||||
assert.equal(semutaMoritaniAlliancePlayer(game, entrant).forces[location], 1);
|
||||
assert.equal(semutaMoritaniAlliancePlayer(game, entrant).shipped, true);
|
||||
assert.equal(semutaMoritaniAlliancePlayer(game, entrant).moved, entryKind === 'movement' ? 1 : 0);
|
||||
const offerAction: Action = { type: 'decision', alliance: true };
|
||||
const offer = transition(() => { act(owner, offerAction); });
|
||||
assert.ok(game.response?.kind === 'moritaniAlliance'); assert.equal(game.response.owner, owner);
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'allianceResponse');
|
||||
assert.equal(game.pendingTerrorEntry?.cause, entryKind); assert.equal(game.pendingTerrorEntry?.token, token);
|
||||
assert.equal(game.decision ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(semutaMoritaniAllianceInventory(game), originalInventory);
|
||||
return { game, kind: 'moritaniAlliance' as const, advanced: false as const, entryKind,
|
||||
owner, recipient: entrant, entrant, holder, spender, richese, guild: spender, cost, karama: cost,
|
||||
semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response), originalEntry: structuredClone(game.pendingTerrorEntry!),
|
||||
originalTerror: structuredClone(game.moritaniTerror!), originalOffers: structuredClone(game.allianceOffers),
|
||||
token, territory, sector, location, origin, price, shipmentAction, entryAction, offerAction,
|
||||
revealAction: { type: 'decision', reveal: true } as Action, revealActor: owner,
|
||||
declineAction: { type: 'decision', decline: true } as Action, declineActor: owner,
|
||||
acceptAction: { type: 'decision', accept: true } as Action, acceptActor: entrant,
|
||||
robberyAction: { type: 'decision', choice: 'spice' } as Action, robberyActor: owner,
|
||||
initial, lobby, setup, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder,
|
||||
originalInventory, originalSpice, spiceOrder, acquisition, acquired, cache: acquisition,
|
||||
placement, paidShipment, entry, offer, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original public Basic R/C/M/E/G E2/E3 setup, unopened main/Spice positions and scalar lotteries only. Guild is actually dealt the printed Karama; CHOAM buys physical cache Semuta for one spice; Moritani places its original six in Oh Gap.',
|
||||
'Native turn1 Mentat actually places the available Robbery token at empty Arrakeen. Original legal Storm dials and ordinary controls reach turn2. Emperor genuinely pays the printed city1 or desert2 shipment tariff and Guild collects its native income.',
|
||||
entryKind === 'shipment' ? 'The actual one-counter Arrakeen shipment opens the hidden-token entry.'
|
||||
: 'The actual paid Imperial Basin10 cohort then makes its ordinary one-territory ground move into Arrakeen10, consuming its real movement index before the entry.',
|
||||
'Moritani actually offers Enemy of My Enemy while both parties are unallied. Stop before the real used cancellation cost. No played hand, force, payment, token, alliance, clock, Terror entry or pending control is installed.',
|
||||
] };
|
||||
}
|
||||
export type SemutaMoritaniAllianceFixture = ReturnType<typeof createSemutaMoritaniAllianceFixture>;
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaE3PlacementFixture as fixture, actSemutaE3Placement as act,
|
||||
passSemutaE3Placement as pass, claimSemutaE3Placement as claim,
|
||||
allowSemutaE3Placement as allow, finishSemutaE3Placement as finish,
|
||||
semutaE3PlacementPlayer as player, semutaE3PlacementInventory as inventory,
|
||||
type SemutaE3PlacementFixture,
|
||||
} from './fixture-semuta-e3-placement';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'ecazPlacement' as const },
|
||||
{ kind: 'ecazPlacement' as const, second: true },
|
||||
{ kind: 'moritaniPlacement' as const },
|
||||
{ kind: 'moritaniDuke' as const },
|
||||
];
|
||||
const physical = (game: Game) => ({ turn: game.turn, phase: game.phase, storm: game.storm,
|
||||
order: game.order, active: game.active, ready: game.ready, movementRemaining: game.movementRemaining,
|
||||
ecazAmbassadors: game.ecazAmbassadors, ecazPlacementTurn: game.ecazPlacementTurn,
|
||||
pendingEcazPlacement: game.pendingEcazPlacement, moritaniTerror: game.moritaniTerror,
|
||||
pendingMoritaniPlacement: game.pendingMoritaniPlacement, dukeVidal: game.dukeVidal,
|
||||
dukeAcquisitionTurn: game.dukeAcquisitionTurn, freeRevival: game.freeRevival,
|
||||
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
forces: player.forces, reserves: player.reserves, tanks: player.tanks, elites: player.elites,
|
||||
leaders: player.leaders, ally: player.ally, shipped: player.shipped, moved: player.moved, revived: player.revived })) });
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaE3PlacementFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.game.advanced, false); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', f.owner, 'emperor']);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.equal(f.acquisition.price, 1);
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(f.originalDuke?.leader.dead ?? false, false);
|
||||
if (f.kind === 'ecazPlacement') {
|
||||
assert.equal(f.game.phase, 4); assert.ok(f.game.pendingEcazPlacement && f.token && f.territory);
|
||||
assert.equal(f.game.pendingEcazPlacement.cost, f.second ? 2 : 1);
|
||||
assert.equal(f.game.pendingEcazPlacement.token, f.token); assert.equal(f.game.pendingEcazPlacement.territory, f.territory);
|
||||
assert.equal(f.game.ecazAmbassadors?.tokens.find(token => token.id === f.token)?.zone, 'supply');
|
||||
assert.deepEqual(player(f.game, f.owner), player(f.declaration.before, f.owner), 'The pending placement is unpaid');
|
||||
if (f.second) {
|
||||
assert.ok(f.firstPlacement);
|
||||
assert.ok(f.firstPlacement.steps.some(step => step.action.type === 'passResponse'));
|
||||
assert.equal(player(f.firstPlacement.before, f.owner).spice - player(f.firstPlacement.after, f.owner).spice, 1);
|
||||
assert.equal(f.game.ecazAmbassadors?.placement?.count, 1);
|
||||
assert.equal(f.game.ecazAmbassadors?.tokens.find(token => token.effect === 'ecaz')?.location, 'sietch_tabr');
|
||||
} else assert.equal(f.firstPlacement, null);
|
||||
} else if (f.kind === 'moritaniPlacement') {
|
||||
assert.equal(f.game.phase, 8); assert.ok(f.game.pendingMoritaniPlacement && f.token);
|
||||
assert.equal(player(f.afterSetup, f.owner).forces['oh_gap:9'], 6);
|
||||
assert.equal(f.game.pendingMoritaniPlacement.token, f.token);
|
||||
assert.equal(f.game.moritaniTerror?.tokens.find(token => token.id === f.token)?.status, 'available');
|
||||
assert.equal(f.game.moritaniTerror?.tokens.find(token => token.id === f.token)?.location, null);
|
||||
assert.deepEqual(player(f.game, f.owner), player(f.declaration.before, f.owner));
|
||||
} else {
|
||||
assert.equal(f.game.phase, 5); assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 6);
|
||||
assert.equal(f.shipments.length, 3);
|
||||
for (const shipment of f.shipments) {
|
||||
const step = shipment.steps.find(step => step.action.type === 'ship'); assert.ok(step);
|
||||
assert.equal(step.action.amount, 1);
|
||||
assert.equal(player(shipment.before, step.actor).spice - player(shipment.after, step.actor).spice, 1);
|
||||
}
|
||||
assert.equal(player(f.game, f.richese).forces['carthag:11'], 1);
|
||||
assert.equal(player(f.game, f.owner).forces['carthag:11'], 1);
|
||||
assert.equal(player(f.game, f.owner).forces['arrakeen:10'], 6);
|
||||
assert.equal(player(f.game, f.spender).forces['arrakeen:10'], 1);
|
||||
assert.deepEqual(f.game.movementRemaining, []); assert.equal(f.game.dukeAcquisitionTurn, 1);
|
||||
assert.equal(f.originalDuke?.controller, null); assert.equal(f.originalDuke?.source, null);
|
||||
assert.equal(f.originalDuke?.leader.strength, 6);
|
||||
assert.deepEqual(f.declaration.steps, [{ actor: f.spender, action: { type: 'endMovement' } }]);
|
||||
}
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaE3PlacementFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaEcazMoritaniDiscard');
|
||||
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, `karama:${f.kind}`);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
if (pending.continuation.kind !== 'karamaEcazMoritaniDiscard') throw Error('Use the original native E3 cost');
|
||||
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function unchangedForcesAndFunds(game: Game, f: SemutaE3PlacementFixture, paid = 0) {
|
||||
for (const current of game.players) {
|
||||
const original = player(f.game, current.id);
|
||||
assert.equal(current.spice, original.spice - (current.id === f.owner ? paid : 0));
|
||||
assert.deepEqual(current.forces, original.forces); assert.deepEqual(current.elites, original.elites);
|
||||
assert.equal(current.reserves, original.reserves); assert.equal(current.tanks, original.tanks);
|
||||
assert.equal(current.revived, original.revived); assert.deepEqual(current.leaders, original.leaders);
|
||||
assert.equal(current.ally, original.ally);
|
||||
}
|
||||
assert.deepEqual(game.freeRevival, f.game.freeRevival); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.deepEqual(game.spiceDeck, f.game.spiceDeck); assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard);
|
||||
}
|
||||
function nativeBoundary(game: Game, f: SemutaE3PlacementFixture) {
|
||||
assert.equal(game.phase, f.kind === 'ecazPlacement' ? 5 : f.kind === 'moritaniPlacement' ? 8 : 6);
|
||||
assert.equal(game.turn, 1); assert.equal(game.status, 'playing');
|
||||
if (f.kind === 'ecazPlacement') {
|
||||
assert.equal(game.ecazPlacementTurn, 1); assert.equal(game.pendingEcazPlacement ?? null, null);
|
||||
assert.deepEqual(game.movementRemaining, game.order); assert.equal(game.active, game.order[0]);
|
||||
assert.ok(game.players.every(player => player.shipped === false && player.moved === 0));
|
||||
if (f.second) assert.equal(game.ecazAmbassadors?.tokens.find(token => token.effect === 'ecaz')?.location, 'sietch_tabr');
|
||||
} else if (f.kind === 'moritaniPlacement') {
|
||||
assert.equal(game.moritaniTerror?.placementTurn, 1); assert.equal(game.pendingMoritaniPlacement ?? null, null);
|
||||
assert.deepEqual(game.ready, f.game.ready);
|
||||
} else {
|
||||
assert.equal(game.dukeAcquisitionTurn, 1); assert.deepEqual(game.movementRemaining, []);
|
||||
assert.equal(game.choamMarket ?? null, null);
|
||||
assert.equal(game.battle ?? null, null);
|
||||
assert.equal(player(game, f.owner).forces['arrakeen:10'], 6); assert.equal(player(game, f.owner).forces['carthag:11'], 1);
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.notEqual(game.response?.kind, f.kind);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
function dukeClosingMarket(game: Game, f: SemutaE3PlacementFixture) {
|
||||
assert.equal(game.phase, 5); assert.equal(game.turn, 1);
|
||||
assert.deepEqual(game.movementRemaining, []); assert.equal(game.dukeAcquisitionTurn, 1);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision?.kind, 'choamMarket');
|
||||
assert.equal(game.decision?.player, f.holder); assert.equal(game.choamMarket?.owner, f.holder);
|
||||
assert.equal(game.choamMarket?.resume, 'phase');
|
||||
}
|
||||
function prevented(game: Game, f: SemutaE3PlacementFixture, claimed: boolean) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
unchangedForcesAndFunds(game, f);
|
||||
if (f.expected.kind === 'ecazPlacement') {
|
||||
assert.deepEqual(game.ecazAmbassadors, f.expected.ambassadors);
|
||||
assert.equal(game.ecazAmbassadors?.placement?.blocked, true);
|
||||
assert.equal(game.ecazAmbassadors?.tokens.find(token => token.id === f.token)?.zone, 'supply');
|
||||
} else if (f.expected.kind === 'moritaniPlacement') {
|
||||
assert.deepEqual(game.moritaniTerror, f.expected.terror);
|
||||
assert.deepEqual(game.moritaniTerror?.tokens, f.originalTerror?.tokens);
|
||||
} else {
|
||||
assert.deepEqual(game.dukeVidal, f.originalDuke);
|
||||
dukeClosingMarket(game, f);
|
||||
}
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
game = finish(game, f.kind, steps);
|
||||
assert.deepEqual(steps, f.kind === 'moritaniDuke'
|
||||
? [{ actor: f.holder, action: { type: 'decision', done: true } }] : []);
|
||||
nativeBoundary(game, f);
|
||||
assert.deepEqual(finish(game, f.kind), game);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`Basic native ${variant.kind}${'second' in variant ? ' second paid opportunity' : ''} cost ${claimed ? 'claim' : 'pass'} resumes original denial and phase once`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
reject(game, f.kind === 'moritaniDuke' ? f.spender : f.owner, f.nativeAction);
|
||||
});
|
||||
|
||||
void test('native E3 placement and Duke allowance controls commit only the original quoted payment, token or shared disc', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
let game = allow(structuredClone(f.game));
|
||||
const paid = f.kind === 'ecazPlacement' ? f.game.pendingEcazPlacement!.cost : 0;
|
||||
unchangedForcesAndFunds(game, f, paid);
|
||||
if (f.kind === 'ecazPlacement') {
|
||||
assert.equal(game.decision?.kind, 'ecazPlacement'); assert.equal(game.pendingEcazPlacement ?? null, null);
|
||||
const token = game.ecazAmbassadors?.tokens.find(token => token.id === f.token); assert.ok(token);
|
||||
assert.equal(token.zone, 'placed'); assert.equal(token.location, f.territory);
|
||||
assert.equal(game.ecazAmbassadors?.placement?.count, f.second ? 2 : 1);
|
||||
} else if (f.kind === 'moritaniPlacement') {
|
||||
const token = game.moritaniTerror?.tokens.find(token => token.id === f.token); assert.ok(token);
|
||||
assert.equal(token.status, 'placed'); assert.equal(token.location, f.territory);
|
||||
assert.equal(game.moritaniTerror?.placementTurn, 1);
|
||||
} else {
|
||||
assert.equal(game.dukeVidal?.controller, f.owner); assert.equal(game.dukeVidal?.source, 'moritani');
|
||||
assert.equal(game.dukeVidal?.acquiredTurn, 1); assert.deepEqual(game.dukeVidal?.leader, f.originalDuke?.leader);
|
||||
dukeClosingMarket(game, f);
|
||||
}
|
||||
game = finish(game, f.kind); nativeBoundary(game, f);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh E3 costs reject stale and underlying replies while original token, prior payment and Duke sources stay parked', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event });
|
||||
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.kind === 'moritaniDuke' ? f.spender : f.owner, f.nativeAction);
|
||||
reject(game, f.owner, { type: 'decision', decline: true });
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
prevented(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer each original Basic E3 placement and acquisition cancellation cost legally', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(choosing)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, initializeSemutaGameForAudit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaEcazEconomyFixture as fixture, actSemutaEcazEconomy as act,
|
||||
passSemutaEcazEconomy as pass, claimSemutaEcazEconomy as claim,
|
||||
allowSemutaEcazEconomy as allow, finishSemutaEcazEconomy as finish,
|
||||
semutaEcazEconomyPlayer as player, semutaEcazEconomyInventory as inventory,
|
||||
semutaEcazEconomyPhysical as physical, semutaEcazEconomyProfile as profile,
|
||||
type SemutaEcazOccupyFixture as SemutaEcazEconomyFixture,
|
||||
type SemutaEcazEconomyFixture as SemutaEcazEconomySource,
|
||||
type SemutaEcazCollectionFixture,
|
||||
} from './fixture-semuta-ecaz-economy';
|
||||
|
||||
const leads = ['ecaz', 'ally'] as const;
|
||||
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||
order: game.order, movementRemaining: game.movementRemaining, storm: game.storm,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard });
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaEcazEconomyFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.equal(f.setup.ecazOccupyPreview, true); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.deepEqual(player(f.afterSetup, f.owner).forces, { 'imperial_basin:9': 6 });
|
||||
assert.equal(player(f.afterSetup, f.owner).reserves, 14);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.ally).spice - player(f.acquisition.before, f.ally).spice, 1);
|
||||
assert.equal(f.placement.steps[0].action.token, f.selfToken); assert.equal(f.placement.steps[0].action.territory, f.territory);
|
||||
assert.equal(player(f.ecazShipment.before, f.owner).spice - player(f.ecazShipment.after, f.owner).spice, 2);
|
||||
assert.equal(player(f.alliance.before, f.ally).spice - player(f.alliance.after, f.ally).spice, 2);
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.trigger === true && step.action.choice === 'alliance'));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.ally && step.action.accept === true));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.loan === false));
|
||||
assert.equal(player(f.sharedStronghold, f.owner).ally, f.ally); assert.equal(player(f.sharedStronghold, f.ally).ally, f.owner);
|
||||
assert.equal(player(f.sharedStronghold, f.owner).forces[f.location], 2);
|
||||
assert.equal(player(f.sharedStronghold, f.ally).forces[f.location], 2);
|
||||
assert.equal(f.basicCollectionQuote.collectionBonus, null, 'Basic does not gain the Advanced bank-income source');
|
||||
assert.equal(player(f.opponentShipment.before, f.opponent).spice - player(f.opponentShipment.after, f.opponent).spice, 1);
|
||||
assert.equal(player(f.game, f.opponent).forces[f.location], 1);
|
||||
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead'); assert.equal(f.beforeLead.decision?.player, f.owner);
|
||||
assert.equal(f.leadAction.lead, f.lead); assert.equal(f.game.battle?.ecazOccupy?.lead, f.lead);
|
||||
assert.equal(f.game.battle?.ecazOccupy?.canceled, false); assert.equal(f.game.battle?.revealed, false);
|
||||
assert.deepEqual(f.game.battle?.plans, {}); assert.deepEqual(f.game.battle, f.originalBattle);
|
||||
assert.deepEqual(f.game.response, f.originalResponse); assert.equal(f.game.response?.kind, 'ecazOccupy');
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 6); assert.equal(f.game.pendingAmbassador ?? null, null);
|
||||
assert.equal(f.allowedProfile.fixedEcazDial, 1); assert.equal(f.allowedProfile.forceOwner, f.ally);
|
||||
assert.equal(f.allowedProfile.maxDial, 3); assert.equal(f.canceledProfile.maxDial, 2);
|
||||
assert.equal(f.canceledProfile.forceOwner, f.lead); assert.equal(f.canceledProfile.fixedEcazDial, 0);
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaEcazEconomySource) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaEcazMoritaniDiscard');
|
||||
if (pending.continuation.kind !== 'karamaEcazMoritaniDiscard') throw Error('Expected the original Ecaz cost');
|
||||
assert.equal(pending.batch.cause, `karama:${f.kind}`); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.battle, f.originalBattle); assert.deepEqual(game.ecazCollection ?? null, f.collection);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function planEntry(state: Game, f: SemutaEcazEconomyFixture, canceled: boolean) {
|
||||
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.equal(state.battle?.ecazOccupy?.lead, f.lead);
|
||||
assert.equal(state.battle?.ecazOccupy?.canceled, canceled); assert.deepEqual(state.battle?.plans, {});
|
||||
assert.equal(state.battle?.revealed, false); assert.deepEqual(physical(state), f.originalPhysical);
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.phase, 6); assert.equal(game.turn, 1); assert.equal(game.battle?.event, f.originalBattle.event);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.battle?.ecazOccupy?.lead, f.lead); assert.equal(game.battle?.ecazOccupy?.canceled, canceled);
|
||||
assert.equal(game.battle?.revealed, false); assert.deepEqual(game.battle?.plans, {});
|
||||
assert.deepEqual(profile(game), canceled ? f.canceledProfile : f.allowedProfile);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.deepEqual(new Set(game.battle?.preLeader?.ready), new Set([f.lead, f.opponent]));
|
||||
assert.ok(steps.every(step => step.action.type !== 'battlePlan' && step.action.type !== 'card'));
|
||||
const otherAlly = f.lead === f.owner ? f.ally : f.owner;
|
||||
reject(game, otherAlly, f.canceledPlan);
|
||||
if (canceled) reject(game, f.lead, f.allowedPlan);
|
||||
const plan = canceled ? f.canceledPlan : f.allowedPlan;
|
||||
const sealed = act(structuredClone(game), f.lead, plan);
|
||||
assert.equal(sealed.battle?.plans[f.lead]?.dial, plan.dial); assert.equal(sealed.battle?.revealed, false);
|
||||
assert.deepEqual(physical(sealed), f.originalPhysical); assert.deepEqual(inventory(sealed), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaEcazEconomyFixture, claimed: boolean) {
|
||||
const game = planEntry(state, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const lead of leads) for (const claimed of [false, true]) void test(
|
||||
`Basic native Occupy ${lead} lead cost Semuta ${claimed ? 'claim' : 'pass'} retains the lead and only disables the combined dial`, () => {
|
||||
const f = fixture({ kind: 'ecazOccupy', lead }); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Basic Occupy allowance keeps its mandatory free Ecaz contribution and actual Richese variable pool', () => {
|
||||
for (const lead of leads) {
|
||||
const f = fixture({ kind: 'ecazOccupy', lead }); nativeSource(f);
|
||||
const game = planEntry(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Occupy cost reactions reject stale foreign and underlying lead or plan replies before native preparation', () => {
|
||||
for (const lead of leads) {
|
||||
const f = fixture({ kind: 'ecazOccupy', lead }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, f.leadAction); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.lead, f.allowedPlan); reject(game, f.opponent, f.opponentPlan);
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass Basic native Occupy costs for either earned lead', () => {
|
||||
for (const lead of leads) {
|
||||
const f = fixture({ kind: 'ecazOccupy', lead }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
|
||||
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, proposed);
|
||||
const claimed = proposed.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
denied(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function nativeCollectionSource(f: SemutaEcazCollectionFixture) {
|
||||
assert.equal(f.lobby.advanced, true); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.equal(f.setup.status, 'setup'); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'ecaz', 'emperor']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.deepEqual(player(f.afterSetup, f.owner).forces, { 'imperial_basin:9': 6 });
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquired, f.ally).spice - player(f.acquisition.before, f.ally).spice, 1);
|
||||
assert.equal(player(f.alliance.before, f.ally).spice - player(f.alliance.after, f.ally).spice, 2);
|
||||
assert.equal(player(f.ecazShipment.before, f.owner).spice - player(f.ecazShipment.after, f.owner).spice, 2);
|
||||
assert.equal(player(f.ordinaryShipment.before, f.holder).spice - player(f.ordinaryShipment.after, f.holder).spice, 2);
|
||||
assert.ok(f.ordinaryShipment.steps.some(step => step.actor === f.holder && step.action.type === 'ship' && step.action.amount === 1));
|
||||
assert.equal(player(f.game, f.holder).forces['funeral_plain:15'], 1);
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.owner && step.action.trigger === true && step.action.choice === 'alliance'));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.ally && step.action.accept === true));
|
||||
assert.ok(f.alliance.steps.every(step => step.action.loan === undefined), 'Advanced alliance needs no fabricated Basic loan reply');
|
||||
assert.equal(player(f.game, f.owner).ally, f.ally); assert.equal(player(f.game, f.ally).ally, f.owner);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 2); assert.equal(player(f.game, f.ally).forces[f.location], 2);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 7); assert.equal(f.game.battle ?? null, null);
|
||||
assert.equal(f.game.pendingAmbassador ?? null, null); assert.equal(f.game.phaseOpening ?? null, null);
|
||||
assert.equal(f.game.response?.kind, 'ecazCollection'); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.deepEqual(f.game.ecazCollection, f.collection); assert.equal(f.collection.stage, 'response');
|
||||
assert.deepEqual(f.allowedQuote.collectionBonus?.strongholds, [f.territory]);
|
||||
assert.ok(f.allowedQuote.collectionBonus && f.allowedQuote.collectionBonus.amount > 0);
|
||||
const allowed = f.allowedQuote.receipts.find(receipt => receipt.player === f.owner)!;
|
||||
const canceled = f.canceledQuote.receipts.find(receipt => receipt.player === f.owner)!;
|
||||
assert.equal(allowed.balance - canceled.balance, f.allowedQuote.collectionBonus.amount);
|
||||
assert.ok(f.canceledQuote.receipts.find(receipt => receipt.player === f.ally)!.strongholds > 0,
|
||||
'The canceled special benefit still has native ordinary allied city income');
|
||||
assert.ok(f.canceledQuote.receipts.find(receipt => receipt.player === f.holder)!.desert > 0,
|
||||
'An actual paid CHOAM desert cohort gives nonzero ordinary Collection independent of the bank bonus');
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
const collectionForces = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces, elites: p.elites,
|
||||
reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, ally: p.ally }));
|
||||
function collectionOutcome(state: Game, f: SemutaEcazCollectionFixture, canceled: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
const quote = canceled ? f.canceledQuote : f.allowedQuote;
|
||||
assert.equal(game.phase, 7); assert.equal(game.turn, 1); assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.decision ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(game.battle ?? null, null); assert.equal(game.ecazCollection?.event, f.collection.event);
|
||||
assert.equal(game.ecazCollection?.stage, 'complete'); assert.equal(game.ecazCollection?.canceled, canceled);
|
||||
assert.equal(game.ecazCollection?.allocation, null); assert.deepEqual(game.ecazCollection?.settled, []);
|
||||
assert.deepEqual(game.spice, quote.spice); assert.deepEqual(collectionForces(game), collectionForces(f.game));
|
||||
assert.deepEqual(game.ecazAmbassadors, f.game.ecazAmbassadors); assert.deepEqual(game.dukeVidal, f.game.dukeVidal);
|
||||
for (const receipt of quote.receipts) assert.equal(player(game, receipt.player).spice, receipt.balance);
|
||||
assert.deepEqual(clock(game), clock(f.game));
|
||||
assert.ok(steps.every(step => step.action.type !== 'ship' && step.action.type !== 'card'));
|
||||
const balances = game.players.map(p => [p.id, p.spice]), ready = act(structuredClone(game), f.owner, { type: 'ready' });
|
||||
assert.equal(ready.phase, 7); assert.deepEqual(ready.players.map(p => [p.id, p.spice]), balances,
|
||||
'An original Collection acknowledgement cannot collect or initialize income again');
|
||||
assert.deepEqual(ready.ecazCollection, game.ecazCollection);
|
||||
reject(game, f.owner, { type: 'passResponse' }); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function collectionDenied(state: Game, f: SemutaEcazCollectionFixture, claimed: boolean) {
|
||||
const game = collectionOutcome(state, f, true);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native Ecaz Collection cost Semuta ${claimed ? 'claim' : 'pass'} credits only the native ordinary collection once`, () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }); nativeCollectionSource(f); const offered = open(f);
|
||||
collectionDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Advanced Ecaz Collection allowance credits the quoted real shared-stronghold bonus once', () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }); nativeCollectionSource(f);
|
||||
const game = collectionOutcome(allow(structuredClone(f.game)), f, false);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
});
|
||||
|
||||
void test('native Advanced Ecaz Collection cost rejects stale foreign and underlying acknowledgements before income', () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' });
|
||||
const done = collectionDenied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass native Advanced Ecaz Collection costs', () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
|
||||
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, proposed);
|
||||
const claimed = proposed.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
collectionDenied(game, f, claimed);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh Advanced Ecaz Semuta initialization preserves original undealt inventories and public preview behavior', () => {
|
||||
const f = fixture({ kind: 'ecazCollection' }); nativeCollectionSource(f);
|
||||
reject(f.lobby, f.lobby.host, { type: 'start' });
|
||||
const ordinaryPreview = act(structuredClone(f.lobby), f.lobby.host, { type: 'start', advancedPreview: true });
|
||||
assert.equal(ordinaryPreview.advanced, true); assert.equal(ordinaryPreview.status, 'setup');
|
||||
assert.equal(ordinaryPreview.semutaPreview, undefined, 'Normal Advanced preview does not silently gain the private Semuta profile');
|
||||
assert.deepEqual(inventory(ordinaryPreview), f.originalInventory);
|
||||
const before = structuredClone(f.setup);
|
||||
assert.throws(() => initializeSemutaGameForAudit(f.setup)); assert.deepEqual(f.setup, before,
|
||||
'The original development initializer cannot overwrite an initialized inventory');
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { quoteMoritaniAllianceCancellation } from '../game/moritani-alliance-cancellation';
|
||||
import {
|
||||
createSemutaMoritaniAllianceFixture as fixture, actSemutaMoritaniAlliance as act,
|
||||
passSemutaMoritaniAlliance as pass, claimSemutaMoritaniAlliance as claim,
|
||||
allowSemutaMoritaniAlliance as allow, finishSemutaMoritaniAlliance as finish,
|
||||
semutaMoritaniAlliancePlayer as player, semutaMoritaniAllianceInventory as inventory,
|
||||
type SemutaMoritaniAllianceFixture,
|
||||
} from './fixture-semuta-moritani-alliance';
|
||||
|
||||
const variants = [{ entry: 'shipment' as const }, { entry: 'movement' as const }];
|
||||
const pieces = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves,
|
||||
elites: p.elites, tanks: p.tanks, leaders: p.leaders, shipped: p.shipped, moved: p.moved }));
|
||||
const physical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order, ready: game.ready,
|
||||
movementRemaining: game.movementRemaining, spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
pieces: pieces(game), balances: game.players.map(p => [p.id, p.spice]),
|
||||
allies: game.players.map(p => [p.id, p.ally, p.allySinceTurn]), offers: game.allianceOffers, aid: game.aid,
|
||||
terror: game.moritaniTerror, arrival: game.pendingArrivalReaction,
|
||||
});
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaMoritaniAllianceFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'moritani', 'emperor', 'guild']);
|
||||
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.equal(player(f.afterSetup, f.owner).forces['oh_gap:9'], 6);
|
||||
assert.equal(Object.keys(player(f.afterSetup, f.entrant).forces).length, 0);
|
||||
assert.equal(Object.keys(player(f.afterSetup, f.richese).forces).length, 0);
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.equal(f.placement.before.turn, 1); assert.equal(f.placement.before.phase, 8);
|
||||
assert.ok(f.placement.steps.some(step => step.actor === f.owner && step.action.type === 'decision' &&
|
||||
step.action.token === f.token && step.action.territory === f.territory));
|
||||
const placed = f.placement.after.moritaniTerror!.tokens.find(token => token.id === f.token);
|
||||
assert.equal(placed?.kind, 'robbery'); assert.equal(placed?.status, 'placed'); assert.equal(placed?.location, f.territory);
|
||||
assert.equal(f.originalTerror.placementTurn, 1);
|
||||
assert.ok(f.paidShipment.steps.some(step => step.actor === f.entrant && step.action.type === 'ship' && step.action.amount === 1));
|
||||
assert.equal(player(f.paidShipment.before, f.entrant).spice - player(f.paidShipment.after, f.entrant).spice, f.price);
|
||||
assert.equal(player(f.paidShipment.after, f.guild).spice - player(f.paidShipment.before, f.guild).spice, f.price,
|
||||
'The ordinary shipment pays its actual tariff to the native Guild');
|
||||
assert.equal(player(f.paidShipment.before, f.entrant).reserves - player(f.paidShipment.after, f.entrant).reserves, 1);
|
||||
assert.equal(f.price, f.entryKind === 'shipment' ? 1 : 2);
|
||||
assert.equal(f.entry.steps[0].actor, f.entrant); assert.deepEqual(f.entry.steps[0].action, f.entryAction);
|
||||
if (f.entryKind === 'movement') {
|
||||
assert.equal(player(f.entry.before, f.entrant).forces[f.origin], 1);
|
||||
assert.equal(player(f.game, f.entrant).forces[f.origin] ?? 0, 0);
|
||||
assert.ok(f.entry.steps.some(step => step.action.type === 'move'));
|
||||
}
|
||||
assert.equal(player(f.game, f.entrant).forces[f.location], 1);
|
||||
assert.equal(player(f.game, f.entrant).shipped, true); assert.equal(player(f.game, f.entrant).moved, Number(f.entryKind === 'movement'));
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.advanced, false);
|
||||
assert.deepEqual(f.offer.steps, [{ actor: f.owner, action: f.offerAction }]);
|
||||
assert.equal(f.game.response?.kind, 'moritaniAlliance'); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.originalEntry.stage, 'allianceResponse'); assert.equal(f.originalEntry.cause, f.entryKind);
|
||||
assert.equal(f.originalEntry.amount, 1); assert.equal(f.originalEntry.elite, 0); assert.equal(f.originalEntry.entrant, f.entrant);
|
||||
assert.equal(f.originalEntry.territory, f.territory); assert.equal(f.originalEntry.token, f.token);
|
||||
assert.deepEqual(f.game.pendingTerrorEntry, f.originalEntry); assert.deepEqual(f.game.moritaniTerror, f.originalTerror);
|
||||
assert.equal(player(f.game, f.owner).ally, null); assert.equal(player(f.game, f.entrant).ally, null);
|
||||
assert.deepEqual(f.game.allianceOffers, f.originalOffers);
|
||||
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaMoritaniAllianceFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaEcazMoritaniDiscard');
|
||||
if (pending.continuation.kind !== 'karamaEcazMoritaniDiscard') throw Error('Use the actual Moritani alliance cost');
|
||||
assert.equal(pending.batch.cause, 'karama:moritaniAlliance'); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
|
||||
[{ card: f.cost, by: f.spender, face: true }]);
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(game.pendingTerrorEntry, f.originalEntry); assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaMoritaniAllianceFixture, claimed: boolean) {
|
||||
const quote = quoteMoritaniAllianceCancellation(f.game, f.originalResponse); assert.ok(quote);
|
||||
assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.equal(state.response ?? null, null);
|
||||
assert.deepEqual(state.pendingTerrorEntry, quote.entry); assert.deepEqual(state.decision, quote.decision);
|
||||
assert.deepEqual(physical(state), physical(f.game)); assert.deepEqual(state.moritaniTerror, f.originalTerror);
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.deepEqual(steps, []); assert.deepEqual(game.decision, quote.decision);
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer'); assert.equal(game.pendingTerrorEntry?.allianceBlocked, true);
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
|
||||
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
|
||||
reject(game, f.owner, f.offerAction); reject(game, f.spender, f.action);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function revealOnce(source: Game, f: SemutaMoritaniAllianceFixture) {
|
||||
let game = act(source, f.revealActor, f.revealAction);
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'robbery'); assert.equal(game.decision?.player, f.owner);
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.token)?.status, 'removed');
|
||||
assert.deepEqual(pieces(game), pieces(f.game));
|
||||
const amount = Math.ceil(player(game, f.entrant).spice / 2), before = structuredClone(game);
|
||||
assert.ok(amount > 0); game = act(game, f.robberyActor, f.robberyAction);
|
||||
assert.equal(player(game, f.entrant).spice, player(before, f.entrant).spice - amount);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice + amount);
|
||||
for (const p of game.players.filter(p => ![f.owner, f.entrant].includes(p.id)))
|
||||
assert.equal(p.spice, player(before, p.id).spice);
|
||||
assert.deepEqual(pieces(game), pieces(f.game)); assert.equal(game.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(game.turn, f.game.turn); assert.equal(game.phase, f.game.phase); assert.equal(game.active, f.entrant);
|
||||
assert.equal(player(game, f.owner).ally, null); assert.equal(player(game, f.entrant).ally, null);
|
||||
assert.deepEqual(game.allianceOffers, f.originalOffers);
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.token)?.status, 'removed');
|
||||
reject(game, f.owner, f.revealAction); reject(game, f.owner, f.robberyAction);
|
||||
reject(game, f.entrant, f.entryAction); assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`Basic native Enemy of My Enemy after actual ${variant.entry} cost Semuta ${claimed ? 'claim' : 'pass'} restores the original reveal choice and resolves Robbery once`, () => {
|
||||
const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
|
||||
revealOnce(game, f);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native Enemy of My Enemy allowance leaves the committed entry in place and lets the entrant actually accept the alliance', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f); let game = allow(structuredClone(f.game));
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'allianceReply'); assert.equal(game.decision?.player, f.entrant);
|
||||
assert.deepEqual(physical(game), physical(f.game)); assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
game = act(game, f.acceptActor, f.acceptAction);
|
||||
assert.equal(player(game, f.owner).ally, f.entrant); assert.equal(player(game, f.entrant).ally, f.owner);
|
||||
assert.equal(player(game, f.owner).allySinceTurn, 2); assert.equal(player(game, f.entrant).allySinceTurn, 2);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null); assert.deepEqual(pieces(game), pieces(f.game));
|
||||
assert.deepEqual(game.players.map(p => [p.id, p.spice]), f.game.players.map(p => [p.id, p.spice]));
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.kind === 'robbery')?.status, 'available');
|
||||
assert.equal(game.moritaniTerror!.tokens.filter(token => token.status === 'placed' && token.location === f.territory).length, 0);
|
||||
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.acceptActor, f.acceptAction);
|
||||
}
|
||||
});
|
||||
|
||||
void test('after prevented Enemy of My Enemy Moritani may really decline and retain the original hidden token', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), state = canceled(pass(open(f)), f, false);
|
||||
const game = act(state, f.declineActor, f.declineAction);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null); assert.deepEqual(game.moritaniTerror, f.originalTerror);
|
||||
assert.deepEqual(physical(game), physical(f.game)); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.owner, f.revealAction); reject(game, f.entrant, f.entryAction);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh Enemy of My Enemy cost rejects stale and underlying choices while the original hidden-token offer stays parked', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
|
||||
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
|
||||
reject(game, f.holder, { type: 'semutaSelect', event, card: f.token });
|
||||
reject(game, f.owner, f.offerAction); reject(game, f.revealActor, f.revealAction);
|
||||
reject(game, f.declineActor, f.declineAction); reject(game, f.acceptActor, f.acceptAction);
|
||||
reject(game, f.spender, { type: 'passResponse' }); reject(game, f.entrant, { type: 'endMovement' });
|
||||
assert.deepEqual(game.pendingTerrorEntry, f.originalEntry); assert.deepEqual(physical(game), physical(f.game));
|
||||
canceled(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer native committed-shipment and ground-entry alliance costs', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit');
|
||||
assert.equal(action.event, event);
|
||||
let game = act(structuredClone(offered), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const selected = botActions(selecting)[0]; assert.ok(selected);
|
||||
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
|
||||
game = act(game, f.holder, selected);
|
||||
} else if (game.pendingTreacheryDiscard) game = pass(game);
|
||||
canceled(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user