Allow Tleilaxu revival prevention for Low Homeworlds

This commit is contained in:
Quintine committed 2026-10-25 20:08:43 +11:00
1 parent 5bcf5bab48
commit ce676f0e54
13 files changed
+501 -46

No files matched your search

+4 -3
View File
@@ -4364,9 +4364,10 @@ export function GameTable({
<>
{g.revival.prevented && (
<p className="notice">
Tleilaxu prevented your normal force and leader revivals
this turn. Ghola treachery and the Emperor’s special
Karama remain separate.
Tleilaxu prevented your force and leader revivals for
the rest of this Revival, including Ghola Treachery.
The Emperor’s native special Karama remains immune;
this prevention does not stop Ghola outside Revival.
</p>
)}
<RevivalCommerce game={g} act={act} busy={busy} />
+4
View File
@@ -47,6 +47,10 @@ export function TleilaxuSpecialKarama({
The Emperor’s native special Karama remains protected by the printed
immunity of special Karama powers.
</p>
{game.homeworlds && <p className="fine">
Provisional Homeworld rule: Low keeps its printed free revival rate.
Preventing the return as a whole gives no separate bonus-only return.
</p>}
{offer.pending && (
<p className="fine">Only the original recipient can be selected during this pending revival.</p>
)}
+27
View File
@@ -16,6 +16,33 @@ open mode gates.
## Current checkpoint and work
**Low Homeworld revival prevention — 25 October. Prototyped / Partial.**
Tleilaxu's native special Karama now admits Low Homeworld targets at quiet
Revival windows and the original pending counter. The printed Low free rate
remains intact; preventing the qualifying return produces no independent
bonus-only counter, payment, quota use, income or new High deployment. Completed
returns stay completed. This is an explicit provisional event/advantage
composition, preserving the 24 October phase 4 Ghola prevention and Emperor
special immunity. The actor guide and the formerly stale recipient Ghola notice
match current behavior. Original cost/once-use and existing minimal thresholds
remain; no strategy refinement.
[Source, policy and limits](HOMEWORLD_BENEFITS_RULES.md#provisional-low-homeworld-revival-prevention--25-october-2026).
Disjoint native-fixture work, bounded source review and root integration pass
**10 files, 87/87**, types/lint/build and four actual component-callback/engine/
GameTable SSR journeys. Genuine 75-action public Advanced Fremen/Tleilaxu +
Homeworlds sources earn original 47 cards, native 10-counter/three-Fedaykin setup,
real 6+6 first Storm, native 10-normal shipment into Storm 13, original half-five
loss and actual second Revival at Low 0/Tanks 5. Quiet and pending prevention
retain stock/free 4; decline really returns 4 and awards native income 1. Original
Ghola is refused in the affected phase, then real readiness reaches phase 5 and
returns 5 without ordinary quota. No played state is installed. All four
existing reactive policies act legally, including allowance at lower existing
thresholds. The selected union includes one existing in-memory Ghola lifecycle
file; no live saved-room/D1/server/reset/restart/deployment operation or broad
assurance campaign occurred. Full module/mode, browser/mobile/deployed and final
assurance remain open; SSR/callbacks are not hydration acceptance.
**High Richese mixed shipment — 25 October. Prototyped / Partial.**
Native High Richese now selects paid physical reserve forces beside one
concealed No-Field. The marker keeps its own token/event and amount 1; the
+48
View File
@@ -2,6 +2,54 @@
Source audit, 9 September 2026. This separates publisher wording, rule composition, and remaining decisions. It does not enable Homeworld games or replace the outstanding Ambassador/Duke revival rulings.
## Provisional Low Homeworld revival prevention — 25 October 2026
**Prototyped / Partial.** The earlier target guard against Tleilaxu special
Karama is removed. Native Advanced Tleilaxu may choose a Low Homeworld faction
at the existing quiet window or original pending revival decision. Eligibility,
physical Karama disposal, once-per-game use and ordinary enclosing priorities
remain in the [native special-power contract](TLEILAXU_SPECIAL_KARAMA.md).
The user-authorized revised PDF's physical page 38 H prevents force/leader
revival for a chosen player, with no Homeworld exception. Publisher E3 printed
page 10 protects Homeworld advantages and penalties from Karama. Under the
14 October choose-recommended instruction, the explicit provisional composition
is **event prevention**: Low keeps its printed extra free-revival rate, but the
ordinary return is prevented as a whole, including its conditional extra
counter. No separate bonus-only return is created. This is not a located
combined publisher FAQ or a change to the printed High/Low face.
No physical force, leader, payment or quota changes because of the stopped
return. No actual free return exists to award Tleilaxu/Axlotl income, and no
paid Cyborg or newly returned group exists to trigger Ix/Southern/Tleilax High
follow-ups. Prior completed returns and their effects remain completed. There
is no new response aimed only at the Homeworld bonus, and no retroactive
interruption of a committed deployment.
The 24 October prevention cutover is retained: Ghola is prevented during the
remaining affected Revival, then becomes available outside that phase under
its own ordinary card rules. The Emperor's native special Karama keeps its
printed immunity. This does not restore the old provisional Ghola bypass or
conflate La La La with a Karama effect. Targets retain readable persistent
guidance; existing minimal policies consume the shared actual native offer,
without strategic tuning.
Bounded evidence: [native source](../tests/fixture-homeworld-revival-prevention.ts),
[native transitions](../tests/homeworld-revival-prevention-runtime.test.ts) and
[controlled regression](../tests/homeworld-revival-benefits-engine.test.ts).
Frozen 10-file 87/87, types/lint/build and four actual-control/engine/GameTable SSR
journeys pass. Genuine 75-action original setup, typed free shipment and Storm
losses earn Low 0/Tanks 5 before the real second Revival. Quiet/pending prevention
retains rate 4 and all physical/usage state; decline returns 4 and awards original
income 1. Held Ghola rejects during affected phase 4, then returns 5 after actual phase
progression. Four existing policies act legally without tuning. One existing
in-memory Ghola lifecycle file is included in the selected union; this is not
final assurance. Private proof:
`/home/quintine/.local/share/dune/checkpoints/20261025-homeworld-revival-prevention/`.
The earlier unresolved-overlap paragraph below remains the historical source
audit; this dated policy supersedes its execution guard. Full module/High-
effect combinations and final assurance remain open.
## Provisional Ix High paid revival bonus — 25 October 2026
**Prototyped / Partial.** The printed Ix High card now connects an ordinary
+45
View File
@@ -1,5 +1,50 @@
# Dune implementation status
## 25 October 2026 — Low Homeworlds retain their rate under Tleilaxu revival prevention
Removed the old target guard from the Homeworld helper and native Tleilaxu
quote. Quiet and original pending selections now accept Low factions using
ordinary physical card cost and once-per-game scope. The explicit provisional
composition prevents the entire qualifying event while leaving its printed
rate unchanged; no independent bonus-only return or new High grant is created.
Existing priorities, committed returns, phase 4 Ghola inclusion and Emperor
special immunity remain. Actor guidance states the Homeworld composition, and
recipient guidance now correctly describes the existing 24 October Ghola rule.
The legacy projected block field stays null; no saved state/schema is changed.
Existing AI thresholds consume the real offer without tuning.
Disjoint native-fixture work, bounded source review and root integration pass
**10 files, 87/87**, types/lint/build and four actual component-callback/engine/
GameTable SSR journeys. A controlled prior army/card fixture now proves all
four existing profiles can prevent the original low free return while keeping
its rate 4, actual counters, unused quotas and income unchanged. Native source
requires 75 actions: actual public Advanced Fremen/Tleilaxu + Homeworlds/E1,
original 47 cards, original Fremen setup 10 with three Fedaykin at sheltered
Tabr 14, real first-Storm dials 6+6→13 and free native 10-normal shipment into
Hagga 13. Original Fremen protection loses exactly five normals, leaving Low 0,
five actual Tanks, five survivors at Hagga and all stars safe at Tabr. Original
phases reach the real next Revival; no played phase, army, wallet, card or
receipt is installed.
Actual controls perform quiet and original pending prevention without returns,
cost to recipient, quota use, income or deployment. Native decline returns 4 free
and awards Tleilaxu its original separate bank 1. The actual held Ghola remains
unspent on phase 4 refusal, then original readiness reaches phase 5 and returns all 5
without ordinary quota. Easy/Medium retain their original choice to allow the
five-Tank counter; Hard/Brutal may prevent, and all four actions complete legally.
The selected union also included the existing in-memory Ghola lifecycle file;
this is bounded related evidence, not final recovery/custody certification.
Private source-bound report/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-homeworld-revival-prevention/`.
`frozen-integrated/report.json` passes unchanged-source tree
`96cdab49e8977847c5aa91be6fed5b73bb7756416dc8476a2bb3bb45c097b34b`,
2803 files, base `5bcf5bab488a1ba784e18d3be8c665abcfe4e292`.
[Source and current interpretation](HOMEWORLD_BENEFITS_RULES.md#provisional-low-homeworld-revival-prevention--25-october-2026).
Broader High-effect/module composition, full modes and final assurance remain
unfinished. SSR/callbacks do not certify browser hydration/mobile/deployed
acceptance. No live saved-room/D1/server/reset/restart/deployment operation.
## 25 October 2026 — High Richese ships paid reserves beside a concealed marker
Native High Richese uses the original single-marker action plus positive
+2 -1
View File
@@ -242,7 +242,7 @@ Treachery returns during the remaining affected Revival and expires outside that
phase. Physical39’s Ghola erratum supplies free immediate returns and a separate
normal allowance, without naming prevention immunity. This is an explicit textual
composition, not a newly retrieved combined publisher FAQ. It supersedes the old
provisional Ghola bypass. The unresolved low-Homeworld overlap guard remains.
provisional Ghola bypass. The 25 October Low Homeworld event-prevention composition now supersedes the earlier target guard; the printed rate remains intact.
[Source, connected controls and limits](TLEILAXU_SPECIAL_KARAMA.md).
## Truthtrance unrevealed Face Dancer fact — 24 October 2026
@@ -1253,6 +1253,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
| Ix High paid-Cyborg bonus | Provisional composition: observe High before the accepted ordinary group; add up to two actual remaining Suboid Tanks per paid Cyborg outside normal allowance/free quota, without Recruits multiplication or future debt. Preserve the paid invoice and original cancellation/stop outcomes. Prior La La La suppresses new grants; a delivered bonus is one existing free-return income event, not a second award. The card establishes the grant; quota, shortage and event classification are application policy, not a located Ix FAQ. | [Current contract and source boundaries](HOMEWORLD_BENEFITS_RULES.md#provisional-ix-high-paid-revival-bonus--25-october-2026) |
| Low Homeworld / Tleilaxu revival prevention | Provisionally prevent the qualifying return as a whole while preserving the printed Low free rate. No independent bonus-only return, cost, quota use, revival income or new High follow-up arises from a stopped event. Quiet and pending native special-Karama windows share the same eligibility. Retain the 24 October Ghola phase4 cutover and Emperor special immunity. This event/advantage composition is not a located combined FAQ. | [Current source and policy](HOMEWORLD_BENEFITS_RULES.md#provisional-low-homeworld-revival-prevention--25-october-2026) |
| High Richese reserve accompaniment | The printed High card permits reserve forces beside a No-Field. Provisionally bill one marker plus the actual paid companions as one rounded ordinary group, observe High before that group and permit its completed shipment to cross Low. Hidden value stays in reserves; optional Smuggler reserves an additional real free counter. Original No-Field/Guild decisions and invoices remain, and the two-token Cunning route retains its separate contract. Combined tariff and grouped threshold timing are composition policy, not a printed worked example. | [Current prototype and boundaries](HOMEWORLD_RULES.md#high-richese-mixed-shipment-prototype--25-october-2026) |
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
| Duke Vidal revival custody | The user selected living set-aside custody after Ecaz revives Duke; a separate Ambassador/Nexus acquisition grants battle control. Only Ecaz may revive him, including Ghola. Native repeated returns now use the separately labelled provisional identity-cohort policy, not a new publisher example or user number. Captured scope and broad combinations remain separately bounded. | [Selected custody](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026), [current cohort policy](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026) |
+3 -2
View File
@@ -21,8 +21,9 @@ composition**: prevention includes Ghola Treachery during the remainder of the
affected Revival Phase and expires outside that phase. Ghola during Shipment
and Movement or Battle stays legal. This replaces the earlier provisional Ghola
bypass; it is not a direct combined GF9 FAQ ruling. No already completed return,
payment, income or allowance is undone. Low-population Homeworld overlap remains
guarded under its existing unresolved contract. Wider combinations and complete
payment, income or allowance is undone. Low-population Homeworlds now follow the
[25 October provisional event-prevention composition](HOMEWORLD_BENEFITS_RULES.md#provisional-low-homeworld-revival-prevention--25-october-2026):
preserve the printed Low rate and prevent the whole qualifying return. Wider combinations and complete
rules/mode acceptance remain unfinished.
## Connected implementation
+1 -7
View File
@@ -154,7 +154,6 @@ import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuild
type GuildAmbassadorMarker, type GuildAmbassadorMarkerQuote } from './guild-ambassador-no-field';
import {
homeworldLowBonus,
homeworldRevivalKaramaBlock,
homeworldSardaukarFreeSupport,
homeworldSardaukarGholaBlock,
snapshotHomeworldRevival,
@@ -32160,8 +32159,6 @@ function tleilaxuSpecialKaramaQuote(g: Game, p: Player) {
(g.pendingRevival && !ownStop) || g.phaseOpening || g.pendingKarama ||
g.choamMarket || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingKull
? 'Resolve the current response, decision or card effect before declaring revival prevention.' : null,
targetBlocks: Object.fromEntries(g.players.map(player =>
[player.id, homeworldRevivalKaramaBlock(g, player.id)])),
});
}
function tleilaxuSpecialKaramaView(g: Game, p: Player) {
@@ -38367,10 +38364,7 @@ export function viewGame(state: Game, id: string) {
),
revival: {
pending: !!g.pendingRevival,
specialKaramaBlock:
g.decision?.kind === 'revivalStop'
? homeworldRevivalKaramaBlock(g, g.decision.recipient)
: null,
specialKaramaBlock: null,
leaders: leaderRevivals.leaders,
kwisatz: leaderRevivals.kwisatz,
...(leaderRevivals.auditorCyclePolicy ? { auditorCyclePolicy: leaderRevivals.auditorCyclePolicy } : {}),
-11
View File
@@ -37,17 +37,6 @@ export function homeworldLowBonus(
: 0;
}
/** Keep the unresolved special-Karama overlap consistent in validation and
* the public controls. This is a release boundary, not a rules exception. */
export function homeworldRevivalKaramaBlock(
context: HomeworldBenefitContext,
target: string,
): string | null {
return homeworldLowBonus(context, target)
? 'Special Karama against a low-population Homeworld awaits the revival-overlap ruling.'
: null;
}
/** Salusa's threshold counts Sardaukar at Salusa, not all Imperial reserves.
* Its high advantage affects the faction's Sardaukar wherever they fight. */
export function homeworldSardaukarFreeSupport(
+2 -2
View File
@@ -3824,13 +3824,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'Resume any interrupted spice draw, fresh-blow window or worm response after the summoned worm resolves. Its Nexus occurs at the end of the blow, followed by eligible Fremen rides. During an open Nexus, the new ride joins the existing queue. Destroyed fresh spice cannot later be doubled by Harvester.',
'During a quiet Revival boundary, Tleilaxu may spend its once-per-game special Karama to select any one player, including itself, an ally or a ready faction. It also retains the original decision before another faction’s normal revival completes, independently of card holdings. Declining that decision continues the original return and preserves later quiet use. A pending decision can select only its original recipient.',
'Prevention stops further force and leader returns in this Revival, including negotiated leaders, KH and Emperor-funded extra forces. It does not undo completed returns, payments or used allowances. A stopped pending request spends no spice or revival quota. Hidden leader identities and negotiated prices remain outside the public decision. Emperor’s special Karama is immune.',
'Provisional scope includes Ghola Treachery returns during the affected Revival and ends when that phase ends; Ghola is available afterward. This composes the revised prevention wording with Ghola’s free-return rule, rather than quoting a combined publisher ruling. Low-population Homeworld overlap retains its existing guard. Full expansion and combination acceptance remains open.',
'Provisional scope includes Ghola Treachery returns during the affected Revival and ends when that phase ends; Ghola is available afterward. This composes the revised prevention wording with Ghola’s free-return rule, rather than quoting a combined publisher ruling. Low Homeworlds provisionally retain their printed free rate while the whole qualifying return is prevented; no independent bonus-only return is created. Full expansion and combination acceptance remains open.',
'CHOAM cash-in, Richese acquisition and Ixian HMS relocation also have connected special-use paths. Ecaz’s supplied p30 unarmed disc difference and Moritani’s supplied p35 post-loss winner-card disposal now have owned controls and native cost/once-use, with explicit provisional numeric/disposal interpretations. Advanced preview remains six-classic only; broader timing and combinations are not certified.',
'Richese’s special purchase is available in development tables: spend a Karama and three spice to choose privately from the cache, with separate Emperor income and explicit full-hand/final-cache guards. See its acquisition guide for current controls and limits.',
'See Ecaz special Karama and Moritani special Karama for their source-bound first versions. Their declarations do not create a normal faction-cancellation window; existing Kull/BG handling remains in the shared special-intent pipeline.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Tleilaxu proactive Revival is prototyped: quiet any-player selection and original target-bound interruption share current physical-cost validation; completed returns stay intact. Ghola inclusion and phase expiry are provisional; Emperor special immunity and the low-Homeworld overlap guard remain.', evidence: ['game/tleilaxu-special-karama.ts', 'game/engine.ts', 'game/card-availability.ts', 'game/revival.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Tleilaxu proactive Revival is prototyped: quiet any-player selection and original target-bound interruption share current physical-cost validation; completed returns stay intact. Ghola inclusion and phase expiry are provisional; Emperor special immunity remains. Low Homeworld target eligibility now follows explicit event prevention while leaving its printed rate intact.', evidence: ['game/tleilaxu-special-karama.ts', 'game/engine.ts', 'game/card-availability.ts', 'game/revival.ts', 'tests/fixture-homeworld-revival-prevention.ts', 'tests/homeworld-revival-prevention-runtime.test.ts', 'docs/HOMEWORLD_BENEFITS_RULES.md'] },
{ area: 'Player controls', status: 'Partial', detail: 'Native Advanced owner has persistent card/target controls before a request and in the pending decision, including self, ally, ready and prior-return choices. Enclosing priorities and target restrictions retain readable reasons.', evidence: ['components/tleilaxu-special-karama.tsx', 'components/game-table.tsx', 'tests/tleilaxu-special-karama-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four existing profiles reuse public-Tanks thresholds through the shared current quote; no strategic refinement or difficulty calibration.', evidence: ['game/bots.ts', 'tests/tleilaxu-special-karama-window.test.ts', 'tests/ghola-bots.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Proactive source cutover, provisional Ghola/phase scope, printed Special Karama immunity and remaining Homeworld/combination limits are explicit.', evidence: ['docs/TLEILAXU_SPECIAL_KARAMA.md', 'docs/RULE_DECISIONS.md'] },
@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { distance, location, territory } from '../game/board';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import { nativeShipmentSources } from '../game/homeworld-options';
import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type HomeworldRevivalPreventionStep = TruthMovementStep;
export type HomeworldRevivalPreventionTransition = { before: Game; steps: HomeworldRevivalPreventionStep[]; after: Game };
export const actHomeworldRevivalPrevention = actTruthMovement;
export const homeworldRevivalPreventionPlayer = (game: Game, actor = 'fremen') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const homeworldRevivalPreventionClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
export function homeworldRevivalPreventionInventory(game: Game) {
homeworldGameIntegrity(game);
const sum = (counts: Record<string, number>) => Object.values(counts).reduce((total, count) => total + count, 0);
for (const player of game.players) {
const visitors = Object.values(game.homeworlds!.custody!.visitors)
.map(groups => groups[player.id] ?? { normal: 0, elite: 0 });
assert.equal(player.reserves + player.tanks + sum(player.forces) +
visitors.reduce((total, group) => total + group.normal + group.elite, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
visitors.reduce((total, group) => total + group.elite, 0), 3);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original Treachery Card retains one physical slot');
return ids;
}
export const homeworldRevivalPreventionPopulation = (game: Game) => {
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
.find(home => home.native === 'fremen' && home.card === 'southern_hemisphere'); assert.ok(home); return home;
};
/** Original setup and response controls only. The native revivalStop remains
* owned by Tleilaxu; this helper never chooses prevention or its decline. */
export function nextHomeworldRevivalPrevention(game: Game): HomeworldRevivalPreventionStep | null {
if (game.truthtrance) return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'revivalStop') return null;
const fremen = homeworldRevivalPreventionPlayer(game, 'fremen');
if (game.status === 'setup' && game.setupStage === 'forces' && fremen.reserves === 20)
return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 },
elitePlacements: { 'sietch_tabr:14': 3 } } };
if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: 6 } };
}
if (game.decision?.kind === 'wormRide' || game.decision?.kind === 'wormPlacement')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
}
return nextRicheseStrongholdsNativeStep(game);
}
export const nextHomeworldRevivalPreventionNativeStep = nextHomeworldRevivalPrevention;
export function advanceHomeworldRevivalPrevention(game: Game, until: (state: Game) => boolean,
actions: HomeworldRevivalPreventionStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine Fremen chronology ended before its requested control');
const next = nextHomeworldRevivalPrevention(game);
assert.ok(next, `Choose the original revivalStop or owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actHomeworldRevivalPrevention(game, next.actor, next.action, actions);
}
throw Error('The native LOW-Homeworld revival source did not reach its finite boundary');
}
export function passHomeworldRevivalPrevention(game: Game, actions: HomeworldRevivalPreventionStep[] = []): Game {
for (let count = 0; game.response && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actHomeworldRevivalPrevention(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response); return game;
}
export function finishHomeworldRevivalPrevention(game: Game, actions: HomeworldRevivalPreventionStep[] = []): Game {
return advanceHomeworldRevivalPrevention(game, state => state.phase === 5 && homeworldRevivalPreventionClean(state), actions);
}
/** Actual public Advanced Fremen/Tleilaxu + Homeworlds/E1. Ship every ordinary
* native Fremen reserve into the actual first Storm and allow native half-loss.
* All three original Fedaykin remain safely in Tabr; no played force, Tank,
* hand, wallet, phase or revival receipt is assigned. */
export function createHomeworldRevivalPreventionFixture(options: { ghola?: boolean } = {}) {
const owner = 'tleilaxu', target = 'fremen', withGhola = options.ghola ?? true;
let game = createGame('HOMEWORLDREVIVALPREVENTION', newPlayer(target, 'Fremen', 'fremen'), true, ['ix']);
joinGame(game, newPlayer(owner, 'Tleilaxu', 'tleilaxu'));
const initial = structuredClone(game), actions: HomeworldRevivalPreventionStep[] = [];
const act = (actor: string, action: Action) => { game = actHomeworldRevivalPrevention(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceHomeworldRevivalPrevention(game, until, actions); };
const transition = (run: () => void): HomeworldRevivalPreventionTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, ['ix']);
assert.ok(game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalInventory = [...game.deck].map(card => card.id).sort(); assert.equal(originalInventory.length, 47);
const karama = game.deck.find(card => card.effect === 'karama');
const targetCard = game.deck.find(card => withGhola ? card.effect === 'ghola' : card.kind === 'projectile');
assert.ok(karama && targetCard);
const deal = [targetCard.id, karama.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), fremen = homeworldRevivalPreventionPlayer(game, target);
assert.equal(game.storm, 1);
assert.deepEqual(fremen.forces, { 'sietch_tabr:14': 10 });
assert.deepEqual(fremen.elites?.forces, { 'sietch_tabr:14': 3 });
assert.equal(fremen.reserves, 10); assert.equal(fremen.elites?.reserves, 0);
assert.deepEqual(fremen.hand.map(card => card.id), [targetCard.id]);
assert.deepEqual(homeworldRevivalPreventionPlayer(game, owner).hand.map(card => card.id), [karama.id]);
assert.equal(homeworldRevivalPreventionPlayer(game, owner).faceDancers?.length, 3);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
!['hagga_basin', 'sietch_tabr'].includes(card.territory));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1 && state.storm === 13); });
assert.deepEqual(firstStorm.steps.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [6, 6]);
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && homeworldRevivalPreventionClean(state));
const beforeShipment = structuredClone(game), destination = 'hagga_basin', sector = 13, key = 'hagga_basin:13';
assert.ok(territory('the_great_flat').sectors.some(from => distance(location('the_great_flat', from), key) <= 2));
assert.equal(game.storm, sector);
const homeworldSources = nativeShipmentSources(viewGame(game, target), 10, 0); assert.ok(homeworldSources);
const shipmentStep: HomeworldRevivalPreventionStep = { actor: target, action: {
type: 'ship', amount: 10, elite: 0, territory: destination, sector, allyPayment: 0, homeworldSources,
} };
act(shipmentStep.actor, shipmentStep.action);
const stormProtection = structuredClone(game);
assert.equal(game.response?.kind, 'stormProtection'); assert.equal(game.response?.resume, 'shipment');
assert.equal(homeworldRevivalPreventionPlayer(game, target).reserves, 0);
const halfLoss = transition(() => { reach(homeworldRevivalPreventionClean); });
const afterLoss = structuredClone(game), lost = homeworldRevivalPreventionPlayer(game, target);
assert.equal(lost.spice, homeworldRevivalPreventionPlayer(beforeShipment, target).spice);
assert.equal(lost.tanks, 5); assert.equal(lost.elites?.tanks, 0);
assert.equal(lost.reserves, 0); assert.equal(homeworldRevivalPreventionPopulation(game).side, 'low');
assert.deepEqual(lost.forces, { 'sietch_tabr:14': 10, [key]: 5 });
assert.deepEqual(lost.elites?.forces, { 'sietch_tabr:14': 3 });
reach(state => state.turn === 2 && state.phase === 4 && homeworldRevivalPreventionClean(state));
assert.equal(homeworldRevivalPreventionPlayer(game, target).tanks, 5);
assert.equal(homeworldRevivalPreventionPlayer(game, target).reserves, 0);
assert.equal(homeworldRevivalPreventionPlayer(game, target).revived, 0);
assert.equal(homeworldRevivalPreventionPopulation(game).population, 0);
assert.ok(homeworldRevivalPreventionPlayer(game, owner).hand.some(card => card.id === karama.id));
assert.deepEqual(homeworldRevivalPreventionInventory(game), originalInventory);
const special: Action = { type: 'card', card: karama.id, mode: 'special', target };
const request: Action = { type: 'revive', amount: 4, elite: 0 };
const ghola: Action | null = withGhola ? { type: 'card', card: targetCard.id, amount: 5, elite: 0 } : null;
return { game, source: structuredClone(game), initial, enableHomeworlds, lobby, setup, beforeStartingDeal, afterSetup,
firstStorm, beforeShipment, shipmentStep, stormProtection, halfLoss, afterLoss,
owner, tleilaxu: owner, target, fremen: target, karama: karama.id, card: karama.id, special, action: special,
request, ordinaryRevival: request, ghola: withGhola ? targetCard.id : null, gholaAction: ghola,
destination, territory: destination, sector, location: key, lowPopulation: 0, tanks: 5,
originalInventory, originalCards: originalInventory, originalSpice, deal, dealOrder, spiceOrder,
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public Advanced Fremen/Tleilaxu ready/startPreview enables only Homeworlds and the original Ix47 deck. Native Traitor choices, actual three-card Face Dancer deal and Fremen setup place ten original counters including all three Fedaykin in Sietch Tabr14, leaving ten ordinary native reserves at Southern Hemisphere.',
`Only native scalar lotteries, two unopened Treachery positions and four unopened ordinary Spice positions are selected. Tleilaxu receives its original printed Karama; Fremen receives ${withGhola ? 'the original Ghola' : 'an ordinary projectile'}. No held cards, forecast, phase, wallet, forces or casualty receipts are installed.`,
'Two actual first-Storm secret dials of six move the real Storm from1 to13. Hagga Basin13 is within two territories of Great Flat15. After actual Spice/Charity/auction/Revival, Fremen really ships all ten ordinary native reserves there free using explicit Homeworld sources. Native stormProtection is allowed; its exact half-loss sends five ordinary counters to Tanks. The five survivors remain at13 and the three Fedaykin remain safely atTabr14.',
'Real remaining Movement, peaceful Battle, Collection, Mentat, second Storm/Spice/Charity/Bidding reach clean turn-two Revival. Southern Hemisphere stays LOW0 with five actual ordinary Tanks. Original first/next Storm and Homeworld history are retained. The source stops before any four-free request or special Karama, with the original printed low rate and all quotas unused.',
'The caller owns proactive special prevention, the original reactive revivalStop, its explicit decline, all native income responses and any original Ghola action. The source introduces no low-bonus-only return or phase bypass.',
],
};
}
export type HomeworldRevivalPreventionFixture = ReturnType<typeof createHomeworldRevivalPreventionFixture>;
export function requestHomeworldRevivalPrevention(source: Game, fixture: HomeworldRevivalPreventionFixture,
actions: HomeworldRevivalPreventionStep[] = []) {
const before = structuredClone(source), start = actions.length;
const game = actHomeworldRevivalPrevention(source, fixture.target, fixture.request, actions);
assert.equal(game.decision?.kind, 'revivalStop');
return { game, before, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
}
export function allowHomeworldRevivalPrevention(source: Game, fixture: HomeworldRevivalPreventionFixture,
actions: HomeworldRevivalPreventionStep[] = []) {
const before = structuredClone(source), start = actions.length;
assert.equal(source.decision?.kind, 'revivalStop'); assert.equal(source.decision?.player, fixture.owner);
let game = actHomeworldRevivalPrevention(source, fixture.owner, { type: 'decision', decline: true }, actions);
game = advanceHomeworldRevivalPrevention(game, homeworldRevivalPreventionClean, actions);
return { game, before, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
}
+28 -20
View File
@@ -360,37 +360,45 @@ void test('actual high Tleilax phase opening permits one ordinary free-income pa
inventory(g);
});
void test('every AI respects the projected low-Homeworld special-Karama boundary and can allow the saved revival', () => {
void test('controlled Low Homeworld keeps its rate while human and minimal policies can prevent the whole return', () => {
let g = setup(['fremen', 'tleilaxu', 'atreides'], true);
position(g, 'fremen', 2, 12);
position(g, 'tleilaxu', 12, 0);
// Advanced identity is established before genuine setup history is signed;
// public Advanced/Homeworld starts remain independently gated.
// Controlled later army/card allocation; the separate native fixture earns
// its actual Low population and Tanks through setup/shipment/Storm losses.
g = enterRevival(g);
const card = hold(g, 'tleilaxu', 'karama');
g = applyAction(g, 'fremen', { type: 'revive', amount: 1, elite: 0 });
assert.equal(g.decision?.kind, 'revivalStop');
const before = reload(g);
assert.match(
viewGame(g, 'tleilaxu').revival.specialKaramaBlock!,
/revival-overlap ruling/,
);
rejected(g, 'tleilaxu', { type: 'card', card, mode: 'special' });
const offer = viewGame(g, 'tleilaxu').tleilaxuSpecialKarama!;
assert.equal(offer.blocked, null);
assert.deepEqual(offer.targets, [{ player: 'fremen', blocked: null }]);
assert.equal(viewGame(g, 'tleilaxu').revival.specialKaramaBlock, null);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(g), 'tleilaxu');
view.players.find((player) => player.id === 'tleilaxu')!.bot = difficulty;
const candidates = botActions(view);
assert.deepEqual(candidates, [{ type: 'decision', decline: true }]);
const allowed = applyAction(reload(g), 'tleilaxu', candidates[0]);
assert.equal(p(allowed, 'fremen').reserves, 3);
assert.equal(p(allowed, 'fremen').tanks, 11);
assert.equal(p(allowed, 'tleilaxu').specialKaramaUsed ?? false, false);
assert.ok(p(allowed, 'tleilaxu').hand.some((held) => held.id === card));
assert.equal(
viewGame(allowed, 'tleilaxu').revival.specialKaramaBlock,
null,
);
inventory(allowed);
const action = botActions(view)[0];
assert.deepEqual(action, { type: 'card', card, mode: 'special', target: 'fremen' });
const stopped = applyAction(reload(g), 'tleilaxu', action);
assert.equal(stopped.pendingRevival ?? null, null);
assert.equal(p(stopped, 'fremen').reserves, 2);
assert.equal(p(stopped, 'fremen').tanks, 12);
assert.equal(p(stopped, 'fremen').revived, 0);
assert.equal(p(stopped, 'fremen').freeForcesRevived, 0);
assert.equal(p(stopped, 'tleilaxu').specialKaramaUsed, true);
assert.equal(p(stopped, 'tleilaxu').spice, p(before, 'tleilaxu').spice);
assert.ok(!p(stopped, 'tleilaxu').hand.some(held => held.id === card));
assert.equal(stopped.discard.filter(held => held.id === card).length, 1);
const recipient = viewGame(stopped, 'fremen');
assert.equal(recipient.revival.prevented, true);
assert.equal(recipient.revival.freeRemaining, 4);
rejected(stopped, 'fremen', { type: 'revive', amount: 1, elite: 0 });
inventory(stopped);
}
const allowed = applyAction(reload(g), 'tleilaxu', { type: 'decision', decline: true });
assert.equal(p(allowed, 'fremen').reserves, 3);
assert.equal(p(allowed, 'fremen').tanks, 11);
assert.ok(p(allowed, 'tleilaxu').hand.some(held => held.id === card));
assert.deepEqual(g, before);
});
@@ -0,0 +1,141 @@
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 { freeRevivalRate } from '../game/revival';
import {
createHomeworldRevivalPreventionFixture as fixture,
actHomeworldRevivalPrevention as act,
requestHomeworldRevivalPrevention as request,
allowHomeworldRevivalPrevention as allow,
advanceHomeworldRevivalPrevention as advance,
finishHomeworldRevivalPrevention as finish,
homeworldRevivalPreventionPlayer as player,
homeworldRevivalPreventionClean as clean,
homeworldRevivalPreventionInventory as inventory,
homeworldRevivalPreventionPopulation as population,
} from './fixture-homeworld-revival-prevention';
function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
const before = structuredClone(game);
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('native LOW Fremen stays an eligible quiet special-Karama target and retains the printed four-free rate', () => {
const f = fixture(), before = structuredClone(player(f.game, f.target)), owner = structuredClone(player(f.game, f.owner));
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
assert.equal(population(f.game).population, 0); assert.equal(population(f.game).side, 'low');
assert.equal(before.tanks, 5); assert.equal(before.elites?.tanks, 0);
assert.deepEqual(before.elites?.forces, { 'sietch_tabr:14': 3 });
assert.equal(f.stormProtection.response?.kind, 'stormProtection');
assert.equal(f.stormProtection.response?.amount, 10);
assert.equal(player(f.afterLoss, f.target).forces[f.location], 5);
const view = viewGame(f.game, f.owner), offer = view.tleilaxuSpecialKarama;
assert.equal(offer?.blocked, null); assert.equal(offer?.pending, false);
assert.deepEqual(offer?.cards, [f.karama]);
assert.equal(offer?.targets.find(choice => choice.player === f.target)?.blocked, null);
assert.equal(viewGame(f.game, f.target).revival.specialKaramaBlock, null);
assert.equal(freeRevivalRate(f.game, before), 4);
const stopped = act(f.game, f.owner, f.special), after = player(stopped, f.target);
assert.deepEqual(after, before, 'Preventing the event returns neither the ordinary three nor the independent low increment');
assert.equal(player(stopped, f.owner).spice, owner.spice);
assert.equal(player(stopped, f.owner).specialKaramaUsed, true);
assert.equal(player(stopped, f.owner).hand.some(card => card.id === f.karama), false);
assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(stopped.revivalPrevention, { player: f.target, turn: stopped.turn });
assert.equal(stopped.pendingRevival ?? null, null);
assert.deepEqual(stopped.homeworldRevivalReturn, f.game.homeworldRevivalReturn);
assert.notEqual(stopped.revivalFreeIncome?.[f.target], stopped.turn);
assert.equal(freeRevivalRate(stopped, after), 4);
assert.equal(viewGame(stopped, f.target).revival.homeworldBonus, 1);
assert.equal(viewGame(stopped, f.target).revival.prevented, true);
reject(stopped, f.target, f.request, /prevented/);
reject(stopped, f.owner, f.special);
assert.deepEqual(inventory(stopped), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
});
void test('the original reactive revivalStop may prevent all four requested free counters before any return, quota or income', () => {
const f = fixture(), before = structuredClone(player(f.game, f.target)), ownerSpice = player(f.game, f.owner).spice;
const pending = request(f.game, f).game;
assert.equal(pending.decision?.kind, 'revivalStop'); assert.equal(pending.decision?.player, f.owner);
assert.equal(pending.pendingRevival?.player, f.target);
assert.equal(pending.pendingRevival?.amount, 4); assert.equal(pending.pendingRevival?.free, 4);
assert.equal(pending.pendingRevival?.cost, 0);
assert.deepEqual(player(pending, f.target), before);
const offer = viewGame(pending, f.owner).tleilaxuSpecialKarama;
assert.equal(offer?.blocked, null); assert.equal(offer?.pending, true);
assert.deepEqual(offer?.targets, [{ player: f.target, blocked: null }]);
const stopped = act(pending, f.owner, f.special);
assert.equal(stopped.pendingRevival ?? null, null); assert.equal(stopped.decision, null);
assert.deepEqual(player(stopped, f.target), before);
assert.equal(player(stopped, f.owner).spice, ownerSpice);
assert.equal(player(stopped, f.owner).specialKaramaUsed, true);
assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(stopped.homeworldRevivalReturn, f.game.homeworldRevivalReturn);
assert.notEqual(stopped.revivalFreeIncome?.[f.target], stopped.turn);
assert.equal(freeRevivalRate(stopped, player(stopped, f.target)), 4);
reject(stopped, f.target, f.request, /prevented/);
assert.deepEqual(inventory(stopped), f.originalInventory);
});
void test('declining that original counter really returns four free normals and pays the separate native free-revival income', () => {
const f = fixture(), before = structuredClone(player(f.game, f.target)), ownerSpice = player(f.game, f.owner).spice;
const pending = request(f.game, f).game;
const allowed = allow(pending, f), after = player(allowed.game, f.target);
assert.equal(after.reserves, 4); assert.equal(after.tanks, 1);
assert.equal(after.spice, before.spice); assert.equal(after.revived, 4); assert.equal(after.freeForcesRevived, 4);
assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.elites, before.elites);
assert.equal(population(allowed.game).side, 'high');
assert.equal(player(allowed.game, f.owner).spice, ownerSpice + 1);
assert.ok(!player(allowed.game, f.owner).specialKaramaUsed);
assert.ok(player(allowed.game, f.owner).hand.some(card => card.id === f.karama));
assert.equal(allowed.game.pendingRevival, null); assert.ok(clean(allowed.game));
assert.equal(allowed.game.revivalFreeIncome?.[f.target], allowed.game.turn);
assert.deepEqual(inventory(allowed.game), f.originalInventory);
});
void test('original Ghola stays blocked during the prevented Revival but works through real readiness after phase4', () => {
const f = fixture(); assert.ok(f.ghola && f.gholaAction);
const stopped = act(f.game, f.owner, f.special), before = structuredClone(player(stopped, f.target));
assert.ok(before.hand.some(card => card.id === f.ghola));
reject(stopped, f.target, f.gholaAction, /prevented.*Ghola/);
assert.equal(viewGame(stopped, f.target).ghola?.available, false);
assert.ok(player(stopped, f.target).hand.some(card => card.id === f.ghola));
const movement = finish(stopped);
assert.equal(movement.turn, 2); assert.equal(movement.phase, 5);
assert.equal(player(movement, f.target).reserves, 0); assert.equal(player(movement, f.target).tanks, 5);
const requested = act(movement, f.target, f.gholaAction);
const completed = advance(requested, clean), after = player(completed, f.target);
assert.equal(after.reserves, 5); assert.equal(after.tanks, 0);
assert.equal(after.spice, before.spice); assert.equal(after.revived, before.revived);
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.elites, before.elites);
assert.equal(after.hand.some(card => card.id === f.ghola), false);
assert.equal(completed.discard.filter(card => card.id === f.ghola).length, 1);
assert.deepEqual(inventory(completed), f.originalInventory);
});
void test('all four existing profiles legally answer the genuine five-Tank reactive stop without changing their thresholds', () => {
const f = fixture(), pending = request(f.game, f).game;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
const answered = act(structuredClone(pending), f.owner, action);
const completed = advance(answered, clean), after = player(completed, f.target);
if (action.type === 'card') {
assert.equal(after.reserves, 0); assert.equal(after.tanks, 5); assert.equal(after.revived, 0);
assert.equal(freeRevivalRate(completed, after), 4);
assert.equal(player(completed, f.owner).specialKaramaUsed, true);
} else {
assert.equal(action.type, 'decision'); assert.equal(action.decline, true);
assert.equal(after.reserves, 4); assert.equal(after.tanks, 1); assert.equal(after.revived, 4);
assert.ok(player(completed, f.owner).hand.some(card => card.id === f.karama));
}
assert.deepEqual(inventory(completed), f.originalInventory);
assert.equal(finish(completed).phase, 5);
}
});