Offer Semuta on native movement cancellation costs
This commit is contained in:
1 parent
30c066726b
commit
d018a506e1
9 files changed
+972
-6
No files matched your search
@@ -16,6 +16,38 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**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.
|
||||
`karamaMovementDiscard` retains the original declaration, physical group, move
|
||||
index/queue and Storm inputs. Claim/pass finishes native denial once: Fremen/Ix
|
||||
keep an unused move and legal reduced-range replacement; Guild follows the
|
||||
original queue under its timing lock; HMS neither moves nor collects, then begins
|
||||
the original Storm turn once. Native quotes and Fremen's canonical order remain
|
||||
authoritative. Existing persistent controls and minimal profiles serve all four.
|
||||
[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#movement-cancellation-costs--26-october-2026).
|
||||
|
||||
Two disjoint source lanes, bounded native review and root integration pass
|
||||
**16 files,205/205**, types/lint/build and **16 native engine/GameTable SSR/real
|
||||
Semuta-callback journeys**, including actual range-one replacements. Twenty-two
|
||||
new cases cover both rule bands where applicable, Guild TAKE/WAIT, positive HMS
|
||||
collection, native allowance, relevant rejection and all four profiles. Real
|
||||
cache1 Semuta and original dealt Karama precede every cost. Fremen70/83 Basic/
|
||||
Advanced actions include actual free reserve entry; Guild77/80 uses real circle/
|
||||
Storm controls. Ix117/123 inherits paid acquisition75/80 then pays2 to ship a
|
||||
Cyborg. HMS154/160 declares a real three-step route over an eight-spice opportunity
|
||||
with six original passengers; denial retains it and initializes Storm once.
|
||||
Original45/57 cards remain physical. No played hand, force, clock, queue, route,
|
||||
Spice or pending receipt is installed. Fixture feedback repairs one absence
|
||||
assertion and two numeric sort comparators; native rule guards remain intact.
|
||||
Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-movement-lint/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-movement-runtime/`.
|
||||
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
|
||||
These are engine/SSR/callback results, not hydration, DOM/browser/mobile or
|
||||
deployed evidence. Card-flight/complex parents, wider combinations, complete
|
||||
modes and final assurance remain unfinished.
|
||||
|
||||
**Semuta on information/advisor cancellation costs — 26 October. Prototyped / Partial.**
|
||||
Printed-role Karama stopping ordinary Basic Spiritual Advisors, Atreides Movement
|
||||
foresight, Advanced Fremen next-Storm knowledge or native Face Dancer replacement
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Semuta claims native movement cancellation costs
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping ordinary Fremen/Cyborg
|
||||
range, Advanced Guild timing or HMS movement/collection now offers its exact cost
|
||||
before native denial. `karamaMovementDiscard` binds the original declaration,
|
||||
physical group, move/queue, route/collection and Storm-reset inputs. Native pure
|
||||
quotes retain authority, with Fremen's additional canonical order check; no allowed
|
||||
Ixian movement trial or future Storm is executed in preflight. Claim/pass resumes
|
||||
once. Fremen/Ix retain the unused move and a legal replacement; Guild follows the
|
||||
original first actor under its lock; HMS neither moves nor collects, then begins
|
||||
the original Storm turn once. Existing controls and minimal bots serve all four.
|
||||
[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#movement-cancellation-costs--26-october-2026).
|
||||
|
||||
Two disjoint source lanes, bounded native review and root integration pass
|
||||
**16 files,205/205**, types/lint/build and **16 native engine/GameTable SSR/actual
|
||||
Semuta-callback journeys**, including real reduced-range replacements. Twenty-two
|
||||
new cases cover applicable modes, TAKE/WAIT, positive HMS collection, native
|
||||
allowance, relevant rejection and all four profiles. Actual cache1 Semuta and
|
||||
original dealt Karama precede all costs: Fremen70/83 Basic/Advanced actions;
|
||||
Guild77/80 with real circles/Storm; Ix117/123 with inherited75/80 acquisition and
|
||||
paid2 Cyborg shipment; HMS154/160 with six original passengers and a three-step
|
||||
route over eight Spice. Original45/57 cards remain physical. No played-state
|
||||
hand, force, wallet, clock, queue, route, Spice or receipt is installed.
|
||||
|
||||
Feedback repairs one absence assertion and two numeric sort comparators in the
|
||||
new tests. No native gameplay guard is weakened. Private source-bound report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-movement-lint/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-movement-runtime/`.
|
||||
No saved-room/D1/server/reset/restart/deployment/recovery operation occurs.
|
||||
These are engine/SSR/callback results; hydration, DOM/browser/mobile, card-flight/
|
||||
complex parents, broader combinations, full modes and deployed/final assurance
|
||||
remain open.
|
||||
|
||||
## 26 October 2026 — Semuta claims information and advisor cancellation costs
|
||||
|
||||
**Prototyped / Partial.** Exact printed-role Karama stopping ordinary Basic
|
||||
|
||||
@@ -2,6 +2,69 @@
|
||||
|
||||
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.
|
||||
|
||||
## Movement cancellation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping ordinary native Fremen
|
||||
range, Ixian Cyborg range, Advanced Guild timing or HMS movement/collection now
|
||||
offers its exact used cost before prevention. `karamaMovementDiscard` binds the
|
||||
original typed response and pending declaration. Existing persistent neutral
|
||||
controls, private claim/pass and minimal profiles serve each source. This is the
|
||||
existing physical Semuta fresh-discard convention, not a new publisher ruling.
|
||||
|
||||
The native [movement cancellation quote](../game/karama-movement-cancellation.ts)
|
||||
validates denial without transferring forces, quoting a new collection or
|
||||
choosing a future Storm. Fremen additionally retains its canonical pending-order
|
||||
validation. Ixian denial keeps its native declaration-only validation rather than
|
||||
requiring an allowed movement. The cost is already physically spent; preflight
|
||||
never supplies it again as an unspent card.
|
||||
|
||||
After claim/pass, retire the fresh group, restore the original response and finish
|
||||
native prevention once. Fremen/Ixian packets clear while their current move index
|
||||
and physical group stay unspent; the native one-use restriction remains and a
|
||||
legal reduced-range replacement is available. Guild locks the prevented timing
|
||||
privilege and activates the original first remaining actor. A canceled HMS route
|
||||
neither relocates nor collects, then calls the original Storm-turn beginning once.
|
||||
Advanced may then naturally draw/consume a real Storm source and reach its next
|
||||
control; no forecast, shuffle, reset or collection is pre-executed for Semuta.
|
||||
|
||||
A new-kind-only signature binds the original packets, physical typed groups,
|
||||
movement queue/blocks, HMS pointer/route/collection/spice and native Storm reset
|
||||
inputs, including leader use and Hajr. Earlier receipt kinds omit this branch.
|
||||
Competing battle, shipment, arrival, Spiritual, CHOAM, worm and card-flight parents
|
||||
remain separate. No broad profile or release gate opens.
|
||||
|
||||
[Fremen/Guild source](../tests/fixture-semuta-fremen-guild-movement.ts) and
|
||||
[engine cases](../tests/semuta-fremen-guild-movement-engine.test.ts),
|
||||
[Ixian source](../tests/fixture-semuta-ixian-movement.ts) and
|
||||
[engine cases](../tests/semuta-ixian-movement-engine.test.ts) record the native
|
||||
action paths. Wider producers/combinations, complete modes and final assurance
|
||||
remain open.
|
||||
|
||||
Frozen **16 files,205/205**, types/lint/build and **16 actual native engine/
|
||||
GameTable SSR/real Semuta-callback journeys** pass on unchanged source, including
|
||||
legal range-one replacements. Twenty-two new cases cover both applicable rule
|
||||
bands, TAKE/WAIT, positive collection, native allowance, stale/underlying rejection
|
||||
and all four minimal profiles.
|
||||
|
||||
Real cache1 Semuta and original dealt Karama supply each source. Fremen70/83
|
||||
Basic/Advanced action prefixes include actual free reinforcement at Great Flat15;
|
||||
Guild77/80 uses real lobby circle choices and first-Storm dials. Ix117/123 inherits
|
||||
the paid acquisition75/80, then pays2 to ship one Cyborg before declaring its
|
||||
range-two group. HMS154/160 retains its six original passengers and declares
|
||||
Polar0 → Hagga12 → Plastic12 → Broken Land12 over an actual eight-spice opportunity.
|
||||
No movement/collection or new Storm occurs before the fresh cost. Prevention
|
||||
keeps the pile/HMS intact and begins Storm once; Advanced reveals its actual card
|
||||
only afterward. Original45/57 cards remain physical. No played-state hand, force,
|
||||
wallet, clock, queue, route, Spice or pending receipt is installed.
|
||||
|
||||
Feedback repairs an absent-versus-null opening assertion and two test-only numeric
|
||||
sort comparators. Native gameplay guards remain intact. Private source-bound
|
||||
report/logs: `/home/quintine/.local/share/dune/checkpoints/20261026-semuta-movement-lint/`;
|
||||
journeys/HTML: adjacent `20261026-semuta-movement-runtime/`.
|
||||
No saved-room/database/server/reset/restart/deployment/recovery operation occurs.
|
||||
Engine/SSR/callback evidence leaves hydration, DOM/browser/mobile, broader
|
||||
combinations, complete modes and exact-revision deployed acceptance open.
|
||||
|
||||
## Terminal information and advisor cancellation costs — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Printed-role Karama stopping ordinary Basic Spiritual
|
||||
|
||||
+60
-6
@@ -1576,7 +1576,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -5886,9 +5886,31 @@ function semutaTerminalKaramaResponse(g: Game, response: ResponseWindow, player:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Park only the original uncommitted movement/timing attempt. Native quotes
|
||||
* validate denial; no force transfer, collection or Storm opening runs here. */
|
||||
function semutaMovementKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean {
|
||||
if (!['fremenMovement','ixMovement','mobileStronghold','guildTiming'].includes(response.kind) ||
|
||||
g.battle || g.decision || g.phaseOpening || g.pendingKarama || g.pendingRevival ||
|
||||
g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
|
||||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry ||
|
||||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || g.pendingChoamMove ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
|
||||
g.choamMarket || g.pendingChoamMarketGhola) return false;
|
||||
const packets = Number(!!g.pendingFremenMove) + Number(!!g.pendingIxMove) + Number(!!g.pendingMobileMove);
|
||||
if (packets !== (response.kind === 'guildTiming' ? 0 : 1)) return false;
|
||||
const context: Game = {...g,pendingTreacheryDiscard:null,response};
|
||||
try {
|
||||
validateKaramaUse(context,getPlayer(context,player),{kind:'cancel',response});
|
||||
if (response.kind === 'fremenMovement') pendingFremenMovement(context,response);
|
||||
return movementCancellationQuote(context,response)?.kind === response.kind;
|
||||
} 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' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' }
|
||||
>;
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
@@ -5901,6 +5923,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
const richese = c.kind === 'karamaRicheseAuctionDiscard' || c.kind === 'karamaRicheseGiftDiscard';
|
||||
const auctionAdvantage = c.kind === 'karamaAuctionAdvantageDiscard';
|
||||
const terminal = c.kind === 'karamaTerminalDiscard';
|
||||
const movement = c.kind === 'karamaMovementDiscard';
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
@@ -5993,12 +6016,32 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
noField:p.noField, noFieldEvent:p.noFieldEvent, faceDancers:p.faceDancers,
|
||||
replaced:p.faceDancerReplacedTurn})),
|
||||
} : undefined,
|
||||
movement: movement ? {
|
||||
fremen:g.pendingFremenMove, ix:g.pendingIxMove, mobile:g.pendingMobileMove,
|
||||
stronghold:g.mobileStronghold, spice:g.spice,
|
||||
remaining:g.movementRemaining, active:g.active, ready:g.ready,
|
||||
guildGranted:g.guildTimingGranted, guildLocked:g.guildTimingLocked,
|
||||
saphoLast:g.saphoMovementLast, hajr:g.hajr, nexus:g.nexus,
|
||||
lastBattle:g.lastBattle, storm:g.storm, stormDialers:g.stormDialers,
|
||||
stormDials:g.stormDials, stormPending:g.stormPending, stormCard:g.stormCard,
|
||||
stormKnown:g.stormCardKnown, stormResolution:g.stormResolution,
|
||||
stormSource:g.stormMovementSource, duke:g.dukeVidal,
|
||||
homeworlds:g.homeworlds, occupation:g.homeworldOccupationHistory,
|
||||
skills:g.leaderSkills, shipmentPromises:g.shipmentPromises, movementPromises:g.movementPromises,
|
||||
choamMovement:g.choamMovement, baliset:g.choamBaliset,
|
||||
players:g.players.map(p => ({id:p.id, moved:p.moved, advisors:p.advisors,
|
||||
noField:p.noField, noFieldEvent:p.noFieldEvent, fremenBlocked:p.fremenMovementBlocked,
|
||||
ixBlocked:p.ixMovementBlocked, nexusFremenBlocked:p.fremenNexusMovementBlockedTurn,
|
||||
nexusWormBlocked:p.fremenNexusWormBlockedTurn,
|
||||
hajr:p.hajrPlays, hajrWindow:p.hajrWindow, ornithopterExtra:p.ornithopterExtraMoves,
|
||||
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 ? p.shipped : undefined,
|
||||
physical: battle || worthless || income || shipment || conversion || terminal
|
||||
shipped: income || shipment || conversion || terminal || movement ? p.shipped : undefined,
|
||||
physical: battle || worthless || income || shipment || conversion || terminal || movement
|
||||
? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, leaders: battle ? p.leaders : undefined,
|
||||
noField: shipment ? p.noField : undefined,
|
||||
@@ -6913,6 +6956,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
continuation?.kind === 'karamaRicheseGiftDiscard' ||
|
||||
continuation?.kind === 'karamaAuctionAdvantageDiscard' ||
|
||||
continuation?.kind === 'karamaTerminalDiscard' ||
|
||||
continuation?.kind === 'karamaMovementDiscard' ||
|
||||
continuation?.kind === 'karamaShipmentDiscard' ||
|
||||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
@@ -6945,6 +6989,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
semutaAuctionAdvantageResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaTerminalDiscard' &&
|
||||
semutaTerminalKaramaResponse(g,c.use.response,c.player,c.resume.decision)) ||
|
||||
(c.kind === 'karamaMovementDiscard' &&
|
||||
semutaMovementKaramaResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaShipmentDiscard' &&
|
||||
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
|
||||
(c.kind === 'karamaBgConversionCancelDiscard' &&
|
||||
@@ -7849,6 +7895,7 @@ function semutaOfferSupported(
|
||||
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
|
||||
const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard';
|
||||
const terminalDiscard = continuation.kind === 'karamaTerminalDiscard';
|
||||
const movementDiscard = continuation.kind === 'karamaMovementDiscard';
|
||||
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
|
||||
continuation.kind === 'karamaBgConversionCancelDiscard';
|
||||
const ambassadorDiscard = continuation.kind === 'ambassador';
|
||||
@@ -7887,7 +7934,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
|
||||
continuation.kind !== 'karamaEmperorShareDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentPriceDiscard' &&
|
||||
continuation.kind !== 'specialKaramaDiscard' &&
|
||||
@@ -7924,7 +7971,7 @@ function semutaOfferSupported(
|
||||
continuation.promise.kind === 'shipment' ||
|
||||
continuation.promise.kind === 'movement' ||
|
||||
continuation.promise.kind === 'battle'))) return false;
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
continuation.kind !== 'tupileCleanupDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
@@ -8029,6 +8076,7 @@ function semutaOfferSupported(
|
||||
richeseDiscard ||
|
||||
auctionAdvantageDiscard ||
|
||||
terminalDiscard ||
|
||||
movementDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard'
|
||||
? (continuation.kind === 'karamaBattleDiscard'
|
||||
? semutaBattleKaramaResponse(g, continuation.use.response)
|
||||
@@ -8044,6 +8092,8 @@ function semutaOfferSupported(
|
||||
? semutaAuctionAdvantageResponse(g,continuation.use.response,continuation.player)
|
||||
: terminalDiscard
|
||||
? semutaTerminalKaramaResponse(g,continuation.use.response,continuation.player,continuation.resume.decision)
|
||||
: movementDiscard
|
||||
? semutaMovementKaramaResponse(g,continuation.use.response,continuation.player)
|
||||
: continuation.kind === 'karamaShipmentDiscard'
|
||||
? semutaGuildShipmentKaramaResponse(g, continuation.use.response)
|
||||
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
|
||||
@@ -8080,6 +8130,7 @@ function semutaOfferSupported(
|
||||
richeseDiscard ||
|
||||
auctionAdvantageDiscard ||
|
||||
terminalDiscard ||
|
||||
movementDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard' ||
|
||||
((continuation.kind === 'ordinaryCardDiscard'
|
||||
? ordinaryDiscardIncomeMatches(g, continuation)
|
||||
@@ -8437,6 +8488,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
next.kind === 'karamaRicheseGiftDiscard' ||
|
||||
next.kind === 'karamaAuctionAdvantageDiscard' ||
|
||||
next.kind === 'karamaTerminalDiscard' ||
|
||||
next.kind === 'karamaMovementDiscard' ||
|
||||
next.kind === 'karamaShipmentDiscard' ||
|
||||
next.kind === 'karamaBgConversionCancelDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard' ||
|
||||
@@ -15792,6 +15844,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
semutaIncomeKaramaResponse(g, use.response) || semutaRicheseKaramaResponse(g,use.response) ||
|
||||
semutaAuctionAdvantageResponse(g,use.response,p.id) ||
|
||||
semutaTerminalKaramaResponse(g,use.response,p.id) ||
|
||||
semutaMovementKaramaResponse(g,use.response,p.id) ||
|
||||
guildShipmentDiscard || bgConversionCancelDiscard)) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
|
||||
@@ -15800,6 +15853,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
: use.response.kind === 'richeseGift' ? 'karamaRicheseGiftDiscard'
|
||||
: ['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'
|
||||
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
|
||||
: use.response.kind === 'choamInflation'
|
||||
? 'karamaInflationDiscard'
|
||||
|
||||
@@ -1292,6 +1292,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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 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.',
|
||||
'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.',
|
||||
'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.',
|
||||
@@ -1432,6 +1433,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-spiritual-foresight-engine.test.ts',
|
||||
'tests/fixture-semuta-storm-dancer.ts',
|
||||
'tests/semuta-storm-dancer-engine.test.ts',
|
||||
'tests/fixture-semuta-fremen-guild-movement.ts',
|
||||
'tests/semuta-fremen-guild-movement-engine.test.ts',
|
||||
'tests/fixture-semuta-ixian-movement.ts',
|
||||
'tests/semuta-ixian-movement-engine.test.ts',
|
||||
'tests/semuta-homeworld-bg-charity.test.ts',
|
||||
'tests/semuta-homeworld-emperor-share.test.ts',
|
||||
'tests/semuta-homeworld-capture-karama.test.ts',
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
|
||||
import { PLAYER_CIRCLE_SECTORS } from '../game/player-positions';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import {
|
||||
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
|
||||
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory,
|
||||
semutaRicheseCacheKaramaPlayer,
|
||||
} from './fixture-semuta-richese-cache-karama';
|
||||
|
||||
export type SemutaFremenGuildMovementStep = { actor: string; action: Action };
|
||||
export const actSemutaFremenGuildMovement = actSemutaRicheseCacheKarama;
|
||||
export const semutaFremenGuildMovementPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaFremenGuildMovementInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaFremenGuildMovementClean = semutaRicheseCacheKaramaClean;
|
||||
|
||||
export function nextSemutaFremenGuildMovement(game: Game, guildWait = false): SemutaFremenGuildMovementStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const fremen = game.players.find(player => player.faction === 'fremen');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && fremen?.reserves === 20 &&
|
||||
viewGame(game, fremen.id).setupPending.includes(fremen.id))
|
||||
return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } };
|
||||
if (game.decision?.kind === 'guildTiming')
|
||||
return { actor: game.decision.player, action: { type: 'decision', take: false } };
|
||||
}
|
||||
const next = nextSemutaRicheseCacheKarama(game);
|
||||
if (next?.action.type === 'stormDial' && game.turn === 1)
|
||||
return { actor: next.actor, action: { ...next.action,
|
||||
amount: guildWait && next.actor === game.stormDialers[0] ? 6 : 0 } };
|
||||
return next;
|
||||
}
|
||||
export function advanceSemutaFremenGuildMovement(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaFremenGuildMovementStep[] = [], guildWait = false): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaFremenGuildMovement(game, guildWait);
|
||||
assert.ok(next, 'Resolve the original fresh reaction or native declaration before continuing');
|
||||
game = actSemutaFremenGuildMovement(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Fremen/Guild source did not reach its finite boundary');
|
||||
}
|
||||
export function semutaFremenGuildMove(game: Game, range: 1 | 2, from = 'sietch_tabr:14'): Action {
|
||||
const to = TERRITORIES.filter(territory => territory.type === 'sand').flatMap(territory =>
|
||||
territory.sectors.filter(sector => sector !== game.storm).map(sector => location(territory.id, sector)))
|
||||
.find(to => gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) === range);
|
||||
assert.ok(to, `The original Tabr group needs a legal range-${range} sand destination`);
|
||||
const destination = splitLocation(to);
|
||||
return { type: 'move', from, amount: 1, elite: 0, territory: destination.territory, sector: destination.sector };
|
||||
}
|
||||
|
||||
/** Original public Basic/undealt Advanced setup and paid cache1. WAIT uses only
|
||||
* actual lobby circle choices and first-Storm dials, never installed order. */
|
||||
export function createSemutaFremenGuildMovementFixture(options: {
|
||||
kind?: 'fremenMovement' | 'guildTiming'; advanced?: boolean; take?: boolean;
|
||||
} = {}) {
|
||||
const kind = options.kind ?? 'fremenMovement';
|
||||
const advanced = options.advanced ?? kind === 'guildTiming', take = options.take ?? true;
|
||||
assert.ok(kind !== 'guildTiming' || advanced, 'Flexible native Guild timing is Advanced only');
|
||||
const guildWait = kind === 'guildTiming' && !take;
|
||||
const richese = 'richese', holder = 'choam', fremen = 'fremen', guild = 'guild', spender = 'emperor';
|
||||
const owner = kind === 'fremenMovement' ? fremen : guild;
|
||||
let game = createGame('SEMUTAFREMENGUILDMOVEMENT', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of [holder, fremen, guild, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaFremenGuildMovementStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaFremenGuildMovement(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceSemutaFremenGuildMovement(game, until, actions, guildWait);
|
||||
};
|
||||
if (guildWait) {
|
||||
act(fremen, { type: 'seatPosition', position: 6 });
|
||||
act(guild, { type: 'seatPosition', position: 3 });
|
||||
act(fremen, { type: 'seatPosition', position: 4 });
|
||||
}
|
||||
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 ? 'original-undealt-semuta-preview' : 'public-basic-start' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaFremenGuildMovementInventory(game);
|
||||
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 4); assert.equal(ordinary.length, 4);
|
||||
const deal = [...ordinary.map(card => card.id), printed.id], cost = printed.id;
|
||||
const dealOrder = deal.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
||||
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).forces['sietch_tabr:14'], 10);
|
||||
assert.ok(semutaFremenGuildMovementPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const front: Game['spiceDeck'] = [], spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < (advanced ? 2 : 1); position++) {
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; front.push(card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaFremenGuildMovementStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaFremenGuildMovementPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaFremenGuildMovement(game, guildWait); 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(semutaFremenGuildMovementPlayer(beforeBid, holder).spice - semutaFremenGuildMovementPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaFremenGuildMovementPlayer(game, richese).spice - semutaFremenGuildMovementPlayer(beforeBid, richese).spice, 1);
|
||||
let shipment: { before: Game; action: Action; after: Game; steps: SemutaFremenGuildMovementStep[] } | null = null;
|
||||
let beforeDeclaration: Game, nativeAction: Action;
|
||||
if (kind === 'fremenMovement') {
|
||||
reach(state => state.phase === 5 && state.active === fremen && semutaFremenGuildMovementClean(state));
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
const action: Action = { type: 'ship', territory: 'the_great_flat', sector: 15, amount: 1, elite: 0, allyPayment: 0 };
|
||||
act(fremen, action); reach(semutaFremenGuildMovementClean);
|
||||
shipment = { before, action, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
|
||||
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).shipped, true);
|
||||
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).moved, 0);
|
||||
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).forces['the_great_flat:15'], 1);
|
||||
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).reserves, semutaFremenGuildMovementPlayer(before, fremen).reserves - 1);
|
||||
assert.equal(semutaFremenGuildMovementPlayer(game, fremen).spice, semutaFremenGuildMovementPlayer(before, fremen).spice);
|
||||
beforeDeclaration = structuredClone(game); nativeAction = semutaFremenGuildMove(game, 2);
|
||||
act(fremen, nativeAction);
|
||||
assert.ok(game.pendingFremenMove);
|
||||
assert.deepEqual(semutaFremenGuildMovementPlayer(game, fremen), semutaFremenGuildMovementPlayer(beforeDeclaration, fremen));
|
||||
} else {
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
|
||||
assert.equal(game.decision?.player, guild);
|
||||
assert.equal(game.movementRemaining![0] === guild, !take);
|
||||
if (guildWait) {
|
||||
assert.equal(game.playerPositions![guild], 3);
|
||||
assert.equal(PLAYER_CIRCLE_SECTORS[game.playerPositions![guild] - 1], 8);
|
||||
assert.equal(game.storm, 7);
|
||||
assert.deepEqual(actions.filter(step => step.action.type === 'stormDial').map(step => step.action.amount).sort((a,b) => Number(a)-Number(b)), [0, 6]);
|
||||
}
|
||||
beforeDeclaration = structuredClone(game); nativeAction = { type: 'decision', take };
|
||||
act(guild, nativeAction);
|
||||
assert.equal(game.response?.take, take); assert.equal(game.active, null);
|
||||
}
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision, null);
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.ok(semutaFremenGuildMovementPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaFremenGuildMovementPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaFremenGuildMovementInventory(game), originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return {
|
||||
game, source, beforeCost: source, kind, advanced, take, guildWait,
|
||||
richese, holder, fremen, guild, spender, owner, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
action, nativeAction, beforeDeclaration, pending: structuredClone(game.pendingFremenMove ?? null),
|
||||
originalResponse: structuredClone(game.response), originalOrder: [...game.order],
|
||||
originalRemaining: [...game.movementRemaining!], originalActive: game.active,
|
||||
shipment, initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder,
|
||||
originalSpice, spiceOrder, acquired, acquisition, originalInventory, inventory: originalInventory,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
advanced ? 'Original Advanced Semuta initializer on the ready undealt R/C/F/G/E lobby.' : 'Actual public bare Basic R/C/F/G/E ready/start initializes its original Semuta preview.',
|
||||
'Only unopened original deal/Spice positions and scalar lotteries are selected. Fremen really places ten starting forces at Tabr14; Emperor really receives its printed Karama. CHOAM pays one in the native first cache auction for the original Semuta.',
|
||||
kind === 'fremenMovement'
|
||||
? 'Earlier original combined turns finish. Fremen actually ships one native reserve free to The Great Flat15, then declares one ordinary Tabr force to a shared-board distance-two sand destination. Its actual native response retains the exact uncommitted group and move0.'
|
||||
: guildWait
|
||||
? 'Actual lobby circle moves Fremen to temporary empty6, Guild to vacated3, then Fremen to vacated4. Printed circle3 points to sector8. Native first-Storm dials6+0 move the storm from1 to7, genuinely making Guild first. Guild really chooses WAIT out of order; no order, clock, grant or pending response is installed.'
|
||||
: 'Default native circles and original first-Storm zero dials leave Guild nonfirst. At the actual first phase-five timing decision Guild chooses TAKE out of order, before any physical combined turn.',
|
||||
'Stop before the exact dealt cancellation cost. No played hand, wallet, force, clock, queue, shipment allowance or pending receipt is assigned.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaFremenGuildMovementFixture = ReturnType<typeof createSemutaFremenGuildMovementFixture>;
|
||||
|
||||
export function passSemutaFremenGuildMovement(game: Game, actions: SemutaFremenGuildMovementStep[] = []): 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(player => !reaction.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaFremenGuildMovement(game, actor.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||||
}
|
||||
export function claimSemutaFremenGuildMovement(game: Game, holder: string,
|
||||
actions: SemutaFremenGuildMovementStep[] = []): 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 = actSemutaFremenGuildMovement(game, holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaFremenGuildMovement(game, holder, { type: 'semutaSelect', event, card }, actions);
|
||||
return game;
|
||||
}
|
||||
export function allowSemutaFremenGuildMovement(game: Game, actions: SemutaFremenGuildMovementStep[] = []): Game {
|
||||
const kind = game.response?.kind; assert.ok(kind === 'fremenMovement' || kind === 'guildTiming');
|
||||
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaFremenGuildMovement(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind); return game;
|
||||
}
|
||||
export function replaceSemutaFremenMovement(game: Game, fixture: Pick<SemutaFremenGuildMovementFixture, 'fremen'>,
|
||||
actions: SemutaFremenGuildMovementStep[] = []) {
|
||||
assert.equal(game.active, fixture.fremen); assert.equal(game.response, null);
|
||||
const before = structuredClone(game), replacement = semutaFremenGuildMove(game, 1);
|
||||
game = actSemutaFremenGuildMovement(game, fixture.fremen, replacement, actions);
|
||||
game = advanceSemutaFremenGuildMovement(game, semutaFremenGuildMovementClean, actions);
|
||||
const afterReplacement = structuredClone(game), end: Action = { type: 'endMovement' };
|
||||
assert.equal(semutaFremenGuildMovementPlayer(game, fixture.fremen).moved, 1);
|
||||
game = actSemutaFremenGuildMovement(game, fixture.fremen, end, actions);
|
||||
return { game, before, replacement, afterReplacement, end };
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { MOBILE_LOCATION, TERRITORIES, location, mobileRoutes, mobileRouteDistance, splitLocation } from '../game/board';
|
||||
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { stormCardDistance } from '../game/storm-cards';
|
||||
import {
|
||||
createSemutaIxianAuctionFixture, actSemutaIxianAuction, nextSemutaIxianAuctionStep,
|
||||
semutaIxianAuctionPlayer, semutaIxianAuctionInventory, type SemutaIxianAuctionStep,
|
||||
} from './fixture-semuta-ixian-auction';
|
||||
|
||||
export type SemutaIxianMovementStep = SemutaIxianAuctionStep;
|
||||
export type SemutaIxianMovementKind = 'ixMovement' | 'mobileStronghold';
|
||||
export const actSemutaIxianMovement = actSemutaIxianAuction;
|
||||
export const semutaIxianMovementPlayer = semutaIxianAuctionPlayer;
|
||||
export const semutaIxianMovementInventory = semutaIxianAuctionInventory;
|
||||
export const semutaIxianMovementClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingIxMove &&
|
||||
!game.pendingMobileMove && !game.pendingChoamMove;
|
||||
export function semutaIxianMovementStorm(game: Game) {
|
||||
return { turn: game.turn, phase: game.phase, storm: game.storm, pending: game.stormPending,
|
||||
source: game.stormMovementSource, dialers: game.stormDialers, dials: game.stormDials,
|
||||
card: game.stormCard, known: game.stormCardKnown,
|
||||
revealedCards: game.log.flatMap(entry => { const distance = stormCardDistance(entry.component); return distance === null ? [] : [distance]; }),
|
||||
turnBeginnings: game.log.filter(entry => entry.text === `Turn ${game.turn} begins.`).length };
|
||||
}
|
||||
/** Never silently answer the tested movement counter, fresh disposal or native
|
||||
* next-turn HMS route chooser. Other original optional controls are declined. */
|
||||
export function nextSemutaIxianMovementStep(game: Game): SemutaIxianMovementStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance ||
|
||||
game.response?.kind === 'ixMovement' || game.response?.kind === 'mobileStronghold') return null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'mobileStronghold' && !game.decision.placement)
|
||||
return null;
|
||||
return nextSemutaIxianAuctionStep(game);
|
||||
}
|
||||
export function advanceSemutaIxianMovement(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaIxianMovementStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaIxianMovementStep(game);
|
||||
assert.ok(next, 'Choose the original movement/route or fresh reaction before continuing');
|
||||
game = actSemutaIxianMovement(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Ixian movement programme did not reach its finite boundary');
|
||||
}
|
||||
export function passSemutaIxianMovement(game: Game, actions: SemutaIxianMovementStep[] = []): 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 = actSemutaIxianMovement(game, actor.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||||
}
|
||||
export function claimSemutaIxianMovement(game: Game, holder: string,
|
||||
actions: SemutaIxianMovementStep[] = []): 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 = actSemutaIxianMovement(game, holder, { type: 'semutaCommit', event }, actions);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaIxianMovement(game, holder, { type: 'semutaSelect', event, card }, actions);
|
||||
return game;
|
||||
}
|
||||
/** Finish only the current native allowance. HMS returns at the real newly
|
||||
* initialized Storm boundary; caller decides when to reveal/traverse it. */
|
||||
export function allowSemutaIxianMovement(game: Game, actions: SemutaIxianMovementStep[] = []): Game {
|
||||
const kind = game.response?.kind; assert.ok(kind === 'ixMovement' || kind === 'mobileStronghold');
|
||||
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 = actSemutaIxianMovement(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind);
|
||||
if (kind === 'ixMovement') game = advanceSemutaIxianMovement(game, semutaIxianMovementClean, actions);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Fork the exact native paid-Semuta source before its unused auction counter,
|
||||
* then actually allow all original setup/Bidding/Revival controls. No card,
|
||||
* resource, force, phase, queue, route or pending movement is installed. */
|
||||
export function createSemutaIxianMovementFixture(options: { kind?: SemutaIxianMovementKind; advanced?: boolean } = {}) {
|
||||
const kind = options.kind ?? 'ixMovement', advanced = options.advanced ?? false;
|
||||
const parent = createSemutaIxianAuctionFixture({ advanced });
|
||||
let game = structuredClone(parent.game);
|
||||
const actions: SemutaIxianMovementStep[] = structuredClone(parent.actions);
|
||||
const owner = parent.owner, holder = parent.holder, spender = parent.spender, cost = parent.cost;
|
||||
const act = (actor: string, action: Action) => { game = actSemutaIxianMovement(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaIxianMovement(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) };
|
||||
};
|
||||
const originalPoint = game.mobileStronghold!.location; assert.equal(originalPoint, 'polar_sink:0');
|
||||
const source = 'polar_sink:0';
|
||||
let shipment: { before: Game; steps: SemutaIxianMovementStep[]; after: Game } | null = null;
|
||||
let replacement: Action | null = null, destination: string | null = null, replacementDestination: string | null = null;
|
||||
let route: string[] | null = null;
|
||||
let collection: { location: string; available: number; collected: number }[] = [];
|
||||
let nativeAction: Action;
|
||||
if (kind === 'ixMovement') {
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaIxianMovementClean(state));
|
||||
shipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 1, allyPayment: 0 });
|
||||
reach(semutaIxianMovementClean);
|
||||
});
|
||||
assert.equal(semutaIxianMovementPlayer(shipment.before, owner).spice - semutaIxianMovementPlayer(game, owner).spice, 2);
|
||||
assert.equal(semutaIxianMovementPlayer(game, owner).forces[source], 1);
|
||||
assert.equal(semutaIxianMovementPlayer(game, owner).elites!.forces[source], 1);
|
||||
assert.equal(semutaIxianMovementPlayer(game, owner).forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(semutaIxianMovementPlayer(game, owner).moved, 0);
|
||||
const view = viewGame(game, owner), own = view.players.find(p => p.id === owner)!;
|
||||
const emptySand = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => location(t.id, sector)))
|
||||
.filter(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)));
|
||||
destination = emptySand.find(key => groundMoveRouteDistance(view, own, source, key) === 2 &&
|
||||
botGroundMoveAllowed(view, own, source, key, 1)) ?? null;
|
||||
replacementDestination = emptySand.find(key => groundMoveRouteDistance(view, own, source, key) === 1 &&
|
||||
botGroundMoveAllowed(view, own, source, key, 1)) ?? null;
|
||||
assert.ok(destination && replacementDestination, 'The actually shipped Cyborg has real two- and one-step destinations');
|
||||
nativeAction = { type: 'move', forces: { [source]: 1 }, eliteForces: { [source]: 1 }, ...splitLocation(destination) };
|
||||
replacement = { type: 'move', forces: { [source]: 1 }, eliteForces: { [source]: 1 }, ...splitLocation(replacementDestination) };
|
||||
} else {
|
||||
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'mobileStronghold' && !state.decision.placement);
|
||||
const passengers = semutaIxianMovementPlayer(game, owner).forces[MOBILE_LOCATION]; assert.equal(passengers, 6);
|
||||
// Prefer a genuinely positive prior blow beyond the next ordinary Storm's
|
||||
// maximum sector7, so post-Storm continuation can also inspect the same pile.
|
||||
route = mobileRoutes(game, 3).find(candidate => candidate.some(key => (game.spice[key] ?? 0) > 0) &&
|
||||
candidate.filter(key => (game.spice[key] ?? 0) > 0).every(key => splitLocation(key).sector > 7)) ?? null;
|
||||
assert.ok(route, `Native HMS needs a printed <=3 route over actual prior Spice: ${JSON.stringify(game.spice)}`);
|
||||
assert.ok(mobileRouteDistance(route) <= 3);
|
||||
collection = route.filter(key => (game.spice[key] ?? 0) > 0).map(key => ({ location: key,
|
||||
available: game.spice[key], collected: Math.min(game.spice[key], passengers * 2) }));
|
||||
assert.ok(collection.length && collection.reduce((sum, pile) => sum + pile.collected, 0) > 0);
|
||||
nativeAction = { type: 'decision', route, collect: true };
|
||||
destination = route.at(-1)!;
|
||||
}
|
||||
const beforeDeclaration = structuredClone(game), declaration = transition(() => act(owner, nativeAction));
|
||||
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
|
||||
if (kind === 'ixMovement') {
|
||||
assert.ok(game.pendingIxMove); assert.equal(semutaIxianMovementPlayer(game, owner).moved, 0);
|
||||
assert.deepEqual(semutaIxianMovementPlayer(game, owner).forces, semutaIxianMovementPlayer(beforeDeclaration, owner).forces);
|
||||
} else {
|
||||
assert.deepEqual(game.pendingMobileMove?.route, route); assert.equal(game.pendingMobileMove?.collect, true);
|
||||
assert.equal(game.mobileStronghold!.location, originalPoint);
|
||||
assert.deepEqual(game.spice, beforeDeclaration.spice);
|
||||
}
|
||||
assert.ok(semutaIxianMovementPlayer(game, holder).hand.some(card => card.id === parent.semuta));
|
||||
assert.ok(semutaIxianMovementPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaIxianMovementInventory(game), parent.originalInventory);
|
||||
const gameSource = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source: gameSource, beforeCost: gameSource, kind, advanced, owner, holder, spender,
|
||||
cost, karama: cost, card: cost, semuta: parent.semuta, action, nativeAction, beforeDeclaration, declaration,
|
||||
pending: structuredClone(kind === 'ixMovement' ? game.pendingIxMove : game.pendingMobileMove),
|
||||
shipment, sourceLocation: source, destination, replacement, replacementDestination,
|
||||
originalPoint, route, collect: kind === 'mobileStronghold', collection,
|
||||
collectionAmount: collection.reduce((sum, pile) => sum + pile.collected, 0),
|
||||
stormBefore: structuredClone(semutaIxianMovementStorm(game)), parent,
|
||||
acquisition: parent.acquisition, acquired: parent.acquired, setup: parent.setup, afterSetup: parent.afterSetup,
|
||||
originalInventory: parent.originalInventory, originalCards: parent.originalInventory,
|
||||
inheritedActionCount: parent.sourceActionCount, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [...parent.staging,
|
||||
'Fork the original paid-cache source before its unused Karama counter, then actually allow the original Ix pool, optional Technology decline, Atreides inspection and native normal auctions/Revival. Originally dealt Atreides Karama and paid CHOAM Semuta remain held.',
|
||||
kind === 'ixMovement' ? 'Ix actually pays2 to ship one original Cyborg reserve to Polar Sink, then declares an empty two-step sand destination through its printed movement advantage. A distinct empty one-step native replacement is exposed; no move or counter is precompleted.' : `Every actual first-turn combined turn ends without moving the six original HMS passengers. Native first Collection leaves the actual prior Spice blow untouched. At the original second-turn HMS chooser, declare collect:true on the real printed route ${route!.join(' → ')} over ${collection.map(pile => `${pile.location}=${pile.available}`).join(', ')}. No Spice, route, force or income is installed.`,
|
||||
'Stop before the actual cancellation cost. Native ground denial keeps the current move unused; native HMS denial clears the route and begins the original Storm exactly once. A later Storm reveal/resolution is a legitimate suffix, not state that this source pretends frozen.',
|
||||
] };
|
||||
}
|
||||
export type SemutaIxianMovementFixture = ReturnType<typeof createSemutaIxianMovementFixture>;
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { location } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaFremenGuildMovementFixture as fixture, actSemutaFremenGuildMovement as act,
|
||||
passSemutaFremenGuildMovement as pass, claimSemutaFremenGuildMovement as claim,
|
||||
allowSemutaFremenGuildMovement as allow, replaceSemutaFremenMovement as replace,
|
||||
semutaFremenGuildMovementInventory as inventory, semutaFremenGuildMovementPlayer as player,
|
||||
type SemutaFremenGuildMovementFixture,
|
||||
} from './fixture-semuta-fremen-guild-movement';
|
||||
|
||||
const variants = [
|
||||
{ kind: 'fremenMovement' as const, advanced: false },
|
||||
{ kind: 'fremenMovement' as const, advanced: true },
|
||||
{ kind: 'guildTiming' as const, advanced: true, take: true },
|
||||
{ kind: 'guildTiming' as const, advanced: true, take: false },
|
||||
];
|
||||
const physical = (game: Game) => ({
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
||||
moved: player.moved, shipped: player.shipped, revived: player.revived, ally: player.ally })),
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order,
|
||||
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
});
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaFremenGuildMovementFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
|
||||
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.equal(player(f.afterSetup, f.fremen).forces['sietch_tabr:14'], 10);
|
||||
assert.ok(f.actions.some(step => step.actor === f.fremen && step.action.type === 'fremenSetup'));
|
||||
assert.equal(f.acquisition.price, 1);
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
if (f.kind === 'fremenMovement') {
|
||||
assert.ok(f.shipment && f.pending);
|
||||
assert.equal(player(f.game, f.owner).shipped, true); assert.equal(player(f.game, f.owner).moved, 0);
|
||||
assert.equal(player(f.game, f.owner).forces['the_great_flat:15'], 1);
|
||||
assert.equal(player(f.game, f.owner).spice, player(f.shipment.before, f.owner).spice);
|
||||
assert.equal(f.pending.turn, f.game.turn); assert.equal(f.pending.move, 0);
|
||||
assert.deepEqual(f.pending.order.group, [['sietch_tabr:14', 1]]);
|
||||
assert.equal(f.pending.order.elite, 0);
|
||||
assert.deepEqual(player(f.game, f.owner), player(f.beforeDeclaration, f.owner));
|
||||
} else {
|
||||
assert.equal(f.game.response?.take, f.take);
|
||||
assert.equal(f.game.active, null);
|
||||
assert.equal(f.game.movementRemaining![0] === f.guild, !f.take);
|
||||
assert.equal(f.game.guildTimingGranted, false); assert.equal(f.game.guildTimingLocked, false);
|
||||
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration));
|
||||
if (!f.take) {
|
||||
assert.equal(f.game.storm, 7); assert.equal(f.game.playerPositions![f.guild], 3);
|
||||
assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial').map(step => step.action.amount).sort((a,b) => Number(a)-Number(b)), [0, 6]);
|
||||
assert.equal(f.actions.filter(step => step.action.type === 'seatPosition').length, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
function open(f: SemutaFremenGuildMovementFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.equal(pending.continuation.kind, 'karamaMovementDiscard');
|
||||
assert.equal(pending.batch.cause, `karama:${f.kind}`);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, by: entry.discardedBy, public: entry.publicFace })),
|
||||
[{ id: f.cost, by: f.spender, public: true }]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
|
||||
assert.equal(game.response ?? null, null);
|
||||
if (pending.continuation.kind !== 'karamaMovementDiscard') throw Error('Use the exact native movement-cost continuation');
|
||||
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
|
||||
assert.equal(pending.continuation.use.kind, 'cancel');
|
||||
assert.equal(pending.continuation.use.response.kind, f.kind);
|
||||
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.deepEqual(game.movementRemaining, f.originalRemaining); assert.equal(game.active, f.originalActive);
|
||||
assert.equal(game.guildTimingGranted, f.game.guildTimingGranted);
|
||||
assert.equal(game.guildTimingLocked, f.game.guildTimingLocked);
|
||||
assert.deepEqual(game.pendingFremenMove ?? null, f.pending);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaFremenGuildMovementFixture, claimed: boolean) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.deepEqual(game.movementRemaining, f.originalRemaining);
|
||||
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 (f.kind === 'fremenMovement') {
|
||||
assert.equal(game.pendingFremenMove ?? null, null);
|
||||
assert.equal(game.active, f.fremen); assert.equal(player(game, f.fremen).moved, 0);
|
||||
assert.deepEqual(player(game, f.fremen).fremenMovementBlocked, { turn: game.turn, move: 0 });
|
||||
assert.deepEqual(player(game, f.fremen).forces, player(f.beforeDeclaration, f.fremen).forces);
|
||||
reject(game, f.fremen, f.nativeAction);
|
||||
} else {
|
||||
assert.equal(game.active, f.originalRemaining[0]);
|
||||
assert.equal(game.guildTimingLocked, true); assert.equal(game.guildTimingGranted, false);
|
||||
assert.ok(game.players.every(player => !player.shipped && player.moved === 0));
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
function finishReplacement(game: Game, f: SemutaFremenGuildMovementFixture) {
|
||||
const result = replace(game, f), destination = location(result.replacement.territory as string, result.replacement.sector as number);
|
||||
assert.equal(player(result.afterReplacement, f.fremen).moved, 1);
|
||||
assert.equal(player(result.afterReplacement, f.fremen).forces['sietch_tabr:14'], player(game, f.fremen).forces['sietch_tabr:14'] - 1);
|
||||
assert.equal(player(result.afterReplacement, f.fremen).forces[destination], (player(game, f.fremen).forces[destination] ?? 0) + 1);
|
||||
assert.equal(player(result.afterReplacement, f.fremen).spice, player(game, f.fremen).spice);
|
||||
assert.equal(player(result.afterReplacement, f.fremen).shipped, true);
|
||||
assert.equal(result.end.type, 'endMovement');
|
||||
assert.ok(!result.game.movementRemaining!.includes(f.fremen));
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
}
|
||||
|
||||
for (const variant of variants) for (const claimed of [false, true]) void test(
|
||||
`${variant.advanced ? 'Advanced' : 'Basic'} ${variant.kind}${variant.kind === 'guildTiming' ? variant.take ? ' TAKE' : ' WAIT' : ''} printed cost is ${claimed ? 'claimed' : 'passed'} before native prevention`, () => {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const game = prevented(claimed ? claim(pending, f.holder) : pass(pending), f, claimed);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
reject(game, f.holder, { type: 'semutaPass', event });
|
||||
if (f.kind === 'fremenMovement') finishReplacement(game, f);
|
||||
});
|
||||
|
||||
void test('native allowance controls commit the original range-two group or the exact Guild timing once without card costs', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant); nativeSource(f);
|
||||
const game = allow(structuredClone(f.game));
|
||||
assert.equal(game.pendingTreacheryDiscard ?? 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));
|
||||
if (f.kind === 'fremenMovement') {
|
||||
const to = location(f.nativeAction.territory as string, f.nativeAction.sector as number);
|
||||
assert.equal(game.pendingFremenMove ?? null, null);
|
||||
assert.equal(player(game, f.fremen).moved, 1); assert.equal(player(game, f.fremen).shipped, true);
|
||||
assert.equal(player(game, f.fremen).forces['sietch_tabr:14'], player(f.game, f.fremen).forces['sietch_tabr:14'] - 1);
|
||||
assert.equal(player(game, f.fremen).forces[to], (player(f.game, f.fremen).forces[to] ?? 0) + 1);
|
||||
assert.equal(player(game, f.fremen).fremenMovementBlocked ?? null, null);
|
||||
} else {
|
||||
assert.deepEqual(physical(game), physical(f.game));
|
||||
assert.equal(game.guildTimingGranted, true); assert.equal(game.guildTimingLocked, false);
|
||||
const expected = f.take ? f.originalRemaining : [...f.originalRemaining.filter(id => id !== f.guild), f.guild];
|
||||
assert.deepEqual(game.movementRemaining, expected);
|
||||
assert.equal(game.active, f.take ? f.guild : expected[0]);
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the exact fresh movement cost rejects underlying declarations and stale reactions without repeating physical settlement', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
reject(pending, f.spender, f.action);
|
||||
reject(pending, f.owner, f.nativeAction);
|
||||
reject(pending, f.owner, { type: 'passResponse' });
|
||||
const game = prevented(claim(pending, f.holder), f, true);
|
||||
reject(game, f.holder, { type: 'semutaCommit', event });
|
||||
reject(game, f.holder, { type: 'semutaPass', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer every original Fremen/Guild movement-cost source legally', () => {
|
||||
for (const variant of variants) {
|
||||
const f = fixture(variant), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(pending, 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(pending), f.holder, action);
|
||||
const claimed = action.type === 'semutaCommit';
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selecting = viewGame(game, f.holder); selecting.players.find(player => player.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);
|
||||
prevented(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, type Action, type Game, viewGame } from '../game/engine';
|
||||
import { MOBILE_LOCATION, mobileRouteDistance } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaIxianMovementFixture as fixture, actSemutaIxianMovement as act,
|
||||
advanceSemutaIxianMovement as advance, passSemutaIxianMovement as pass,
|
||||
claimSemutaIxianMovement as claim, allowSemutaIxianMovement as allow,
|
||||
semutaIxianMovementPlayer as player, semutaIxianMovementInventory as inventory,
|
||||
semutaIxianMovementClean as clean, semutaIxianMovementStorm as storm,
|
||||
type SemutaIxianMovementFixture,
|
||||
} from './fixture-semuta-ixian-movement';
|
||||
|
||||
const forcesAndMoney = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice,
|
||||
forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites,
|
||||
moved: p.moved, shipped: p.shipped }));
|
||||
function reject(game: Game, actor: string, proposed: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
|
||||
}
|
||||
function nativeSource(f: SemutaIxianMovementFixture) {
|
||||
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
|
||||
assert.ok(f.parent.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
|
||||
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
|
||||
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
|
||||
assert.equal(f.originalPoint, 'polar_sink:0'); assert.equal(f.game.mobileStronghold?.location, f.originalPoint);
|
||||
assert.equal(player(f.game, f.owner).forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(player(f.game, f.owner).elites!.forces[MOBILE_LOCATION], 3);
|
||||
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
|
||||
if (f.kind === 'ixMovement') {
|
||||
assert.ok(f.shipment && f.replacement && f.destination && f.replacementDestination);
|
||||
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.owner);
|
||||
assert.equal(player(f.shipment.before, f.owner).spice - player(f.shipment.after, f.owner).spice, 2);
|
||||
assert.equal(player(f.game, f.owner).forces[f.sourceLocation], 1);
|
||||
assert.equal(player(f.game, f.owner).elites!.forces[f.sourceLocation], 1);
|
||||
assert.equal(player(f.game, f.owner).moved, 0); assert.equal(player(f.game, f.owner).shipped, true);
|
||||
assert.deepEqual(f.game.pendingIxMove, f.pending);
|
||||
} else {
|
||||
assert.ok(f.route && f.collection.length && f.collectionAmount > 0);
|
||||
assert.ok(mobileRouteDistance(f.route) <= 3); assert.equal(f.route[0], f.originalPoint);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
|
||||
assert.equal(f.beforeDeclaration.decision?.kind, 'mobileStronghold');
|
||||
assert.equal(f.nativeAction.collect, true); assert.deepEqual(f.game.pendingMobileMove, f.pending);
|
||||
assert.equal(f.stormBefore.turnBeginnings, 0, 'Native Storm initialization follows this actual HMS opportunity');
|
||||
for (const pile of f.collection) assert.equal(f.game.spice[pile.location], pile.available);
|
||||
assert.equal(f.actions.filter(step => step.action.type === 'endMovement').length, f.game.players.length);
|
||||
}
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaIxianMovementFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.continuation.kind, 'karamaMovementDiscard');
|
||||
if (pending.continuation.kind !== 'karamaMovementDiscard') throw Error('Expected the exact movement cancellation 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.deepEqual(forcesAndMoney(game), forcesAndMoney(f.game));
|
||||
assert.deepEqual(game.pendingIxMove, f.game.pendingIxMove); assert.deepEqual(game.pendingMobileMove, f.game.pendingMobileMove);
|
||||
assert.deepEqual(game.mobileStronghold, f.game.mobileStronghold); assert.deepEqual(game.spice, f.game.spice);
|
||||
assert.deepEqual(game.movementRemaining, f.game.movementRemaining); assert.equal(game.active, f.game.active);
|
||||
assert.deepEqual(storm(game), f.stormBefore, 'No native Storm initialization/card draw precedes the cost reaction');
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); return game;
|
||||
}
|
||||
function costCompleted(game: Game, f: SemutaIxianMovementFixture, claimed: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
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));
|
||||
}
|
||||
function groundDenied(game: Game, f: SemutaIxianMovementFixture, claimed: boolean) {
|
||||
costCompleted(game, f, claimed); assert.ok(f.replacement && f.replacementDestination && f.destination);
|
||||
assert.equal(game.pendingIxMove, null); assert.equal(game.active, f.owner);
|
||||
assert.deepEqual(game.movementRemaining, f.game.movementRemaining);
|
||||
assert.deepEqual(forcesAndMoney(game), forcesAndMoney(f.game)); assert.deepEqual(storm(game), f.stormBefore);
|
||||
assert.deepEqual(player(game, f.owner).ixMovementBlocked, { turn: f.game.turn, move: player(f.game, f.owner).moved });
|
||||
assert.equal(player(game, f.owner).moved, 0); reject(game, f.owner, f.nativeAction);
|
||||
game = act(game, f.owner, f.replacement); game = advance(game, clean);
|
||||
assert.equal(player(game, f.owner).moved, 1); assert.equal(player(game, f.owner).shipped, true);
|
||||
assert.equal(player(game, f.owner).forces[f.sourceLocation] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).elites!.forces[f.sourceLocation] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).forces[f.replacementDestination], 1);
|
||||
assert.equal(player(game, f.owner).elites!.forces[f.replacementDestination], 1);
|
||||
assert.equal(player(game, f.owner).forces[f.destination] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
|
||||
assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.owner, f.nativeAction); return game;
|
||||
}
|
||||
function initializedOnce(game: Game, f: SemutaIxianMovementFixture) {
|
||||
const after = storm(game);
|
||||
assert.equal(after.turnBeginnings, f.stormBefore.turnBeginnings + 1);
|
||||
assert.equal(after.revealedCards.length, f.stormBefore.revealedCards.length + Number(f.advanced));
|
||||
if (f.advanced) {
|
||||
assert.ok(after.revealedCards.at(-1)! >= 1 && after.revealedCards.at(-1)! <= 6);
|
||||
assert.deepEqual(game.stormDialers, []);
|
||||
} else assert.deepEqual(game.stormDialers, f.game.lastBattle);
|
||||
}
|
||||
function hmsDenied(game: Game, f: SemutaIxianMovementFixture, claimed: boolean) {
|
||||
costCompleted(game, f, claimed); assert.equal(game.pendingMobileMove, null);
|
||||
assert.equal(game.mobileStronghold?.location, f.originalPoint);
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
|
||||
assert.deepEqual(player(game, f.owner).forces, player(f.game, f.owner).forces);
|
||||
for (const pile of f.collection) assert.equal(game.spice[pile.location], pile.available);
|
||||
assert.notEqual(game.decision?.kind, 'mobileStronghold'); initializedOnce(game, f);
|
||||
const continued = advance(game, state => state.phase === 1 && clean(state));
|
||||
assert.equal(continued.turn, 2); assert.equal(continued.mobileStronghold?.location, f.originalPoint);
|
||||
assert.equal(player(continued, f.owner).spice, player(f.game, f.owner).spice);
|
||||
assert.equal(player(continued, f.owner).forces[MOBILE_LOCATION], 6);
|
||||
for (const pile of f.collection) assert.equal(continued.spice[pile.location], pile.available);
|
||||
assert.deepEqual(inventory(continued), f.originalInventory); initializedOnce(continued, f); return continued;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} native Cyborg-range cost Semuta ${claimed ? 'claim' : 'pass'} keeps the paid group and permits its real one-step replacement`, () => {
|
||||
const f = fixture({ advanced }); nativeSource(f); const offered = open(f);
|
||||
groundDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} native HMS collection cost Semuta ${claimed ? 'claim' : 'pass'} leaves the real Spice pile and begins the original Storm once`, () => {
|
||||
const f = fixture({ kind: 'mobileStronghold', advanced }); nativeSource(f); const offered = open(f);
|
||||
hmsDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('original Ixian range and positive HMS collection allowances retain their actual native transfers and Storm suffix', () => {
|
||||
for (const advanced of [false, true]) for (const kind of ['ixMovement', 'mobileStronghold'] as const) {
|
||||
const f = fixture({ advanced, kind }), game = allow(structuredClone(f.game));
|
||||
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.ok(!game.pendingTreacheryDiscard); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
if (kind === 'ixMovement') {
|
||||
assert.ok(f.destination); assert.equal(game.pendingIxMove, null);
|
||||
assert.equal(player(game, f.owner).forces[f.destination], 1);
|
||||
assert.equal(player(game, f.owner).elites!.forces[f.destination], 1);
|
||||
assert.equal(player(game, f.owner).forces[f.sourceLocation] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).moved, 1); assert.equal(player(game, f.owner).ixMovementBlocked, undefined);
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
|
||||
} else {
|
||||
assert.equal(game.pendingMobileMove, null); assert.equal(game.mobileStronghold?.location, f.destination);
|
||||
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice + f.collectionAmount);
|
||||
for (const pile of f.collection) assert.equal(game.spice[pile.location] ?? 0, pile.available - pile.collected);
|
||||
initializedOnce(game, f);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Ixian movement cost reactions reject stale, foreign and underlying actions before transfer or Storm initialization', () => {
|
||||
for (const advanced of [false, true]) for (const kind of ['ixMovement', 'mobileStronghold'] as const) {
|
||||
const f = fixture({ advanced, kind }), 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' });
|
||||
if (kind === 'mobileStronghold') reject(game, f.owner, { type: 'ready' });
|
||||
const done = kind === 'ixMovement' ? groundDenied(pass(game), f, false) : hmsDenied(pass(game), f, false);
|
||||
reject(done, f.holder, { type: 'semutaCommit', event }); reject(done, f.spender, f.action);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer native Basic and Advanced Cyborg or HMS movement costs', () => {
|
||||
for (const advanced of [false, true]) for (const kind of ['ixMovement', 'mobileStronghold'] as const) {
|
||||
const f = fixture({ advanced, kind }), 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);
|
||||
if (kind === 'ixMovement') groundDenied(game, f, claimed); else hmsDenied(game, f, claimed);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user