Offer Semuta on pending revival benefit cancellation costs
This commit is contained in:
1 parent
d018a506e1
commit
4d09b6bffa
9 files changed
+1152
-5
No files matched your search
@@ -16,6 +16,45 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Semuta on pending revival-benefit cancellation costs — 26 October. Prototyped / Partial.**
|
||||
Printed-role Karama stopping native CHOAM revival, Tleilaxu granted limits or
|
||||
discounts, early leaders or Advanced foreign Ghola now offers its exact used
|
||||
cost. `karamaRevivalDiscard` retains the original unpaid request and remaining
|
||||
checks. Claim/pass restores that response and finishes native prevention once;
|
||||
native quotes decide abandonment, repricing, funding, later checks and actual
|
||||
return/payment/income. No return or payment is pre-applied for Semuta. Existing
|
||||
persistent controls and minimal profiles serve these five kinds.
|
||||
[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#pending-revival-benefit-cancellation-costs--26-october-2026).
|
||||
|
||||
Two disjoint source lanes, bounded native/diff review and root integration pass
|
||||
**15 files,248/248**, types/lint/build and **26 native engine/GameTable SSR/actual
|
||||
Semuta-callback journeys** on unchanged source. Thirty-three new cases cover
|
||||
claim/pass, applicable rule bands, affordable/unfunded repricing, above-cap
|
||||
abandonment, real smaller alternatives, native allowance, relevant rejection and
|
||||
all four minimal profiles. Actual cache1 Semuta and original dealt Karama precede
|
||||
every cost. CHOAM220/230 Basic/Advanced actions include four paid shipped counters
|
||||
and Storm deaths; limit222/232 includes five actual Atreides casualties and a
|
||||
deferred Emperor bribe6. Their actual return pays6/free2 and earns Tleilaxu7,
|
||||
funding its own shipment4/cost8 and turn3 Storm deaths: discount369/383 actions,
|
||||
or370 with a real bribe2 making full-price4 unaffordable. Leader sources241/256
|
||||
use actual paid2 shipments and Poison/no-Traitor battle deaths of Wykk2/Yueh1;
|
||||
discount246/261 passes the prior early window, foreign256 retains four active
|
||||
own leaders. All original57 cards remain physical; no played hand, casualty,
|
||||
wallet, clock, allowance or pending request is installed.
|
||||
|
||||
Feedback repairs fixture assumptions about Arrakeen protection, printed free2,
|
||||
the actual CHOAM free-revival chooser and automatic income acknowledgements.
|
||||
After declining Semuta, no remaining counter can mean income is already paid;
|
||||
claiming the used Karama can keep that native income response actionable. Pure
|
||||
quotes independently bind the cost and earned credit. Native guards stay intact.
|
||||
Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-revival-benefits-proof/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-revival-benefits-runtime/`.
|
||||
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
|
||||
These are engine/SSR/callback results; hydration, DOM/browser/mobile, complex
|
||||
parents, wider combinations, complete modes and deployed/final assurance remain
|
||||
unfinished.
|
||||
|
||||
**Semuta on native movement cancellation costs — 26 October. Prototyped / Partial.**
|
||||
Printed-role Karama stopping ordinary Fremen/Cyborg range, Advanced Guild timing
|
||||
or HMS movement/collection now offers its exact used cost before prevention.
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Semuta claims pending revival-benefit cancellation costs
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping native CHOAM revival,
|
||||
Tleilaxu granted limits or discounts, early leaders or Advanced foreign Ghola
|
||||
offers its exact used cost before prevention. `karamaRevivalDiscard` binds the
|
||||
unpaid request, remaining checks, physical Tanks/leader, payer and native ledgers.
|
||||
Pure native cancellation validation neither simulates a return nor supplies the
|
||||
spent cost as unspent. Claim/pass retires the group, restores the exact response
|
||||
and finishes prevention once; native abandonment, repricing/funding, remaining
|
||||
checks, physical return/payment and earned income retain authority. Existing
|
||||
persistent controls and minimal bots serve all five kinds. Earlier signature
|
||||
formats remain unchanged.
|
||||
[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#pending-revival-benefit-cancellation-costs--26-october-2026).
|
||||
|
||||
Two disjoint source lanes, bounded native/diff review and root integration pass
|
||||
**15 files,248/248**, types/lint/build and **26 native engine/GameTable SSR/actual
|
||||
Semuta-callback journeys** on unchanged source. Thirty-three new cases cover
|
||||
claim/pass, applicable bands, allowed benefits, funded/unfunded repricing,
|
||||
above-cap abandonment, real smaller replacements, relevant rejection and four
|
||||
minimal profiles. Genuine cache1 Semuta and original dealt Karama supply every
|
||||
cost, retaining all original57 cards. CHOAM220/230 Basic/Advanced actions include
|
||||
shipment4/cost8 and Storm casualties; granted limit222/232 uses actual deferred
|
||||
Emperor bribe6 and five Atreides Storm deaths. Allowed Atreides5/free2/cost6 earns
|
||||
Tleilaxu7, funding shipment4/cost8 and turn3 deaths: own discount369/383, or370
|
||||
with a real bribe2 making full4 unaffordable. Leader241/256 uses real paid2
|
||||
shipments, Wykk2/Yueh1 Poison/no-Traitor deaths and original bounty. Discount246/261
|
||||
passes the prior early window; foreign256 retains four actual active own leaders.
|
||||
No played-state hand, casualty, wallet, clock, allowance or request is installed.
|
||||
|
||||
Feedback repairs source assumptions about protected Arrakeen, native free2,
|
||||
CHOAM's actual free-revival chooser and automatic income acknowledgement. With
|
||||
the original only Karama spent, declining Semuta can let income auto-settle;
|
||||
recovering it can preserve an actionable native counter. Independent pure quotes
|
||||
assert actual payment, free allowance and earned credit once. Native gameplay
|
||||
guards remain intact. Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-revival-benefits-proof/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-revival-benefits-runtime/`.
|
||||
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
|
||||
These are engine/SSR/callback results; hydration, DOM/browser/mobile, complex
|
||||
parents, broader combinations, complete modes and deployed/final assurance remain
|
||||
open.
|
||||
|
||||
## 26 October 2026 — Semuta claims native movement cancellation costs
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping ordinary Fremen/Cyborg
|
||||
|
||||
@@ -2,6 +2,81 @@
|
||||
|
||||
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.
|
||||
|
||||
## Pending revival-benefit cancellation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping native CHOAM revival,
|
||||
Tleilaxu granted force limits or discounts, early leader revival or Advanced
|
||||
foreign Ghola now offers its exact used cost. `karamaRevivalDiscard` retains the
|
||||
original unpaid request, response and remaining checks. Existing persistent
|
||||
neutral controls, private claim/pass and minimal profiles serve these sources.
|
||||
This applies the existing fresh-discard timing, not a new publisher ruling.
|
||||
|
||||
The native [cancellation quote](../game/revival-cancellation.ts) is pure preflight:
|
||||
no return, payment, allowance or income is simulated while Semuta is open.
|
||||
The spent cost is not supplied again as an unspent card. The subtype signature
|
||||
binds the request, physical Tanks/leader, payer, rules and relevant allowance,
|
||||
Homeworld, Tech and income ledgers; earlier receipt formats remain unchanged.
|
||||
Competing battle, decision, opening, arrival, transport and market parents remain
|
||||
outside this clean source. CHOAM holding Semuta needs no new hand reservation
|
||||
exception: its request commits no Treachery card or incoming card slot.
|
||||
|
||||
After claim/pass, retire the group and restore the exact response before native
|
||||
prevention once. Limit, early and foreign cancellation abandon the request;
|
||||
foreign denial retains its native per-turn block. CHOAM can abandon an above-cap
|
||||
request or reprice a legal one. Discount denial requotes the original payment;
|
||||
insufficient funds return nothing and consume no allowance. Retained requests
|
||||
use their native remaining checks, then actual physical return/payment and
|
||||
earned income once. Neither `revive` nor a negotiated-price action is replayed.
|
||||
An abandoned/unfunded request can leave a real smaller legal return available.
|
||||
The separate completed-revival income cost below retains its earlier boundary.
|
||||
|
||||
Source evidence: [force sources](../tests/fixture-semuta-revival-benefits-forces.ts)
|
||||
and [cases](../tests/semuta-revival-benefits-forces-engine.test.ts),
|
||||
[leader sources](../tests/fixture-semuta-revival-benefits-leaders.ts) and
|
||||
[cases](../tests/semuta-revival-benefits-leaders-engine.test.ts).
|
||||
Two disjoint source lanes, bounded native/diff review and root integration pass
|
||||
**15 files,248/248**, types/lint/build and **26 native engine/GameTable SSR/real
|
||||
Semuta-callback journeys** on unchanged source. Thirty-three new cases cover
|
||||
all five kinds, applicable rule bands, claim/pass, actual cheaper/granted
|
||||
allowance, unfunded/above-cap outcomes, smaller legal alternatives, relevant
|
||||
rejection and all four minimal profiles. Real cache1 acquisition and original
|
||||
dealt Karama precede every cost; all original57 cards remain physical.
|
||||
|
||||
Force sources retain actual paid shipments and Storm casualties. CHOAM220/230
|
||||
Basic/Advanced actions ship4 for8 before turn2 deaths and native cheap3/full6
|
||||
revival. Limit222/232 uses an actual Emperor bribe6, deferred through Mentat,
|
||||
and Atreides shipment5/cost10/deaths before the native funded5 request. Allowing
|
||||
that actual request returns5/free2 for6 and earns Tleilaxu7 (5→12), then its own
|
||||
shipment4/cost8 into spice-free exposed sand and turn3 Storm deaths provide the
|
||||
discount source369/383. Its cheap2/full4 has exactly4 available; a real bribe2
|
||||
makes the370-action variant unfunded. Above-cap CHOAM4 and granted-limit denial
|
||||
abandon without payment/allowance. Smaller alternatives actually return pieces.
|
||||
|
||||
Leader sources241/256 actions use genuine paid2 shipments into Imperial Basin9,
|
||||
both original Poison weapons with Wykk2/Yueh1, dial0 and actual no-Traitor votes.
|
||||
Native weapons kill both leaders; Tleilaxu wins with one surviving force and its
|
||||
original bounty. Early cancellation abandons; discount246/261 actually passes
|
||||
the prior early window and then pays full2 once; Advanced foreign256 abandons
|
||||
the real dead Atreides disc while preserving its native per-turn block. The
|
||||
actual four living own leaders supply foreign capacity. Original battle-discard
|
||||
groups are explicitly declined, preserving Semuta for the later used cost.
|
||||
No played-state hand, force/leader death, wallet, clock, allowance or pending
|
||||
request is installed.
|
||||
|
||||
Feedback repairs test-source assumptions about protected Arrakeen, Atreides'
|
||||
printed free2, the native CHOAM free-revival chooser and automatic income.
|
||||
Once the only Karama is spent, declining Semuta can let earned income settle
|
||||
automatically; recovering it can leave that same income counter actionable.
|
||||
Pure native resume quotes independently predict cost and earned income before
|
||||
asserting the actual payment/credit and only remaining acknowledgements. Native
|
||||
gameplay guards remain intact. Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-revival-benefits-proof/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-revival-benefits-runtime/`.
|
||||
No saved-room/database/server/reset/restart/deployment/recovery operation occurs.
|
||||
Engine/SSR/callback evidence leaves hydration, DOM/browser/mobile, complex
|
||||
parents, wider combinations, complete modes and exact-revision deployed
|
||||
acceptance open.
|
||||
|
||||
## Movement cancellation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping ordinary native Fremen
|
||||
|
||||
+50
-5
@@ -1576,7 +1576,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -5908,9 +5908,30 @@ function semutaMovementKaramaResponse(g: Game, response: ResponseWindow, player:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** The unpaid request stays owned by native revival. Its quote may abandon,
|
||||
* reprice or retain checks; none of those outcomes is executed before Semuta. */
|
||||
function semutaRevivalKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean {
|
||||
if (!['choamRevival','revivalLimit','revivalDiscount','earlyRevival','foreignGhola'].includes(response.kind) ||
|
||||
!g.pendingRevival || g.battle || g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance ||
|
||||
g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
|
||||
g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove ||
|
||||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry ||
|
||||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
|
||||
(g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete') ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
g.choamMarket || g.pendingChoamMarketGhola) return false;
|
||||
const context: Game = {...g,pendingTreacheryDiscard:null,response};
|
||||
try {
|
||||
validateKaramaUse(context,getPlayer(context,player),{kind:'cancel',response});
|
||||
return !!revivalCancellationQuote(context,response);
|
||||
} 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' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' }
|
||||
>;
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
@@ -5924,6 +5945,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
const auctionAdvantage = c.kind === 'karamaAuctionAdvantageDiscard';
|
||||
const terminal = c.kind === 'karamaTerminalDiscard';
|
||||
const movement = c.kind === 'karamaMovementDiscard';
|
||||
const revival = c.kind === 'karamaRevivalDiscard';
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
@@ -6036,12 +6058,24 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
hajr:p.hajrPlays, hajrWindow:p.hajrWindow, ornithopterExtra:p.ornithopterExtraMoves,
|
||||
leaders:p.leaders, kwisatz:p.kwisatz})),
|
||||
} : undefined,
|
||||
revival: revival ? {
|
||||
pending:g.pendingRevival, rules:g.revivalRules, requests:g.revivalRequests,
|
||||
free:g.freeRevival, extra:g.emperorExtra, freeIncome:g.revivalFreeIncome,
|
||||
recruits:g.recruits, active:g.active, ready:g.ready,
|
||||
homeworlds:g.homeworlds, opening:g.homeworldRevival, return:g.homeworldRevivalReturn,
|
||||
progress:g.homeworldRevivalProgress, victory:g.homeworldVictoryReinforcement,
|
||||
occupation:g.homeworldOccupationHistory, tech:g.techTokens, skills:g.leaderSkills,
|
||||
banker:g.spiceBankerIncome, bureaucrat:g.bureaucratPayments, duke:g.dukeVidal,
|
||||
players:g.players.map(p => ({id:p.id, revived:p.revived, free:p.freeForcesRevived,
|
||||
leaderRevived:p.leaderRevived, cycle:p.revivalCycle, ecazCohort:p.ecazRevivalCohort,
|
||||
gholaBlocked:p.gholaBlocked, leaders:p.leaders, kwisatz:p.kwisatz})),
|
||||
} : 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 ? p.shipped : undefined,
|
||||
physical: battle || worthless || income || shipment || conversion || terminal || movement
|
||||
physical: battle || worthless || income || shipment || conversion || terminal || movement || revival
|
||||
? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, leaders: battle ? p.leaders : undefined,
|
||||
noField: shipment ? p.noField : undefined,
|
||||
@@ -6957,6 +6991,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
continuation?.kind === 'karamaAuctionAdvantageDiscard' ||
|
||||
continuation?.kind === 'karamaTerminalDiscard' ||
|
||||
continuation?.kind === 'karamaMovementDiscard' ||
|
||||
continuation?.kind === 'karamaRevivalDiscard' ||
|
||||
continuation?.kind === 'karamaShipmentDiscard' ||
|
||||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
@@ -6991,6 +7026,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
semutaTerminalKaramaResponse(g,c.use.response,c.player,c.resume.decision)) ||
|
||||
(c.kind === 'karamaMovementDiscard' &&
|
||||
semutaMovementKaramaResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaRevivalDiscard' &&
|
||||
semutaRevivalKaramaResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaShipmentDiscard' &&
|
||||
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
|
||||
(c.kind === 'karamaBgConversionCancelDiscard' &&
|
||||
@@ -7896,6 +7933,7 @@ function semutaOfferSupported(
|
||||
const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard';
|
||||
const terminalDiscard = continuation.kind === 'karamaTerminalDiscard';
|
||||
const movementDiscard = continuation.kind === 'karamaMovementDiscard';
|
||||
const revivalDiscard = continuation.kind === 'karamaRevivalDiscard';
|
||||
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
|
||||
continuation.kind === 'karamaBgConversionCancelDiscard';
|
||||
const ambassadorDiscard = continuation.kind === 'ambassador';
|
||||
@@ -7934,7 +7972,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
|
||||
continuation.kind !== 'karamaEmperorShareDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentPriceDiscard' &&
|
||||
continuation.kind !== 'specialKaramaDiscard' &&
|
||||
@@ -7971,7 +8009,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 && continuation.kind !== 'kaitainDiscard' &&
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
continuation.kind !== 'tupileCleanupDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
@@ -8077,6 +8115,7 @@ function semutaOfferSupported(
|
||||
auctionAdvantageDiscard ||
|
||||
terminalDiscard ||
|
||||
movementDiscard ||
|
||||
revivalDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard'
|
||||
? (continuation.kind === 'karamaBattleDiscard'
|
||||
? semutaBattleKaramaResponse(g, continuation.use.response)
|
||||
@@ -8094,6 +8133,8 @@ function semutaOfferSupported(
|
||||
? semutaTerminalKaramaResponse(g,continuation.use.response,continuation.player,continuation.resume.decision)
|
||||
: movementDiscard
|
||||
? semutaMovementKaramaResponse(g,continuation.use.response,continuation.player)
|
||||
: revivalDiscard
|
||||
? semutaRevivalKaramaResponse(g,continuation.use.response,continuation.player)
|
||||
: continuation.kind === 'karamaShipmentDiscard'
|
||||
? semutaGuildShipmentKaramaResponse(g, continuation.use.response)
|
||||
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
|
||||
@@ -8131,6 +8172,7 @@ function semutaOfferSupported(
|
||||
auctionAdvantageDiscard ||
|
||||
terminalDiscard ||
|
||||
movementDiscard ||
|
||||
revivalDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard' ||
|
||||
((continuation.kind === 'ordinaryCardDiscard'
|
||||
? ordinaryDiscardIncomeMatches(g, continuation)
|
||||
@@ -8489,6 +8531,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
next.kind === 'karamaAuctionAdvantageDiscard' ||
|
||||
next.kind === 'karamaTerminalDiscard' ||
|
||||
next.kind === 'karamaMovementDiscard' ||
|
||||
next.kind === 'karamaRevivalDiscard' ||
|
||||
next.kind === 'karamaShipmentDiscard' ||
|
||||
next.kind === 'karamaBgConversionCancelDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard' ||
|
||||
@@ -15845,6 +15888,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
semutaAuctionAdvantageResponse(g,use.response,p.id) ||
|
||||
semutaTerminalKaramaResponse(g,use.response,p.id) ||
|
||||
semutaMovementKaramaResponse(g,use.response,p.id) ||
|
||||
semutaRevivalKaramaResponse(g,use.response,p.id) ||
|
||||
guildShipmentDiscard || bgConversionCancelDiscard)) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
|
||||
@@ -15854,6 +15898,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
: ['atreidesAuction','ixAuction','ixTechnology'].includes(use.response.kind) ? 'karamaAuctionAdvantageDiscard'
|
||||
: ['advisor','atreidesSpice','stormPeek','faceDancerReplacement'].includes(use.response.kind) ? 'karamaTerminalDiscard'
|
||||
: ['fremenMovement','ixMovement','mobileStronghold','guildTiming'].includes(use.response.kind) ? 'karamaMovementDiscard'
|
||||
: ['choamRevival','revivalLimit','revivalDiscount','earlyRevival','foreignGhola'].includes(use.response.kind) ? 'karamaRevivalDiscard'
|
||||
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
|
||||
: use.response.kind === 'choamInflation'
|
||||
? 'karamaInflationDiscard'
|
||||
|
||||
@@ -1291,6 +1291,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'When Semuta itself was proposed as the now-prevented allied gift, its donor may spend it to claim that counter’s used cost. Prevention first retains Semuta and restores the original parent; the same committed response then spends Semuta and takes the cost, without a second prevention or transfer to the ally. Passing keeps Semuta. Other commitments remain enforced.',
|
||||
'Printed-role Karama stopping Atreides auction inspection, Ixian auction preparation or Advanced Technology also offers its exact cost before prevention. Claim or Continue resumes the original lot or ordinary draw once, without new inspection, extra Ix choice or exchange. Technology stays used and its original unseen lot remains; the native Atreides peek follows. If outgoing Semuta was selected for that now-prevented exchange, only that reservation releases so Semuta can claim the counter’s cost. Other commitments and independent knowledge remain.',
|
||||
'Printed-role Karama stopping clean native Tleilaxu revival income offers that exact used cost before income prevention. The ordinary or Ghola revival has already completed: returned forces/leaders, actual payment, normal/free/elite allowance use and the free-income history stay unchanged. Claim or Continue withholds the original income once; taking its counter cannot repeat the revival or recover an already discarded Ghola. Ghola’s own earlier discard is a separate opportunity. Unfinished battle, Ambassador, market and arrival parents retain their separate boundaries.',
|
||||
'Printed-role Karama stopping a clean native CHOAM revival benefit, Tleilaxu granted force limit or discount, early leader revival or Advanced foreign Ghola also offers its exact used cost. The original request stays unpaid and its counters or leader stay in the Tanks through the offer. Claim or Continue finishes the original prevention once: native rules may abandon the request, reprice it, open its remaining response or complete an affordable return. Unfunded repricing returns nothing and consumes no allowance. Any income follows the actual return once; taking Karama cannot repeat the request. Other unfinished parents remain separate.',
|
||||
'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.',
|
||||
@@ -1429,6 +1430,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-prevented-gift-reservation.test.ts',
|
||||
'tests/fixture-semuta-revival-income.ts',
|
||||
'tests/semuta-revival-income-engine.test.ts',
|
||||
'tests/fixture-semuta-revival-benefits-forces.ts',
|
||||
'tests/semuta-revival-benefits-forces-engine.test.ts',
|
||||
'tests/fixture-semuta-revival-benefits-leaders.ts',
|
||||
'tests/semuta-revival-benefits-leaders-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,252 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES, location } from '../game/board';
|
||||
import { quoteRevivalCancellation } from '../game/revival-cancellation';
|
||||
import { quoteRevivalResume } from '../game/revival-resume';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaRevivalIncome, advanceSemutaRevivalIncome, nextSemutaRevivalIncomeStep,
|
||||
semutaRevivalIncomeClean, semutaRevivalIncomeInventory, semutaRevivalIncomePlayer,
|
||||
passSemutaRevivalIncome, claimSemutaRevivalIncome,
|
||||
type SemutaRevivalIncomeStep,
|
||||
} from './fixture-semuta-revival-income';
|
||||
|
||||
export type SemutaRevivalBenefitsForcesKind = 'choamRevival' | 'revivalLimit' | 'revivalDiscount';
|
||||
export type SemutaRevivalBenefitsForcesStep = SemutaRevivalIncomeStep;
|
||||
export const actSemutaRevivalBenefitsForces = actSemutaRevivalIncome;
|
||||
export const nextSemutaRevivalBenefitsForces = nextSemutaRevivalIncomeStep;
|
||||
export const advanceSemutaRevivalBenefitsForces = advanceSemutaRevivalIncome;
|
||||
export const semutaRevivalBenefitsForcesPlayer = semutaRevivalIncomePlayer;
|
||||
export const semutaRevivalBenefitsForcesInventory = semutaRevivalIncomeInventory;
|
||||
export const semutaRevivalBenefitsForcesClean = semutaRevivalIncomeClean;
|
||||
export const passSemutaRevivalBenefitsForces = passSemutaRevivalIncome;
|
||||
export const claimSemutaRevivalBenefitsForces = claimSemutaRevivalIncome;
|
||||
|
||||
/** Pass exactly the current native window, leaving a subsequent benefit or
|
||||
* earned-income window explicit. This never submits another revival request. */
|
||||
export function allowSemutaRevivalBenefitsForces(game: Game,
|
||||
actions: SemutaRevivalBenefitsForcesStep[] = []): Game {
|
||||
const response = game.response; assert.ok(response);
|
||||
const original = { kind: response.kind, owner: response.owner, recipient: response.recipient };
|
||||
for (let count = 0; count < game.players.length && game.response &&
|
||||
game.response.kind === original.kind && game.response.owner === original.owner &&
|
||||
game.response.recipient === original.recipient; count++) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaRevivalBenefitsForces(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Finish only the already submitted return's actual optional free-revival,
|
||||
* benefit and income continuations. Ordinary phase readiness remains untouched. */
|
||||
export function finishSemutaRevivalBenefitsForces(game: Game,
|
||||
actions: SemutaRevivalBenefitsForcesStep[] = []): Game {
|
||||
for (let count = 0; count < 30; count++) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the fresh used-cost batch first');
|
||||
if (game.response) {
|
||||
assert.ok(['choamRevival', 'revivalLimit', 'revivalDiscount', 'revivalIncome'].includes(game.response.kind));
|
||||
game = allowSemutaRevivalBenefitsForces(game, actions);
|
||||
} else if (game.decision?.kind === 'revivalStop' || game.decision?.kind === 'choamFreeRevival') {
|
||||
game = actSemutaRevivalBenefitsForces(game, game.decision.player, { type: 'decision', decline: true }, actions);
|
||||
} else {
|
||||
assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.equal(game.decision ?? null, null);
|
||||
return game;
|
||||
}
|
||||
}
|
||||
throw Error('The original force-revival continuations did not settle');
|
||||
}
|
||||
|
||||
/** Genuine bare native source. Only unopened starting-card/Spice positions and
|
||||
* original scalar lotteries are selected. No played forces, Tanks, wallets,
|
||||
* free quotas, phase, pending return or cancellation response is installed. */
|
||||
export function createSemutaRevivalBenefitsForcesFixture(options: {
|
||||
kind: SemutaRevivalBenefitsForcesKind; advanced?: boolean; amount?: number; unfunded?: boolean;
|
||||
}) {
|
||||
const kind = options.kind, advanced = options.advanced ?? false, unfunded = options.unfunded ?? false;
|
||||
const amount = options.amount ?? (kind === 'choamRevival' ? 3 : kind === 'revivalLimit' ? 5 : 4);
|
||||
assert.ok(kind === 'revivalLimit' ? amount === 5 :
|
||||
kind === 'choamRevival' ? amount === 3 || amount === 4 : amount === 4);
|
||||
assert.ok(!unfunded || kind === 'revivalDiscount', 'Only the own-T discount source needs its real bribe variant');
|
||||
const seller = 'richese', holder = 'choam', tleilaxu = 'tleilaxu', atreides = 'atreides', spender = 'emperor';
|
||||
const recipient = kind === 'choamRevival' ? holder : kind === 'revivalLimit' ? atreides : tleilaxu;
|
||||
const owner = kind === 'choamRevival' ? holder : tleilaxu;
|
||||
let game = createGame('SEMUTAREVIVALFORCES', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']);
|
||||
for (const faction of ['choam', 'tleilaxu', 'atreides', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: SemutaRevivalBenefitsForcesStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaRevivalBenefitsForces(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRevivalBenefitsForces(game, until, actions); };
|
||||
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);
|
||||
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
else act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: advanced ? 'explicit-original-semuta-initializer' : 'public-basic-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalInventory = semutaRevivalBenefitsForcesInventory(game); assert.equal(originalInventory.length, 57);
|
||||
const costCard = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
|
||||
const ordinary = game.deck.filter(card => !card.effect &&
|
||||
['projectile', 'poison', 'shield', 'antidote'].includes(card.kind)).slice(0, 4);
|
||||
assert.ok(costCard); assert.equal(ordinary.length, 4);
|
||||
const deal = [...ordinary.map(card => card.id), costCard.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), cost = costCard.id;
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, tleilaxu).faceDancers?.length, 3);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
const spiceDraws = (advanced ? 2 : 1) * (kind === 'revivalDiscount' ? 3 : 2);
|
||||
for (let position = 0; position < spiceDraws; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
TERRITORIES.some(territory => territory.id === card.territory && territory.sectors.every(sector => sector >= 9)));
|
||||
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) });
|
||||
}
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaRevivalBenefitsForcesStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaRevivalBenefitsForcesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaRevivalBenefitsForces(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(semutaRevivalBenefitsForcesPlayer(beforeBid, holder).spice - semutaRevivalBenefitsForcesPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, seller).spice - semutaRevivalBenefitsForcesPlayer(beforeBid, seller).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 4 && semutaRevivalBenefitsForcesClean(state));
|
||||
const firstRevival = structuredClone(game);
|
||||
assert.equal(game.storm, 18);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).tanks, 0);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).forces['arrakeen:10'], 10);
|
||||
let financingReturn: ReturnType<typeof transition> | null = null;
|
||||
let financingFunding: ReturnType<typeof transition> | null = null;
|
||||
let funding: ReturnType<typeof transition> | null = null;
|
||||
if (kind !== 'choamRevival') {
|
||||
financingFunding = transition(() => { act(spender, { type: 'bribe', target: atreides, amount: 6 }); });
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).spice, 10);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).bribes, 6);
|
||||
}
|
||||
const firstShipper = kind === 'choamRevival' ? holder : atreides;
|
||||
const firstAmount = kind === 'choamRevival' ? 4 : 5;
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === firstShipper && semutaRevivalBenefitsForcesClean(state));
|
||||
const firstShipment = transition(() => {
|
||||
act(firstShipper, { type: 'ship', territory: 'cielago_north', sector: 1, amount: firstAmount, elite: 0, allyPayment: 0 });
|
||||
reach(semutaRevivalBenefitsForcesClean);
|
||||
});
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(firstShipment.before, firstShipper).spice - semutaRevivalBenefitsForcesPlayer(game, firstShipper).spice, 2 * firstAmount);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, firstShipper).forces['cielago_north:1'], firstAmount);
|
||||
act(firstShipper, { type: 'endMovement' });
|
||||
reach(state => state.turn === 2 && state.phase === 0 && semutaRevivalBenefitsForcesClean(state));
|
||||
const firstStorm = transition(() => {
|
||||
reach(state => state.turn === 2 && state.phase === 1 && semutaRevivalBenefitsForcesClean(state));
|
||||
});
|
||||
assert.equal(firstStorm.before.storm, 18); assert.ok(firstStorm.after.storm >= 1 && firstStorm.after.storm <= 6);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, firstShipper).tanks, firstAmount);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, firstShipper).forces['cielago_north:1'] ?? 0, 0);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && semutaRevivalBenefitsForcesClean(state));
|
||||
if (kind !== 'choamRevival') {
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).bribes, 0);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).spice, 6 + (advanced ? 2 : 0));
|
||||
}
|
||||
let shipment = firstShipment, storm = firstStorm;
|
||||
let shipmentDestination = { territory: 'cielago_north', sector: 1 };
|
||||
if (kind === 'revivalDiscount') {
|
||||
financingReturn = transition(() => {
|
||||
act(tleilaxu, { type: 'tleilaxuRevivalLimit', target: atreides });
|
||||
act(atreides, { type: 'revive', amount: 5, elite: 0 });
|
||||
game = finishSemutaRevivalBenefitsForces(game, actions);
|
||||
});
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).revived, 5);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, tleilaxu).spice, 12);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === recipient && semutaRevivalBenefitsForcesClean(state));
|
||||
const sector = game.storm % 18 + 1;
|
||||
const exposed = TERRITORIES.find(territory => territory.type === 'sand' && territory.sectors.includes(sector) &&
|
||||
!Object.entries(game.spice).some(([key, amount]) => amount > 0 && key.startsWith(`${territory.id}:`)) && game.players.every(player =>
|
||||
!Object.entries(player.forces).some(([key, count]) => count > 0 && key.startsWith(`${territory.id}:`))));
|
||||
assert.ok(exposed, 'Choose actual spice-free exposed sand in the next Storm path');
|
||||
shipmentDestination = { territory: exposed.id, sector };
|
||||
shipment = transition(() => {
|
||||
act(recipient, { type: 'ship', ...shipmentDestination, amount: 4, elite: 0, allyPayment: 0 });
|
||||
reach(semutaRevivalBenefitsForcesClean);
|
||||
});
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(shipment.before, recipient).spice - semutaRevivalBenefitsForcesPlayer(game, recipient).spice, 8);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).forces[location(exposed.id, sector)], 4);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).spice, 4);
|
||||
act(recipient, { type: 'endMovement' });
|
||||
reach(state => state.turn === 3 && state.phase === 0 && semutaRevivalBenefitsForcesClean(state));
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).spice, 4, 'No collection credit funds the reprice');
|
||||
storm = transition(() => {
|
||||
reach(state => state.turn === 3 && state.phase === 1 && semutaRevivalBenefitsForcesClean(state));
|
||||
});
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).tanks, 4);
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).forces[location(exposed.id, sector)] ?? 0, 0);
|
||||
reach(state => state.turn === 3 && state.phase === 4 && semutaRevivalBenefitsForcesClean(state));
|
||||
if (unfunded) {
|
||||
const bribe = semutaRevivalBenefitsForcesPlayer(game, recipient).spice - 2;
|
||||
assert.ok(bribe > 0);
|
||||
funding = transition(() => { act(recipient, { type: 'bribe', target: spender, amount: bribe }); });
|
||||
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).spice, 2);
|
||||
}
|
||||
}
|
||||
const beforeRevival = structuredClone(game), nativeAction: Action = { type: 'revive', amount, elite: 0 };
|
||||
const revival = transition(() => {
|
||||
if (kind === 'revivalLimit') act(tleilaxu, { type: 'tleilaxuRevivalLimit', target: recipient });
|
||||
act(recipient, nativeAction);
|
||||
for (let count = 0; count < 20 && game.response?.kind !== kind; count++) {
|
||||
if (game.decision?.kind === 'revivalStop' || game.decision?.kind === 'choamFreeRevival')
|
||||
act(game.decision.player, { type: 'decision', decline: true });
|
||||
else if (game.response?.kind === 'revivalLimit' && kind === 'revivalDiscount')
|
||||
game = allowSemutaRevivalBenefitsForces(game, actions);
|
||||
else throw Error('The original force request did not reach its native benefit response');
|
||||
}
|
||||
});
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.response?.recipient, recipient); assert.ok(game.pendingRevival);
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(semutaRevivalBenefitsForcesPlayer(game, recipient), semutaRevivalBenefitsForcesPlayer(beforeRevival, recipient));
|
||||
assert.ok(semutaRevivalBenefitsForcesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaRevivalBenefitsForcesPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const source = structuredClone(game), pending = structuredClone(game.pendingRevival!);
|
||||
const originalResponse = structuredClone(game.response!), expected = quoteRevivalCancellation(game, game.response!);
|
||||
const allowedResume = quoteRevivalResume(game, 'finish');
|
||||
const expectedResume = expected.pending ? quoteRevivalResume({ ...game,
|
||||
revivalRules: expected.rules, pendingRevival: expected.pending }, 'finish') : null;
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.deepEqual(semutaRevivalBenefitsForcesInventory(game), originalInventory);
|
||||
return { game, source, beforeCost: source, kind, advanced, amount, unfunded,
|
||||
holder, spender, owner, recipient, payer: recipient, seller, tleilaxu, atreides,
|
||||
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, pending, expected, expectedResume, allowedResume, originalResponse,
|
||||
lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder, originalSpice, spiceOrder,
|
||||
acquisition, acquired, firstRevival, financingFunding, firstShipment, firstStorm,
|
||||
financingReturn, shipment, shipmentDestination, storm, funding, beforeRevival, revival,
|
||||
casualties: { first: firstStorm.after, later: storm.after },
|
||||
originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Public Basic start or original Advanced Semuta initializer on a ready undealt R/C/T/A/E lobby; original union47/cache10 retained. Only unopened starting-card/Spice positions and scalar lotteries are selected. Emperor really receives printed Karama; CHOAM pays1 for cache Semuta.',
|
||||
'Actual first Storm17+0 ends18; Atreides protected Arrakeen10 survives. Actual turn1 shipment (CHOAM4 or Atreides5) to exposed Cielago North1 is killed by the next Storm. No casualties, played forces, wallets, clocks or pending controls are installed.',
|
||||
kind === 'revivalLimit' ? 'Emperor actually bribes Atreides6 before the first Mentat; Atreides ships5 for10, then the deferred6 becomes spendable at Mentat. After actual turn2 Storm casualties, Tleilaxu grants5 permission and Atreides submits the funded5-counter request (native free2, paid3) for6. The unpaid native limit response is the source.' :
|
||||
kind === 'choamRevival' ? 'CHOAM uses its real post-cache11 to ship4 for8 into Cielago North1. The next actual Storm kills4; native turn2 CHOAM opening income funds the original cheap3/4-counter request.' :
|
||||
'Emperor actually bribes Atreides6 before first Mentat; Atreides ships5 for10, then the deferred6 becomes spendable (Advanced also retains its actual Arrakeen city income2). After real turn2 Storm deaths Atreides revives5 (free2, paid3), paying6 and earning Tleilaxu7 paid/free income (5→12). Tleilaxu ships4 for8 into actual spice-free exposed sand in the next Storm path, retaining4; turn3 Storm kills4. Its request4 keeps native free2, cheap2/full4. The unfunded branch actually bribes2 away before requesting.',
|
||||
'Stop before Emperor spends its actual cancellation cost. The pure native cancellation quote supplies abandonment/repricing/funding; later income is finished only through actual current response passes, never a replacement request.',
|
||||
] };
|
||||
}
|
||||
export type SemutaRevivalBenefitsForcesFixture = ReturnType<typeof createSemutaRevivalBenefitsForcesFixture>;
|
||||
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { quoteRevivalCancellation } from '../game/revival-cancellation';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actTruthMovement } from './fixture-truth-movement';
|
||||
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type SemutaRevivalBenefitsLeadersStep = { actor: string; action: Action };
|
||||
export type SemutaRevivalBenefitsLeadersKind = 'earlyRevival' | 'revivalDiscount' | 'foreignGhola';
|
||||
export const actSemutaRevivalBenefitsLeaders = actTruthMovement;
|
||||
export const semutaRevivalBenefitsLeadersPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const semutaRevivalBenefitsLeadersClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
/** Sealed plans and discard batches refer to these zones without duplicating cards. */
|
||||
export function semutaRevivalBenefitsLeadersInventory(game: Game) {
|
||||
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length); return ids;
|
||||
}
|
||||
/** Original preparation/readiness and cleanup only. Private plans and every
|
||||
* fresh disposal are explicit; no optional replacement or revival is invented. */
|
||||
export function nextSemutaRevivalBenefitsLeadersStep(game: Game): SemutaRevivalBenefitsLeadersStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'revivalStop') return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
|
||||
if (d?.kind === 'battleCards') return { actor: d.player, action: { type: 'decision', discard: [...d.cards] } };
|
||||
const window = game.battle?.outcomeWindow;
|
||||
if (!d && window && !window.closed) {
|
||||
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'battleOutcomeReady', event: window.event } };
|
||||
}
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaRevivalBenefitsLeaders(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
|
||||
for (let count = 0; count < 2200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaRevivalBenefitsLeadersStep(game);
|
||||
assert.ok(next, 'Supply the real private plans or fresh-discard reaction before continuing');
|
||||
game = actSemutaRevivalBenefitsLeaders(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native leader-revival programme did not reach its finite boundary');
|
||||
}
|
||||
export function passSemutaRevivalBenefitsLeaders(game: Game, actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
|
||||
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
|
||||
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
||||
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
|
||||
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaRevivalBenefitsLeaders(game, actor.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||||
}
|
||||
export function claimSemutaRevivalBenefitsLeaders(game: Game, holder: string,
|
||||
actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
|
||||
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
|
||||
game = actSemutaRevivalBenefitsLeaders(game, holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaRevivalBenefitsLeaders(game, holder, { type: 'semutaSelect', event, card }, actions);
|
||||
return game;
|
||||
}
|
||||
/** Allow exactly one native benefit; later checks stay explicit until finish. */
|
||||
export function allowSemutaRevivalBenefitsLeaders(game: Game,
|
||||
actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
|
||||
const kind = game.response?.kind;
|
||||
assert.ok(kind === 'earlyRevival' || kind === 'revivalDiscount' || kind === 'foreignGhola');
|
||||
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 = actSemutaRevivalBenefitsLeaders(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
/** Complete only the accepted original request's native remaining benefit,
|
||||
* consent/income controls. No request is replayed and fresh costs stay owned. */
|
||||
export function finishSemutaRevivalBenefitsLeaders(game: Game,
|
||||
actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
|
||||
return advanceSemutaRevivalBenefitsLeaders(game, semutaRevivalBenefitsLeadersClean, actions);
|
||||
}
|
||||
|
||||
/** Actual first-turn two-Poison battle earns both native dead discs. Original
|
||||
* starting cards, paid cache, shipments, plans, votes and disposal stay real;
|
||||
* no dead/deaths/ghola/custody/resource/phase/response state is assigned. */
|
||||
export function createSemutaRevivalBenefitsLeadersFixture(options: {
|
||||
kind?: SemutaRevivalBenefitsLeadersKind; advanced?: boolean;
|
||||
} = {}) {
|
||||
const kind = options.kind ?? 'earlyRevival', advanced = options.advanced ?? kind === 'foreignGhola';
|
||||
assert.ok(kind !== 'foreignGhola' || advanced, 'Native foreign Ghola is Advanced only');
|
||||
const seller = 'richese', holder = 'choam', owner = 'tleilaxu', opponent = 'atreides', spender = 'emperor';
|
||||
const territory = 'imperial_basin', sector = 9, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTAREVIVALBENEFITSLEADERS', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']);
|
||||
for (const faction of ['choam', 'tleilaxu', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: SemutaRevivalBenefitsLeadersStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaRevivalBenefitsLeaders(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRevivalBenefitsLeaders(game, until, actions); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
else act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: advanced ? 'explicit-original-semuta-initializer' : 'public-basic-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => !p.hand.length));
|
||||
const originalInventory = semutaRevivalBenefitsLeadersInventory(game); assert.equal(originalInventory.length, 57);
|
||||
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
|
||||
const poisons = game.deck.filter(card => card.kind === 'poison' && !card.effect).slice(0, 2);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 2);
|
||||
assert.ok(printed); assert.equal(poisons.length, 2); assert.equal(ordinary.length, 2);
|
||||
const deal = [ordinary[0].id, ordinary[1].id, poisons[0].id, poisons[1].id, 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, position, from };
|
||||
});
|
||||
const cost = printed.id, beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), deal);
|
||||
const ownLeader = semutaRevivalBenefitsLeadersPlayer(game, owner).leaders.filter(leader => leader.strength > 1)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
const foreignLeader = semutaRevivalBenefitsLeadersPlayer(game, opponent).leaders
|
||||
.slice().sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
assert.ok(ownLeader && foreignLeader); assert.equal(ownLeader.strength, 2); assert.equal(foreignLeader.strength, 1);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < (advanced ? 4 : 2); position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].includes(card.territory));
|
||||
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) });
|
||||
}
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaRevivalBenefitsLeadersStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaRevivalBenefitsLeadersPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaRevivalBenefitsLeadersStep(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.ok(semutaRevivalBenefitsLeadersPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.equal(semutaRevivalBenefitsLeadersPlayer(beforeBid, holder).spice - semutaRevivalBenefitsLeadersPlayer(game, holder).spice, 1);
|
||||
const shipments: { actor: string; before: Game; steps: SemutaRevivalBenefitsLeadersStep[]; after: Game }[] = [];
|
||||
for (const actor of [owner, opponent]) {
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && semutaRevivalBenefitsLeadersClean(state));
|
||||
const shipment = transition(() => {
|
||||
act(actor, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(semutaRevivalBenefitsLeadersClean);
|
||||
});
|
||||
assert.equal(semutaRevivalBenefitsLeadersPlayer(shipment.before, actor).spice - semutaRevivalBenefitsLeadersPlayer(game, actor).spice, 2);
|
||||
shipments.push({ actor, ...shipment }); act(actor, { type: 'endMovement' });
|
||||
}
|
||||
reach(state => state.phase === 6 && semutaRevivalBenefitsLeadersClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === territory);
|
||||
assert.ok(choice); assert.equal(choice.chooser, owner); assert.equal(choice.attacker, owner);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory, target: opponent };
|
||||
act(choice.chooser, battleAction);
|
||||
reach(state => !!state.battle && semutaRevivalBenefitsLeadersClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||
const beforePlans = structuredClone(game), planSteps: SemutaRevivalBenefitsLeadersStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: ownLeader.id, dial: 0, support: 0, weapon: poisons[0].id, defense: null } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: foreignLeader.id, dial: 0, support: 0, weapon: poisons[1].id, defense: null } },
|
||||
];
|
||||
for (const step of planSteps) act(step.actor, step.action);
|
||||
const revealed = structuredClone(game), battleStepsAt = actions.length;
|
||||
const disposals: { before: Game; steps: SemutaRevivalBenefitsLeadersStep[]; after: Game; cards: string[] }[] = [];
|
||||
for (let count = 0; (game.battle || !semutaRevivalBenefitsLeadersClean(game)) && count < 100; count++) {
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
const cards: string[] = game.pendingTreacheryDiscard.batch.entries.map(entry => entry.card.id);
|
||||
disposals.push({ ...transition(() => { game = passSemutaRevivalBenefitsLeaders(game, actions); }), cards });
|
||||
} else {
|
||||
const next = nextSemutaRevivalBenefitsLeadersStep(game); assert.ok(next, 'Finish the original final battle/cleanup controls');
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
}
|
||||
assert.ok(!game.battle && semutaRevivalBenefitsLeadersClean(game));
|
||||
const afterBattle = structuredClone(game), battleResolution = structuredClone(actions.slice(battleStepsAt));
|
||||
const ownDead = semutaRevivalBenefitsLeadersPlayer(game, owner).leaders.find(leader => leader.id === ownLeader.id)!;
|
||||
const foreignDead = semutaRevivalBenefitsLeadersPlayer(game, opponent).leaders.find(leader => leader.id === foreignLeader.id)!;
|
||||
assert.equal(ownDead.dead, true); assert.equal(ownDead.deaths, 1);
|
||||
assert.equal(foreignDead.dead, true); assert.equal(foreignDead.deaths, 1);
|
||||
assert.equal(game.lastBattleContext?.winner, owner);
|
||||
assert.equal(semutaRevivalBenefitsLeadersPlayer(game, owner).forces[location], 1);
|
||||
assert.equal(semutaRevivalBenefitsLeadersPlayer(game, opponent).tanks, 1);
|
||||
assert.ok(disposals.length && disposals.every(disposal => disposal.steps.every(step => step.action.type === 'semutaPass')));
|
||||
assert.ok(semutaRevivalBenefitsLeadersPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
reach(state => state.turn === 2 && state.phase === 4 && semutaRevivalBenefitsLeadersClean(state));
|
||||
const beforeRequest = structuredClone(game), targetLeader = kind === 'foreignGhola' ? foreignLeader.id : ownLeader.id;
|
||||
assert.ok(semutaRevivalBenefitsLeadersPlayer(game, owner).spice >= ownLeader.strength);
|
||||
const nativeAction: Action = kind === 'foreignGhola'
|
||||
? { type: 'reviveForeignGhola', leader: targetLeader } : { type: 'reviveLeader', leader: targetLeader };
|
||||
const declaration = transition(() => { act(owner, nativeAction); });
|
||||
let priorEarly: { before: Game; steps: SemutaRevivalBenefitsLeadersStep[]; after: Game } | null = null;
|
||||
if (kind === 'revivalDiscount') {
|
||||
assert.equal(game.response?.kind, 'earlyRevival');
|
||||
priorEarly = transition(() => { game = allowSemutaRevivalBenefitsLeaders(game, actions); });
|
||||
}
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner); assert.equal(game.response?.recipient, owner);
|
||||
assert.ok(game.pendingRevival && game.pendingRevival.leader === targetLeader);
|
||||
assert.equal(semutaRevivalBenefitsLeadersPlayer(game, owner).spice, semutaRevivalBenefitsLeadersPlayer(beforeRequest, owner).spice);
|
||||
assert.ok(semutaRevivalBenefitsLeadersPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaRevivalBenefitsLeadersInventory(game), originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
const expected = quoteRevivalCancellation(game, game.response!); assert.ok(expected);
|
||||
return { game, source, beforeCost: source, kind, advanced, holder, spender, owner, recipient: owner, payer: owner,
|
||||
opponent, seller, cost, karama: cost, card: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction,
|
||||
targetLeader, ownLeader: ownLeader.id, foreignLeader: foreignLeader.id, normalCost: game.pendingRevival!.normalCost,
|
||||
discountedCost: game.pendingRevival!.cost, pending: structuredClone(game.pendingRevival), expected,
|
||||
territory, location, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder,
|
||||
originalSpice, spiceOrder, acquired, acquisition, shipments, beforeBattle, battleAction,
|
||||
beforePlans, planSteps, revealed, disposals, battleResolution, afterBattle, beforeRequest, declaration, priorEarly,
|
||||
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original public Basic or explicit undealt Advanced Semuta setup uses native R/CHOAM/Tleilaxu/Atreides/Emperor, main47/cache10. Only unopened card/Spice positions and original scalar lotteries are selected. Tleilaxu/Atreides actually receive separate printed Poison weapons; Emperor receives the printed Karama.',
|
||||
'CHOAM really buys Semuta cache1. Tleilaxu and Atreides each really pay2 to ship one normal into otherwise empty Imperial Basin9. Native battle seals Wykk2/Yueh1, dial0/support0, both original Poison and no defense, then submits actual final readiness/no-Traitor votes. Both native leaders die through the real weapons; no dead/deaths/ghola/custody/army/phase assignment occurs.',
|
||||
'All real losing/winner played-card disposal groups are explicitly passed so the original paid Semuta remains held. Native aftermath, Collection, Mentat, next Storm/Spice/Charity/Bidding lead to clean turn2 Revival. The surviving native Tleilaxu group and four active own leaders remain actual outcomes; original funds cover full2 without a bribe.',
|
||||
kind === 'foreignGhola' ? 'The original Advanced foreign-Ghola declaration targets the different actually dead Atreides disc while the real dead own disc leaves Tleilaxu active capacity4.' : kind === 'revivalDiscount' ? 'Actually declare the original own dead Wykk revival and pass its earlier earlyRevival window before stopping at the native remaining discount response.' : 'Actually declare own dead Wykk revival and stop at its native earlyRevival response before any return or payment.',
|
||||
'Stop before the actual Emperor cancellation; source pending/quote are read, not installed. Native cancellation decides abandonment or current full-price continuation; caller owns fresh-cost Semuta and later original checks/income.',
|
||||
] };
|
||||
}
|
||||
export type SemutaRevivalBenefitsLeadersFixture = ReturnType<typeof createSemutaRevivalBenefitsLeadersFixture>;
|
||||
@@ -0,0 +1,278 @@
|
||||
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 { location } from '../game/board';
|
||||
import { quoteRevivalCancellation } from '../game/revival-cancellation';
|
||||
import {
|
||||
createSemutaRevivalBenefitsForcesFixture as fixture, actSemutaRevivalBenefitsForces as act,
|
||||
passSemutaRevivalBenefitsForces as pass, claimSemutaRevivalBenefitsForces as claim,
|
||||
allowSemutaRevivalBenefitsForces as allow, finishSemutaRevivalBenefitsForces as finish,
|
||||
semutaRevivalBenefitsForcesPlayer as player, semutaRevivalBenefitsForcesInventory as inventory,
|
||||
type SemutaRevivalBenefitsForcesFixture,
|
||||
} from './fixture-semuta-revival-benefits-forces';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'choamRevival' as const, advanced: false },
|
||||
{ kind: 'choamRevival' as const, advanced: true },
|
||||
{ kind: 'revivalLimit' as const, advanced: false },
|
||||
{ kind: 'revivalLimit' as const, advanced: true },
|
||||
{ kind: 'revivalDiscount' as const, advanced: false },
|
||||
{ kind: 'revivalDiscount' as const, advanced: true },
|
||||
{ kind: 'choamRevival' as const, advanced: false, amount: 4 },
|
||||
{ kind: 'revivalDiscount' as const, advanced: false, unfunded: true },
|
||||
];
|
||||
const physical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order, ready: game.ready,
|
||||
revivalRules: game.revivalRules, freeRevival: game.freeRevival, revivalFreeIncome: game.revivalFreeIncome,
|
||||
emperorExtra: game.emperorExtra, revivalRequests: game.revivalRequests,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
||||
revived: player.revived, freeForcesRevived: player.freeForcesRevived,
|
||||
leaderRevived: player.leaderRevived, leaders: player.leaders, shipped: player.shipped, moved: player.moved })),
|
||||
});
|
||||
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: SemutaRevivalBenefitsForcesFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
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.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
|
||||
assert.equal(f.firstRevival.storm, 18); assert.equal(player(f.firstRevival, f.atreides).tanks, 0);
|
||||
assert.equal(player(f.firstRevival, f.atreides).forces['arrakeen:10'], 10);
|
||||
assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial' &&
|
||||
f.actions.indexOf(step) < f.actions.findIndex(action => action.action.type === 'richeseBid'))
|
||||
.map(step => Number(step.action.amount)).sort((a, b) => a - b), [0, 17]);
|
||||
const destination = location(f.shipmentDestination.territory, f.shipmentDestination.sector);
|
||||
const shipped = f.kind === 'revivalLimit' ? 5 : 4;
|
||||
assert.equal(player(f.shipment.before, f.recipient).spice - player(f.shipment.after, f.recipient).spice, 2 * shipped);
|
||||
assert.equal(player(f.shipment.after, f.recipient).forces[destination], shipped);
|
||||
assert.ok(f.shipment.steps.some(step => step.actor === f.recipient && step.action.type === 'ship' && step.action.amount === shipped));
|
||||
assert.equal(player(f.storm.before, f.recipient).tanks, 0); assert.equal(player(f.storm.after, f.recipient).tanks, shipped);
|
||||
assert.equal(player(f.storm.after, f.recipient).forces[destination] ?? 0, 0);
|
||||
assert.equal(f.firstStorm.before.storm, 18);
|
||||
if (f.kind !== 'choamRevival') {
|
||||
assert.ok(f.financingFunding);
|
||||
assert.equal(player(f.financingFunding.before, f.spender).spice - player(f.financingFunding.after, f.spender).spice, 6);
|
||||
assert.equal(player(f.financingFunding.after, f.atreides).spice, 10);
|
||||
assert.equal(player(f.financingFunding.after, f.atreides).bribes, 6);
|
||||
assert.equal(player(f.firstShipment.after, f.atreides).spice, 0);
|
||||
assert.equal(player(f.firstShipment.after, f.atreides).forces['cielago_north:1'], 5);
|
||||
assert.equal(player(f.firstStorm.before, f.atreides).spice, 6 + (f.advanced ? 2 : 0));
|
||||
assert.equal(player(f.firstStorm.before, f.atreides).bribes, 0);
|
||||
assert.equal(player(f.firstStorm.after, f.atreides).tanks, 5);
|
||||
}
|
||||
if (f.kind === 'revivalLimit') {
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.financingReturn, null);
|
||||
assert.ok(f.revival.steps.some(step => step.actor === f.tleilaxu && step.action.type === 'tleilaxuRevivalLimit' && step.action.target === f.recipient));
|
||||
assert.equal(f.pending.free, 2); assert.equal(f.pending.cost, 2 * (f.amount - 2));
|
||||
assert.equal(f.expected.outcome, 'abandoned');
|
||||
} else {
|
||||
assert.equal(f.game.turn, f.kind === 'choamRevival' ? 2 : 3);
|
||||
if (f.kind === 'choamRevival') {
|
||||
assert.equal(f.financingFunding, null); assert.equal(f.financingReturn, null);
|
||||
assert.equal(player(f.shipment.before, f.holder).spice, 11);
|
||||
assert.equal(f.pending.cost, f.amount); assert.equal(f.pending.free, 0);
|
||||
assert.equal(f.expected.outcome, f.amount === 4 ? 'abandoned' : 'revive');
|
||||
if (f.amount === 3) assert.equal(f.expected.pending?.cost, 6);
|
||||
} else {
|
||||
assert.ok(f.financingReturn);
|
||||
assert.equal(player(f.financingReturn.before, f.atreides).spice - player(f.financingReturn.after, f.atreides).spice, 6);
|
||||
assert.equal(player(f.financingReturn.after, f.atreides).revived, 5);
|
||||
assert.equal(player(f.financingReturn.after, f.tleilaxu).spice - player(f.financingReturn.before, f.tleilaxu).spice, 7);
|
||||
assert.equal(player(f.shipment.before, f.tleilaxu).spice, 12);
|
||||
assert.equal(player(f.shipment.after, f.tleilaxu).spice, 4);
|
||||
assert.equal(player(f.storm.before, f.tleilaxu).spice, 4);
|
||||
assert.equal(f.shipmentDestination.sector, f.shipment.before.storm % 18 + 1);
|
||||
assert.ok(Object.entries(f.shipment.before.spice).every(([key, amount]) =>
|
||||
amount === 0 || !key.startsWith(`${f.shipmentDestination.territory}:`)));
|
||||
assert.equal(f.pending.amount, 4); assert.equal(f.pending.free, 2);
|
||||
assert.equal(f.pending.cost, 2); assert.equal(f.pending.normalCost, 4);
|
||||
assert.equal(f.expected.pending?.cost, 4); assert.equal(f.expected.outcome, f.unfunded ? 'unfunded' : 'revive');
|
||||
assert.ok(f.revival.steps.some(step => step.action.type === 'passResponse'), 'The earlier own-T limit response is actually allowed');
|
||||
if (f.unfunded) {
|
||||
assert.ok(f.funding); assert.equal(player(f.funding.after, f.payer).spice, 2);
|
||||
assert.ok(f.funding.steps.some(step => step.actor === f.payer && step.action.type === 'bribe' && step.action.target === f.spender));
|
||||
assert.equal(player(f.game, f.payer).spice, f.pending.cost);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(f.game.phase, 4); assert.equal(f.game.response?.kind, f.kind);
|
||||
assert.equal(f.game.response?.owner, f.owner); assert.equal(f.game.response?.recipient, f.recipient);
|
||||
assert.deepEqual(f.game.pendingRevival, f.pending);
|
||||
assert.deepEqual(player(f.game, f.recipient), player(f.beforeRevival, f.recipient));
|
||||
assert.deepEqual(quoteRevivalCancellation(f.game, f.game.response!), f.expected);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaRevivalBenefitsForcesFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRevivalDiscard');
|
||||
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 !== 'karamaRevivalDiscard') throw Error('Use the exact revival-benefit cost');
|
||||
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
|
||||
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
|
||||
assert.deepEqual(game.pendingRevival, f.pending); assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.decision ?? null, null); assert.equal(game.phaseOpening ?? 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;
|
||||
}
|
||||
/** Predict the exact price/free-event income independently. The last usable
|
||||
* counter's disposal may make that income auto-allow, while a reclaimed cost
|
||||
* may leave the same native income window actionable. */
|
||||
function settled(game: Game, f: SemutaRevivalBenefitsForcesFixture, claimed: boolean) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.equal(game.pendingKarama ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.deepEqual(game.revivalRules, f.expected.rules);
|
||||
const returned = f.expected.outcome === 'revive', quote = f.expected.pending;
|
||||
const nativeIncome = returned && f.expectedResume?.nextResponse?.kind === 'revivalIncome'
|
||||
? f.expectedResume.nextResponse : null;
|
||||
const income = game.response ? structuredClone(game.response) : null;
|
||||
const outstanding = income?.kind === 'revivalIncome' ? income.amount! : 0;
|
||||
const credited = (nativeIncome?.amount ?? 0) - outstanding;
|
||||
assert.ok(outstanding === 0 || outstanding === nativeIncome?.amount);
|
||||
assert.ok(credited === 0 || credited === nativeIncome?.amount);
|
||||
if (income) {
|
||||
assert.equal(income.kind, 'revivalIncome'); assert.ok(nativeIncome);
|
||||
assert.equal(income.owner, nativeIncome.owner); assert.equal(income.recipient, nativeIncome.recipient);
|
||||
assert.equal(income.amount, nativeIncome.amount);
|
||||
}
|
||||
const original = player(f.game, f.recipient), recipient = player(game, f.recipient);
|
||||
assert.equal(recipient.tanks, original.tanks - (returned ? f.amount : 0));
|
||||
assert.equal(recipient.reserves, original.reserves + (returned ? f.amount : 0));
|
||||
assert.equal(recipient.revived, original.revived + (returned ? f.amount : 0));
|
||||
assert.equal(recipient.freeForcesRevived ?? 0, (original.freeForcesRevived ?? 0) + (returned ? quote!.free : 0));
|
||||
assert.equal(player(game, f.payer).spice, player(f.game, f.payer).spice - (returned ? quote!.cost : 0) +
|
||||
(f.payer === f.tleilaxu ? credited : 0));
|
||||
assert.equal(player(game, f.tleilaxu).spice, player(f.game, f.tleilaxu).spice -
|
||||
(returned && f.payer === f.tleilaxu ? quote!.cost : 0) + credited);
|
||||
assert.deepEqual(recipient.forces, original.forces); assert.deepEqual(recipient.elites, original.elites);
|
||||
assert.deepEqual(game.freeRevival, f.game.freeRevival);
|
||||
assert.equal(game.active, f.game.active); assert.deepEqual(game.ready, f.game.ready);
|
||||
assert.equal(game.phase, 4); assert.equal(game.turn, f.game.turn);
|
||||
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));
|
||||
if (returned) {
|
||||
const resumed = f.expectedResume; assert.ok(resumed);
|
||||
assert.equal(resumed.outcome, 'revive'); assert.equal(resumed.pending.cost, quote!.cost);
|
||||
assert.ok(nativeIncome); assert.equal(nativeIncome.owner, f.tleilaxu);
|
||||
assert.equal(nativeIncome.recipient, f.payer); assert.equal(nativeIncome.amount, f.payer === f.tleilaxu ? 1 : quote!.cost);
|
||||
if (resumed.freeIncome) {
|
||||
assert.equal(resumed.freeIncome.player, f.payer);
|
||||
assert.equal(game.revivalFreeIncome?.[f.payer], f.game.turn);
|
||||
} else assert.deepEqual(game.revivalFreeIncome, f.game.revivalFreeIncome);
|
||||
} else {
|
||||
assert.equal(income, null); assert.deepEqual(game.revivalFreeIncome, f.game.revivalFreeIncome);
|
||||
}
|
||||
const beforeIncome = structuredClone(game), steps: { actor: string; action: Action }[] = [];
|
||||
game = finish(game, steps);
|
||||
assert.ok(steps.every(step => step.action.type === 'passResponse'));
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.equal(player(game, f.tleilaxu).spice, player(beforeIncome, f.tleilaxu).spice + outstanding);
|
||||
for (const id of game.players.map(player => player.id).filter(id => id !== f.tleilaxu))
|
||||
assert.deepEqual(player(game, id), player(beforeIncome, id));
|
||||
assert.equal(player(game, f.recipient).tanks, recipient.tanks);
|
||||
assert.equal(player(game, f.recipient).reserves, recipient.reserves);
|
||||
assert.equal(player(game, f.recipient).revived, recipient.revived);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind}${'amount' in variant ? ' above normal cap' : ''}${'unfunded' in variant ? ' unfunded' : ''} cost ${claimed ? 'claim' : 'pass'} precedes its original return outcome`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const game = settled(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.recipient, f.nativeAction);
|
||||
});
|
||||
|
||||
void test('native benefit allowances complete the original cheap/granted return once without spending either held card', () => {
|
||||
for (const variant of variants.filter(variant => !('unfunded' in variant))) {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
let game = allow(structuredClone(f.game));
|
||||
const beforeIncome = structuredClone(game), income = game.response?.kind === 'revivalIncome' ? game.response.amount! : 0;
|
||||
assert.equal(f.allowedResume.outcome, 'revive'); assert.equal(f.allowedResume.pending.cost, f.pending.cost);
|
||||
const expectedIncome = f.allowedResume.nextResponse?.kind === 'revivalIncome' ? f.allowedResume.nextResponse.amount! : 0;
|
||||
const credited = expectedIncome - income;
|
||||
assert.ok(credited === 0 || credited === expectedIncome);
|
||||
assert.equal(player(game, f.payer).spice, player(f.game, f.payer).spice - f.pending.cost +
|
||||
(f.payer === f.tleilaxu ? credited : 0));
|
||||
assert.equal(player(game, f.tleilaxu).spice, player(f.game, f.tleilaxu).spice -
|
||||
(f.payer === f.tleilaxu ? f.pending.cost : 0) + credited);
|
||||
if (f.allowedResume.freeIncome) assert.equal(game.revivalFreeIncome?.[f.payer], f.game.turn);
|
||||
assert.equal(player(game, f.recipient).tanks, player(f.game, f.recipient).tanks - f.amount);
|
||||
assert.equal(player(game, f.recipient).reserves, player(f.game, f.recipient).reserves + f.amount);
|
||||
assert.equal(player(game, f.recipient).revived, player(f.game, f.recipient).revived + f.amount);
|
||||
game = finish(game);
|
||||
assert.equal(player(game, f.tleilaxu).spice, player(beforeIncome, f.tleilaxu).spice + income);
|
||||
assert.equal(game.pendingRevival ?? null, null); assert.equal(game.response ?? null, null);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
void test('abandoned and unfunded native requests preserve a real smaller return opportunity', () => {
|
||||
for (const variant of [variants[2], variants[6], variants[7]]) {
|
||||
const f = fixture(variant), game = settled(claim(open(f), f.holder), f, true);
|
||||
const amount = f.kind === 'revivalDiscount' ? 2 : 3, before = structuredClone(game);
|
||||
let next = act(game, f.recipient, { type: 'revive', amount, elite: 0 });
|
||||
next = finish(next);
|
||||
assert.equal(player(next, f.recipient).tanks, player(before, f.recipient).tanks - amount);
|
||||
assert.equal(player(next, f.recipient).reserves, player(before, f.recipient).reserves + amount);
|
||||
assert.equal(player(next, f.recipient).revived, player(before, f.recipient).revived + amount);
|
||||
assert.equal(next.pendingRevival ?? null, null); assert.equal(next.response ?? null, null);
|
||||
assert.deepEqual(inventory(next), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fresh revival-benefit costs reject stale, foreign and underlying actions while the original request is unpaid', () => {
|
||||
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.recipient, f.nativeAction);
|
||||
reject(game, f.owner, { type: 'passResponse' });
|
||||
assert.deepEqual(game.pendingRevival, f.pending); assert.deepEqual(physical(game), physical(f.game));
|
||||
settled(pass(game), f, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer the actual force-benefit cancellation costs 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);
|
||||
settled(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
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 {
|
||||
createSemutaRevivalBenefitsLeadersFixture as fixture, actSemutaRevivalBenefitsLeaders as act,
|
||||
passSemutaRevivalBenefitsLeaders as pass, claimSemutaRevivalBenefitsLeaders as claim,
|
||||
allowSemutaRevivalBenefitsLeaders as allow, finishSemutaRevivalBenefitsLeaders as finish,
|
||||
semutaRevivalBenefitsLeadersPlayer as player, semutaRevivalBenefitsLeadersInventory as inventory,
|
||||
type SemutaRevivalBenefitsLeadersFixture,
|
||||
} from './fixture-semuta-revival-benefits-leaders';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'earlyRevival' as const, advanced: false }, { kind: 'earlyRevival' as const, advanced: true },
|
||||
{ kind: 'revivalDiscount' as const, advanced: false }, { kind: 'revivalDiscount' as const, advanced: true },
|
||||
{ kind: 'foreignGhola' as const, advanced: true },
|
||||
];
|
||||
const forceQuotas = (game: Game) => game.players.map(p => ({ id: p.id, reserves: p.reserves, tanks: p.tanks,
|
||||
forces: p.forces, elites: p.elites, moved: p.moved, shipped: p.shipped, revived: p.revived,
|
||||
freeForcesRevived: p.freeForcesRevived }));
|
||||
const unpaid = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||
forceQuotas: forceQuotas(game), revivalFreeIncome: game.revivalFreeIncome,
|
||||
players: game.players.map(p => ({ id: p.id, spice: p.spice, leaders: p.leaders,
|
||||
revivalCycle: p.revivalCycle, leaderRevived: p.leaderRevived, gholaBlocked: p.gholaBlocked })),
|
||||
pending: game.pendingRevival, rules: game.revivalRules });
|
||||
function leader(game: Game, id: string) {
|
||||
const found = game.players.flatMap(p => p.leaders).find(l => l.id === id); assert.ok(found); return found;
|
||||
}
|
||||
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: SemutaRevivalBenefitsLeadersFixture) {
|
||||
assert.equal(f.setup.advanced, f.advanced); assert.equal(f.game.advanced, f.advanced);
|
||||
assert.equal(f.setup.semutaPreview, true); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(player(f.afterSetup, f.owner).hand[0].kind, 'poison');
|
||||
assert.equal(player(f.afterSetup, f.opponent).hand[0].kind, 'poison');
|
||||
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);
|
||||
for (const shipment of f.shipments) {
|
||||
assert.equal(player(shipment.before, shipment.actor).spice - player(shipment.after, shipment.actor).spice, 2);
|
||||
assert.equal(player(shipment.after, shipment.actor).forces[f.location], 1);
|
||||
assert.ok(shipment.steps.some(step => step.action.type === 'ship' && step.action.amount === 1));
|
||||
}
|
||||
assert.ok(f.planSteps.every(step => step.action.dial === 0 && step.action.support === 0 && step.action.defense === null));
|
||||
const votes = f.battleResolution.filter(step => step.action.type === 'traitorCall');
|
||||
assert.equal(votes.length, 2); assert.ok(votes.every(step => step.action.call === false));
|
||||
assert.deepEqual(new Set(votes.map(step => step.actor)), new Set([f.owner, f.opponent]));
|
||||
assert.equal(leader(f.afterBattle, f.ownLeader).dead, true); assert.equal(leader(f.afterBattle, f.ownLeader).deaths, 1);
|
||||
assert.equal(leader(f.afterBattle, f.foreignLeader).dead, true); assert.equal(leader(f.afterBattle, f.foreignLeader).deaths, 1);
|
||||
assert.equal(f.afterBattle.lastBattleContext?.winner, f.owner);
|
||||
assert.equal(player(f.afterBattle, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(f.afterBattle, f.opponent).tanks, 1);
|
||||
const played = f.planSteps.map(step => String(step.action.weapon)).sort();
|
||||
assert.deepEqual(f.disposals.flatMap(disposal => disposal.cards).sort(), played);
|
||||
assert.ok(f.disposals.every(disposal => disposal.steps.every(step => step.action.type === 'semutaPass')));
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4); assert.ok(!f.game.battle);
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.response?.recipient, f.recipient); assert.deepEqual(f.game.pendingRevival, f.pending);
|
||||
assert.equal(player(f.game, f.payer).spice, player(f.beforeRequest, f.payer).spice);
|
||||
assert.ok(player(f.game, f.payer).spice >= f.normalCost);
|
||||
assert.equal(leader(f.game, f.targetLeader).dead, true); assert.equal(leader(f.game, f.ownLeader).dead, true);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
if (f.kind === 'revivalDiscount') {
|
||||
assert.ok(f.priorEarly); assert.equal(f.priorEarly.before.response?.kind, 'earlyRevival');
|
||||
assert.ok(f.priorEarly.steps.every(step => step.action.type === 'passResponse'));
|
||||
assert.equal(f.priorEarly.after.response?.kind, 'revivalDiscount');
|
||||
assert.equal(f.discountedCost, 1); assert.equal(f.normalCost, 2); assert.equal(f.expected.outcome, 'revive');
|
||||
assert.equal(f.expected.pending?.cost, 2);
|
||||
} else assert.equal(f.expected.outcome, 'abandoned');
|
||||
if (f.kind === 'foreignGhola') {
|
||||
assert.equal(f.advanced, true); assert.equal(f.targetLeader, f.foreignLeader);
|
||||
assert.equal(player(f.game, f.owner).leaders.filter(l => !l.dead).length, 4);
|
||||
assert.equal(leader(f.game, f.foreignLeader).gholaBy, undefined);
|
||||
}
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaRevivalBenefitsLeadersFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.continuation.kind, 'karamaRevivalDiscard');
|
||||
if (pending.continuation.kind !== 'karamaRevivalDiscard') throw Error('Expected the original unpaid revival-benefit cost');
|
||||
assert.deepEqual(pending.continuation.use.response, f.game.response);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]);
|
||||
assert.equal(game.response, null); assert.deepEqual(unpaid(game), unpaid(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.deepEqual(inventory(game), f.originalInventory); assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaRevivalBenefitsLeadersFixture, claimed: boolean) {
|
||||
const game = finish(state);
|
||||
assert.ok(!game.pendingTreacheryDiscard); assert.equal(game.pendingRevival ?? null, null);
|
||||
assert.equal(game.response, null); assert.equal(game.phase, 4); assert.equal(game.turn, 2);
|
||||
assert.deepEqual(game.revivalRules, f.expected.rules); assert.deepEqual(forceQuotas(game), forceQuotas(f.game));
|
||||
assert.deepEqual(game.revivalFreeIncome, f.game.revivalFreeIncome);
|
||||
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));
|
||||
for (const p of f.game.players.filter(p => p.id !== f.payer)) assert.equal(player(game, p.id).spice, p.spice);
|
||||
const returns = f.expected.outcome === 'revive';
|
||||
assert.equal(player(game, f.payer).spice, player(f.game, f.payer).spice - (returns ? f.expected.pending!.cost : 0));
|
||||
assert.equal(leader(game, f.targetLeader).dead, !returns); assert.equal(leader(game, f.targetLeader).deaths, 1);
|
||||
assert.equal(player(game, f.owner).leaderRevived, returns);
|
||||
if (f.kind === 'earlyRevival') assert.ok(game.revivalRules!.earlyBlocked.includes(`${f.owner}:${f.targetLeader}`));
|
||||
if (f.kind === 'foreignGhola') {
|
||||
assert.equal(player(game, f.owner).gholaBlocked?.[f.targetLeader], game.turn);
|
||||
assert.equal(leader(game, f.targetLeader).gholaBy, undefined);
|
||||
}
|
||||
if (f.kind === 'revivalDiscount') {
|
||||
assert.equal(game.revivalRules!.fullPrice, true); assert.equal(leader(game, f.foreignLeader).dead, true);
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.owner, f.nativeAction); reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} native leader ${variant.kind} cost Semuta ${claimed ? 'claim' : 'pass'} resumes only the original unpaid request`, () => {
|
||||
const f = fixture(variant); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native leader benefit allowance pays the original discounted price and returns only the requested disc once', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), game = finish(allow(structuredClone(f.game)));
|
||||
assert.equal(game.pendingRevival ?? null, null); assert.equal(game.response, null);
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - f.discountedCost);
|
||||
assert.equal(leader(game, f.targetLeader).dead, false); assert.equal(leader(game, f.targetLeader).deaths, 1);
|
||||
assert.equal(player(game, f.owner).leaderRevived, true); assert.deepEqual(forceQuotas(game), forceQuotas(f.game));
|
||||
assert.equal(leader(game, f.targetLeader).gholaBy ?? null, f.kind === 'foreignGhola' ? f.owner : null);
|
||||
if (f.kind === 'foreignGhola') assert.equal(leader(game, f.ownLeader).dead, true);
|
||||
else assert.equal(leader(game, f.foreignLeader).dead, true);
|
||||
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.owner, f.nativeAction);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native leader revival-cost reactions reject stale, foreign and underlying replies before repricing or return', () => {
|
||||
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, f.nativeAction); reject(game, f.owner, { type: 'passResponse' });
|
||||
for (const disposal of f.disposals)
|
||||
reject(game, f.holder, { type: 'semutaCommit', event: disposal.before.pendingTreacheryDiscard!.batch.event });
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer native leader early, discount and foreign-Ghola revival 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 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user