Connect stable occupied Homeworld income sharing and native protections
This commit is contained in:
1 parent
84d2ba1d3d
commit
5e902e384d
24 files changed
+1536
-41
No files matched your search
@@ -31,6 +31,7 @@ const decisions = {
|
||||
leaderSkillRevival: true,
|
||||
homeworldRevivalDeployment: true,
|
||||
grummanCollection: true,
|
||||
homeworldOccupiedIncome: true,
|
||||
caladanReinforcement: true,
|
||||
choamAudit: true,
|
||||
nexusChoamInspection: true,
|
||||
|
||||
+6
-1
@@ -2685,6 +2685,11 @@ function policyActions(g: GameView): Action[] {
|
||||
return caladanReinforcementActions(g);
|
||||
if (d.kind === 'grummanCollection')
|
||||
return grummanCollectionActions(g);
|
||||
if (d.kind === 'homeworldOccupiedIncome') {
|
||||
const offer = g.homeworldOccupiedIncome;
|
||||
return offer && !offer.blocked ? [{ type: 'decision', event: offer.event,
|
||||
world: offer.world, ownAmount: offer.amount }] : [];
|
||||
}
|
||||
if (d.kind === 'battleCards')
|
||||
return [
|
||||
{
|
||||
@@ -3625,7 +3630,7 @@ function policyActions(g: GameView): Action[] {
|
||||
if (b.preparation) {
|
||||
if (b.preparation.owner !== me.id) return [];
|
||||
if (b.preparation.kind === 'voice')
|
||||
return level === 0
|
||||
return level === 0 || b.preparation.blocked
|
||||
? [{ type: 'declineBattlePower' }]
|
||||
: [
|
||||
{
|
||||
|
||||
+186
-30
@@ -92,7 +92,9 @@ import { quoteHomeworldBattleRules } from './homeworld-battle-rules';
|
||||
import type { HomeworldForces } from './homeworld-custody';
|
||||
import { nativeReserveSources } from './homeworld-options';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import { lowGrummanRevealBlock } from './homeworld-collection';
|
||||
import { lowGrummanRevealBlock, type HomeworldCollectionSource } from './homeworld-collection';
|
||||
import { quoteOccupiedHomeworldVoice, quoteOccupiedHomeworldFaceDancer, quoteOccupiedHomeworldTerror, occupiedHomeworldSardaukarStatus } from './homeworld-occupied-defenses';
|
||||
import { beginHomeworldOccupiedIncome, quoteHomeworldOccupiedIncomeChoice, homeworldOccupiedIncomeOffer, validateHomeworldOccupiedIncomeState, type HomeworldOccupiedIncomeState } from './homeworld-occupied-income';
|
||||
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
|
||||
import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return';
|
||||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
@@ -744,6 +746,7 @@ export type Battle = {
|
||||
kind: 'voice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer';
|
||||
owner: string;
|
||||
beneficiary: string;
|
||||
blocked?: string;
|
||||
};
|
||||
prescience?: {
|
||||
player: string;
|
||||
@@ -772,6 +775,7 @@ export type Decision =
|
||||
| { kind: 'leaderSkillRevival'; player: string; event: string }
|
||||
| { kind: 'homeworldRevivalDeployment'; player: string; event: string }
|
||||
| { kind: 'grummanCollection'; player: string; event: string }
|
||||
| { kind: 'homeworldOccupiedIncome'; player: string; event: string; world: string }
|
||||
| { kind: 'caladanReinforcement'; player: string; event: string }
|
||||
| { kind: 'choamAudit'; player: string; event: string }
|
||||
| { kind: 'nexusChoamInspection'; player: string; event: string }
|
||||
@@ -1869,6 +1873,10 @@ export type Game = {
|
||||
/** Null/absent disables the module; custody is installed at force placement. */
|
||||
homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null;
|
||||
homeworldOccupationHistory?: HomeworldOccupationHistory;
|
||||
/** Fresh development occupation entry only; never inferred for an older Homeworld save. */
|
||||
homeworldOccupationPreview?: true;
|
||||
pendingHomeworldOccupiedIncome?: HomeworldOccupiedIncomeState | null;
|
||||
homeworldOccupiedIncomeHistory?: HomeworldOccupiedIncomeState[];
|
||||
tupileIntelligence?: TupileIntelligenceState;
|
||||
pendingTech?: { player: string; loser: string; choices: TechId[] } | null;
|
||||
summonedBeforeBlow?: boolean;
|
||||
@@ -7135,6 +7143,14 @@ function homeworldDefenseIntegrity(g: Game) {
|
||||
}
|
||||
}
|
||||
function faceDanceReturnBlock(g: Game, winner: string): string | null {
|
||||
const protectedWinner = homeworldRule(() => quoteOccupiedHomeworldFaceDancer(g, winner));
|
||||
if (protectedWinner.blocked) return protectedWinner.blocked;
|
||||
const coside = g.pendingFaceDance?.winner === winner ? g.pendingFaceDance.winners : undefined;
|
||||
if (coside) for (const target of coside) {
|
||||
if (target === winner) continue;
|
||||
const quote = homeworldRule(() => quoteOccupiedHomeworldFaceDancer(g, target));
|
||||
if (quote.blocked) return quote.blocked;
|
||||
}
|
||||
return g.advanced && g.homeworlds && getPlayer(g, winner).faction === 'emperor'
|
||||
? 'Face Dance returning the Emperor’s army awaits the Kaitain/Salusa reserve-placement ruling.' : null;
|
||||
}
|
||||
@@ -7299,10 +7315,13 @@ function combatForces(
|
||||
const army = combatArmy(g, p.id, t);
|
||||
const physical = army.normal + army.elite;
|
||||
const elite = army.elite;
|
||||
const occupation = g.homeworldOccupationPreview && p.faction === 'emperor'
|
||||
? homeworldRule(() => occupiedHomeworldSardaukarStatus(g)) : undefined;
|
||||
requireRule(!occupation?.blocked, occupation?.blocked ?? 'Occupied Salusa combat is unresolved.');
|
||||
return {
|
||||
normal: physical + prospective - elite,
|
||||
...(nexusSardaukarEffective(g,p.id,t) ? {temporaryElite:nexusSardaukarEffective(g,p.id,t)} : {}),
|
||||
...(homeworldSardaukarFreeSupport(g, p.id)
|
||||
...(!occupation?.suppressed && homeworldSardaukarFreeSupport(g, p.id)
|
||||
? { eliteFreeSupport: true }
|
||||
: {}),
|
||||
normalFixedHalf: p.faction === 'ixians' && !nexusSuboidsActive(g, g.nexusSuboidHistory, p.id),
|
||||
@@ -7312,6 +7331,7 @@ function combatForces(
|
||||
(!g.advanced && p.faction !== 'ixians') ||
|
||||
(g.battle?.eliteBlocked?.includes(p.id) &&
|
||||
!(g.advanced && p.faction === 'ixians')) ||
|
||||
occupation?.suppressed ||
|
||||
(p.faction === 'emperor' && opponent.faction === 'fremen')
|
||||
? 1
|
||||
: 2,
|
||||
@@ -8455,6 +8475,26 @@ export function initializeHomeworldGameForAudit(state: Game): Game {
|
||||
);
|
||||
return initializeSetupGameForAudit(state, true);
|
||||
}
|
||||
/** Fresh occupation prototype; ambiguity remains source-gated rather than assigned a controller. */
|
||||
export function initializeHomeworldOccupationGameForAudit(state: Game): Game {
|
||||
requireRule(state.homeworlds?.custody === null && !state.homeworldOccupationPreview &&
|
||||
!state.homeworldOccupationHistory && !state.nexusCards && !state.leaderSkills &&
|
||||
!state.techTokens && !state.strongholdCards && !state.discoveryEnabled &&
|
||||
!state.discoveries && !state.discoveryStash && !state.greatMaker && !state.ecazTreachery &&
|
||||
typeof state.advanced === 'boolean' && state.expansions.length <= 3 &&
|
||||
state.expansions.every((id, index) => (id === 'ix' || id === 'choam' || id === 'ecaz') &&
|
||||
state.expansions.indexOf(id) === index) &&
|
||||
state.players.every(p => FACTIONS.some(f => f.id === p.faction &&
|
||||
(f.expansion === 'base' || state.expansions.includes(f.expansion)))),
|
||||
'Occupation requires a fresh Homeworld lobby with original selected faction decks and no other modules.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
const g = initializeSetupGameForAudit(state, true, false, false, false, false, false, true);
|
||||
g.homeworldOccupationPreview = true;
|
||||
g.pendingHomeworldOccupiedIncome = null;
|
||||
g.homeworldOccupiedIncomeHistory = [];
|
||||
return g;
|
||||
}
|
||||
/** Offline-only seam for the independent Nexus module; public starts remain gated. */
|
||||
export function initializeNexusGameForAudit(state: Game): Game {
|
||||
requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null,
|
||||
@@ -14057,7 +14097,7 @@ function quoteMovementArrival(
|
||||
players: g.players,
|
||||
order: move,
|
||||
ambassadors: g.ecazAmbassadors?.tokens ?? [],
|
||||
terror: homeworldTerrorEntryBlock(g, move.total)
|
||||
terror: homeworldTerrorEntryBlock(g, move.player, move.total, 'trigger')
|
||||
? [] : (g.moritaniTerror?.tokens ?? []),
|
||||
flight: g.ornithopter,
|
||||
controls: {
|
||||
@@ -14130,7 +14170,7 @@ function quoteShipmentArrival(
|
||||
wantsFighters: false,
|
||||
},
|
||||
ambassadors: g.ecazAmbassadors?.tokens ?? [],
|
||||
terror: homeworldTerrorEntryBlock(g, amount)
|
||||
terror: homeworldTerrorEntryBlock(g, p.id, amount, 'trigger')
|
||||
? []
|
||||
: (g.moritaniTerror?.tokens ?? []),
|
||||
controls: {
|
||||
@@ -14583,7 +14623,7 @@ function openTerritoryEntry(
|
||||
const terrorTokens = moritani &&
|
||||
entrant.id !== moritani.id &&
|
||||
entrant.id !== moritani.ally &&
|
||||
!homeworldTerrorEntryBlock(g, amount)
|
||||
!homeworldTerrorEntryBlock(g, entrant.id, amount, 'trigger')
|
||||
? g.moritaniTerror?.tokens.filter(t => t.status === 'placed' && t.location === to) ?? []
|
||||
: [];
|
||||
requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador,
|
||||
@@ -15212,7 +15252,7 @@ function validateGuildAdvisorEntry(
|
||||
moritani &&
|
||||
moritani.id !== bg.id &&
|
||||
moritani.ally !== bg.id &&
|
||||
!homeworldTerrorEntryBlock(g, quote.amount) &&
|
||||
!homeworldTerrorEntryBlock(g, bg.id, quote.amount, 'trigger') &&
|
||||
g.moritaniTerror?.tokens.some(
|
||||
(t) => t.status === 'placed' && t.location === quote.territory,
|
||||
);
|
||||
@@ -15420,7 +15460,7 @@ function ambassadorRelocationArrivalBlock(
|
||||
moritani &&
|
||||
mover.id !== moritani.id &&
|
||||
mover.id !== moritani.ally &&
|
||||
!homeworldTerrorEntryBlock(g, entering) &&
|
||||
!homeworldTerrorEntryBlock(g, mover.id, entering, 'trigger') &&
|
||||
g.moritaniTerror?.tokens.some(
|
||||
(token) => token.status === 'placed' && token.location === to,
|
||||
);
|
||||
@@ -16157,12 +16197,14 @@ function terrorEntryIntegrity(g: Game) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Public original batch size; concealed No-Field markers count as one. */
|
||||
function homeworldTerrorEntryBlock(g: Game, entering: number): string | null {
|
||||
/** Unknown occupation retains an owned Reveal/Decline window; it never authorizes revelation. */
|
||||
function homeworldTerrorEntryBlock(g: Game, entrant: string, entering: number, mode: 'trigger' | 'reveal'): string | null {
|
||||
const owner = byFaction(g, 'moritani');
|
||||
return owner
|
||||
? homeworldRule(() => lowGrummanRevealBlock(g, owner.id, entering))
|
||||
: null;
|
||||
if (!owner) return null;
|
||||
const occupation = homeworldRule(() => quoteOccupiedHomeworldTerror(g, entrant));
|
||||
if (occupation.status === 'prohibited' || mode === 'reveal' && occupation.blocked)
|
||||
return occupation.blocked;
|
||||
return homeworldRule(() => lowGrummanRevealBlock(g, owner.id, entering));
|
||||
}
|
||||
/** Entry is already paid and committed; a reaction must never replay its original action. */
|
||||
function openTerrorEntry(
|
||||
@@ -16189,7 +16231,7 @@ function openTerrorEntry(
|
||||
const token = tokens[0];
|
||||
if (!moritani || !token || entrant.id === moritani.id ||
|
||||
(entrant.id === moritani.ally && !originallyTriggered) ||
|
||||
homeworldTerrorEntryBlock(g, amount))
|
||||
homeworldTerrorEntryBlock(g, entrant.id, amount, 'trigger'))
|
||||
return false;
|
||||
requireRule(!g.pendingTerrorEntry, 'Resolve the pending Terror entry first.');
|
||||
requireRule(
|
||||
@@ -16287,7 +16329,7 @@ function terrorRevealBlocked(
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>,
|
||||
kind: TerrorKind,
|
||||
): string | null {
|
||||
const homeworldBlock = homeworldTerrorEntryBlock(g, entry.amount);
|
||||
const homeworldBlock = homeworldTerrorEntryBlock(g, entry.entrant, entry.amount, 'reveal');
|
||||
if (homeworldBlock) return homeworldBlock;
|
||||
if (kind === 'atomics') {
|
||||
try { currentAtomicsQuote(g, entry); return null; }
|
||||
@@ -17920,10 +17962,10 @@ function currentEcazCollectionQuote(
|
||||
);
|
||||
return quote;
|
||||
}
|
||||
function creditGiediCollection(g: Game, player: string, desert: number) {
|
||||
function creditGiediCollection(g: Game, player: string, source: HomeworldCollectionSource) {
|
||||
if (!g.homeworlds?.custody || getPlayer(g, player).faction !== 'harkonnen') return;
|
||||
const quote = homeworldRule(() => quoteGiediCollectionReceipt(
|
||||
g, g.turn, player, [{ kind: 'desert', amount: desert }], g.giediCollection,
|
||||
g, g.turn, player, [source], g.giediCollection,
|
||||
));
|
||||
const p = getPlayer(g, player);
|
||||
requireRule(Number.isSafeInteger(p.spice + quote.amount),
|
||||
@@ -17931,7 +17973,7 @@ function creditGiediCollection(g: Game, player: string, desert: number) {
|
||||
p.spice += quote.amount;
|
||||
g.giediCollection = quote.receipt;
|
||||
if (quote.amount)
|
||||
log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert collection once this phase.',
|
||||
log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert or Homeworld Collection once this phase.',
|
||||
{ faction: 'harkonnen', name: 'Giedi Prime collection' });
|
||||
}
|
||||
/** Qualification history records facts only; disputed expiry and benefits remain separate. */
|
||||
@@ -17953,6 +17995,94 @@ function homeworldHistoryIntegrity(g: Game) {
|
||||
requireRule(!!choam === (g.tupileIntelligence !== undefined), 'The original Tupile intelligence ledger is missing.');
|
||||
if (g.tupileIntelligence) homeworldRule(() => validateTupileIntelligenceState(g.tupileIntelligence!, g.players, g.turn));
|
||||
} else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.');
|
||||
homeworldOccupiedIncomeIntegrity(g);
|
||||
}
|
||||
function homeworldOccupiedIncomeIntegrity(g: Game) {
|
||||
const pending = g.pendingHomeworldOccupiedIncome, history = g.homeworldOccupiedIncomeHistory;
|
||||
if (!g.homeworldOccupationPreview) {
|
||||
requireRule(pending === undefined && history === undefined,
|
||||
'Occupied income requires its original fresh occupation profile.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.homeworldOccupationPreview === true && g.homeworlds && !g.nexusCards &&
|
||||
!g.leaderSkills && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled &&
|
||||
Array.isArray(history) && pending !== undefined,
|
||||
'The fresh occupation profile lost its original Homeworld module or income ledger.');
|
||||
const turns = new Set<number>();
|
||||
for (const receipt of history) {
|
||||
requireRule(receipt.turn <= g.turn && !turns.has(receipt.turn) &&
|
||||
receipt.cursor === receipt.queue.length, 'Occupied Collection was incomplete or repeated.');
|
||||
homeworldRule(() => validateHomeworldOccupiedIncomeState(receipt, g, receipt.turn));
|
||||
turns.add(receipt.turn);
|
||||
}
|
||||
const decision = g.decision?.kind === 'homeworldOccupiedIncome' ? g.decision : null;
|
||||
if (!pending) {
|
||||
requireRule(!decision, 'The occupied bank choice lost its original Collection receipt.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.status === 'playing' && g.phase === 7 && pending.turn === g.turn &&
|
||||
!turns.has(pending.turn), 'Occupied bank income belongs to one original Collection phase.');
|
||||
homeworldRule(() => validateHomeworldOccupiedIncomeState(pending, g, g.turn));
|
||||
if (decision) {
|
||||
const row = pending.queue[pending.cursor];
|
||||
requireRule(row && decision.event === pending.event && decision.world === row.world &&
|
||||
decision.player === row.occupier, 'The bank choice lost its original world or beneficiary.');
|
||||
}
|
||||
}
|
||||
function homeworldOccupiedIncomeAutomatic(g: Game): boolean {
|
||||
return !!(g.homeworldOccupationPreview && g.pendingHomeworldOccupiedIncome &&
|
||||
g.status === 'playing' && g.phase === 7 && !g.phaseOpening && !g.decision &&
|
||||
!g.response && !g.truthtrance && !g.pendingTreacheryDiscard && !g.pendingNullentropy &&
|
||||
!g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama &&
|
||||
(!g.ecazCollection || g.ecazCollection.stage === 'complete'));
|
||||
}
|
||||
function commitHomeworldOccupiedIncome(g: Game, actor: string, event: string, world: string, own: number) {
|
||||
const pending = g.pendingHomeworldOccupiedIncome!;
|
||||
const result = homeworldRule(() => quoteHomeworldOccupiedIncomeChoice(pending, g, actor, event, world, own));
|
||||
for (const credit of result.credits)
|
||||
requireRule(Number.isSafeInteger(getPlayer(g, credit.player).spice + credit.amount),
|
||||
'Occupied bank Collection would overflow a spice balance.');
|
||||
g.pendingHomeworldOccupiedIncome = result.state;
|
||||
for (const credit of result.credits) {
|
||||
getPlayer(g, credit.player).spice += credit.amount;
|
||||
creditGiediCollection(g, credit.player, { kind: 'homeworld', amount: credit.amount });
|
||||
log(g, `${getPlayer(g, credit.player).name} received ${credit.amount} spice from ${world}'s occupied bank award.`,
|
||||
{ faction: getPlayer(g, credit.player).faction, name: 'Occupied Homeworld Collection' });
|
||||
}
|
||||
}
|
||||
function resumeHomeworldOccupiedIncome(g: Game) {
|
||||
if (!homeworldOccupiedIncomeAutomatic(g)) return;
|
||||
homeworldOccupiedIncomeIntegrity(g);
|
||||
while (g.pendingHomeworldOccupiedIncome) {
|
||||
const pending = g.pendingHomeworldOccupiedIncome;
|
||||
const row = pending.queue[pending.cursor];
|
||||
if (!row) {
|
||||
g.homeworldOccupiedIncomeHistory!.push(pending);
|
||||
g.pendingHomeworldOccupiedIncome = null;
|
||||
return;
|
||||
}
|
||||
if (row.ally && row.spice > 0) {
|
||||
g.decision = { kind: 'homeworldOccupiedIncome', player: row.occupier,
|
||||
event: pending.event, world: row.world };
|
||||
return;
|
||||
}
|
||||
commitHomeworldOccupiedIncome(g, row.occupier, pending.event, row.world, row.spice);
|
||||
}
|
||||
}
|
||||
function decideHomeworldOccupiedIncome(g: Game, p: Player,
|
||||
decision: Extract<Decision, { kind: 'homeworldOccupiedIncome' }>, action: Action) {
|
||||
homeworldOccupiedIncomeIntegrity(g);
|
||||
requireRule(decision.player === p.id &&
|
||||
action.type === 'decision' && action.event === decision.event && action.world === decision.world &&
|
||||
Object.keys(action).sort().join(',') === 'event,ownAmount,type,world',
|
||||
'Choose only the original occupied bank event, world and own allocation.');
|
||||
commitHomeworldOccupiedIncome(g, p.id, decision.event, decision.world, action.ownAmount as number);
|
||||
g.decision = null;
|
||||
resumeHomeworldOccupiedIncome(g);
|
||||
}
|
||||
function projectedHomeworldOccupiedIncome(g: Game, player: string) {
|
||||
if (g.decision?.kind !== 'homeworldOccupiedIncome' || !g.pendingHomeworldOccupiedIncome) return null;
|
||||
return homeworldRule(() => homeworldOccupiedIncomeOffer(g.pendingHomeworldOccupiedIncome!, g, player));
|
||||
}
|
||||
function tupileIntelligenceBlock(g: Game): string | null {
|
||||
if (!g.homeworlds?.custody || !g.tupileIntelligence || !g.homeworldOccupationHistory)
|
||||
@@ -18016,7 +18146,7 @@ function stageGrummanCollection(g: Game) {
|
||||
function grummanCollectionAutomatic(g: Game): boolean {
|
||||
const frame = g.grummanCollection;
|
||||
return !!(frame?.stage === 'waiting' && frame.turn === g.turn && g.phase === 7 &&
|
||||
!g.phaseOpening && !g.decision && !g.response && !g.truthtrance && !g.pendingTreacheryDiscard &&
|
||||
!g.phaseOpening && !g.pendingHomeworldOccupiedIncome && !g.decision && !g.response && !g.truthtrance && !g.pendingTreacheryDiscard &&
|
||||
!g.pendingNullentropy && !g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama &&
|
||||
(!g.ecazCollection || g.ecazCollection.stage === 'complete') &&
|
||||
homeworldRule(() => quoteGrummanCollection(g, frame.player)).high);
|
||||
@@ -18146,10 +18276,13 @@ function commitCollection(
|
||||
);
|
||||
}
|
||||
for (const receipt of discovery.receipts)
|
||||
creditGiediCollection(g, receipt.player, receipt.desert);
|
||||
creditGiediCollection(g, receipt.player, { kind: 'desert', amount: receipt.desert });
|
||||
stageGrummanCollection(g);
|
||||
}
|
||||
function collect(g: Game) {
|
||||
if (g.homeworldOccupationPreview && !g.pendingHomeworldOccupiedIncome &&
|
||||
!g.homeworldOccupiedIncomeHistory!.some(receipt => receipt.turn === g.turn))
|
||||
g.pendingHomeworldOccupiedIncome = homeworldRule(() => beginHomeworldOccupiedIncome(g, crypto.randomUUID()));
|
||||
if (g.grummanCollection?.turn === g.turn) {
|
||||
grummanCollectionIntegrity(g);
|
||||
return;
|
||||
@@ -18218,8 +18351,8 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
|
||||
pending.settled.push(receipt);
|
||||
getPlayer(g, receipt.ecaz).spice += receipt.ecazAmount;
|
||||
getPlayer(g, receipt.ally).spice += receipt.allyAmount;
|
||||
creditGiediCollection(g, receipt.ecaz, receipt.ecazAmount);
|
||||
creditGiediCollection(g, receipt.ally, receipt.allyAmount);
|
||||
creditGiediCollection(g, receipt.ecaz, { kind: 'desert', amount: receipt.ecazAmount });
|
||||
creditGiediCollection(g, receipt.ally, { kind: 'desert', amount: receipt.allyAmount });
|
||||
log(
|
||||
g,
|
||||
`${territory(receipt.territory).name} shared collection: Ecaz received ${receipt.ecazAmount} spice and ${faction(getPlayer(g, receipt.ally).faction).name} received ${receipt.allyAmount}, ${receipt.method === 'agreement' ? 'by agreement' : 'using the equal split with any odd spice going to the ally'}.`,
|
||||
@@ -18913,10 +19046,12 @@ function combatResponses(g: Game) {
|
||||
if (
|
||||
((g.advanced && ['emperor', 'fremen'].includes(p.faction)) ||
|
||||
(!g.advanced && p.faction === 'ixians')) &&
|
||||
!(p.faction === 'emperor' && other.faction === 'fremen') &&
|
||||
combatForces(g, p, b.territory, other).elite > 0
|
||||
)
|
||||
b.powerChecks.push({ kind: 'eliteStrength', owner: forceOwner });
|
||||
!(p.faction === 'emperor' && other.faction === 'fremen')
|
||||
) {
|
||||
const forces = combatForces(g, p, b.territory, other);
|
||||
if (forces.elite > 0 && forces.eliteStrength > 1)
|
||||
b.powerChecks.push({ kind: 'eliteStrength', owner: forceOwner });
|
||||
}
|
||||
if (g.advanced && p.faction === 'fremen')
|
||||
b.powerChecks.push({ kind: 'fremenSupport', owner: forceOwner });
|
||||
}
|
||||
@@ -18947,9 +19082,17 @@ function battlePreparation(g: Game, kind: 'voice' | 'prescience') {
|
||||
: owner.ally && combatants.includes(owner.ally)
|
||||
? owner.ally
|
||||
: null);
|
||||
if (owner && beneficiary)
|
||||
b.preparation = { kind, owner: owner.id, beneficiary };
|
||||
else if (kind === 'voice') battlePreparation(g, 'prescience');
|
||||
if (owner && beneficiary) {
|
||||
const target = b.attacker === beneficiary ? b.defender : b.attacker;
|
||||
const occupation = kind === 'voice'
|
||||
? homeworldRule(() => quoteOccupiedHomeworldVoice(g, target)) : null;
|
||||
if (occupation?.status === 'prohibited') {
|
||||
battlePreparation(g, 'prescience');
|
||||
return;
|
||||
}
|
||||
b.preparation = { kind, owner: owner.id, beneficiary,
|
||||
...(occupation?.blocked ? { blocked: occupation.blocked } : {}) };
|
||||
} else if (kind === 'voice') battlePreparation(g, 'prescience');
|
||||
else finishBattlePreparation(g);
|
||||
}
|
||||
function normalizeBattle(g: Game) {
|
||||
@@ -26567,6 +26710,7 @@ function finishActionContinuations(g: Game) {
|
||||
resumeHomeworldRevivalReturn(g);
|
||||
resumeHomeworldVictoryReturn(g);
|
||||
resumeMarketGhola(g);
|
||||
resumeHomeworldOccupiedIncome(g);
|
||||
resumeGrummanCollection(g);
|
||||
if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g);
|
||||
if (g.status === 'playing' && g.leaderSkills && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening) {
|
||||
@@ -27424,6 +27568,10 @@ function applyActionInner(
|
||||
decideGrummanCollection(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'homeworldOccupiedIncome') {
|
||||
decideHomeworldOccupiedIncome(g, p, decision, action);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'moritaniTerror') {
|
||||
decideTerror(g, p, action);
|
||||
return g;
|
||||
@@ -30385,6 +30533,9 @@ function applyActionInner(
|
||||
b?.preparation?.kind === 'voice' && b.preparation.owner === id,
|
||||
'Voice must be chosen by Bene Gesserit before prescience and battle plans.',
|
||||
);
|
||||
const target = b.attacker === b.preparation.beneficiary ? b.defender : b.attacker;
|
||||
const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
|
||||
requireRule(occupation.status === 'allowed', occupation.blocked ?? 'This target cannot receive the Voice.');
|
||||
requireRule(
|
||||
VOICE_KINDS.includes(String(action.kind)),
|
||||
'Choose a card type.',
|
||||
@@ -30798,7 +30949,7 @@ export function viewGame(state: Game, id: string) {
|
||||
schema: g.schema,
|
||||
botsPending: g.botsPending ?? false,
|
||||
semutaReaction: projectedSemutaReaction(g, me),
|
||||
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || grummanCollectionAutomatic(g),
|
||||
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g),
|
||||
botNextActionAt: g.botNextActionAt ?? null,
|
||||
code: g.code,
|
||||
version: g.version,
|
||||
@@ -30809,6 +30960,7 @@ export function viewGame(state: Game, id: string) {
|
||||
leaderSkills: projectedLeaderSkills(g, id),
|
||||
bureaucrat: projectedBureaucrat(g, id),
|
||||
spiceBankerIncomePreview: g.spiceBankerIncomePreview === true,
|
||||
homeworldOccupationPreview: g.homeworldOccupationPreview === true,
|
||||
spiceBankerIncome: g.spiceBankerIncomePreview
|
||||
? bankerIncomeRule(() => projectBankerIncome(g.spiceBankerIncome!, bankerIncomeContext(g))) : null,
|
||||
bribeOptions: projectedBribes(g, me),
|
||||
@@ -30853,6 +31005,7 @@ export function viewGame(state: Game, id: string) {
|
||||
moritaniPendingPlacement:
|
||||
me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null,
|
||||
grummanCollection: projectedGrummanCollection(g, id),
|
||||
homeworldOccupiedIncome: projectedHomeworldOccupiedIncome(g, id),
|
||||
tupileIntelligence: projectedTupileIntelligence(g, id),
|
||||
terrorEntry: g.pendingTerrorEntry
|
||||
? (() => {
|
||||
@@ -31818,7 +31971,10 @@ export function viewGame(state: Game, id: string) {
|
||||
: {},
|
||||
traitorSubmitted: Object.keys(b.traitorCalls),
|
||||
traitorVoters: traitorVoters(g, b),
|
||||
preparation: b.preparation ?? null,
|
||||
preparation: b.preparation && g.homeworldOccupationPreview && b.preparation.kind === 'voice'
|
||||
? { ...b.preparation, blocked: homeworldRule(() => quoteOccupiedHomeworldVoice(g,
|
||||
b.attacker === b.preparation!.beneficiary ? b.defender : b.attacker)).blocked ?? undefined }
|
||||
: b.preparation ?? null,
|
||||
nexusInspection: b.nexusInspection ? {
|
||||
event: b.nexusInspection.event, mode: b.nexusInspection.mode,
|
||||
owner: b.nexusInspection.owner, target: b.nexusInspection.target,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Game } from './engine';
|
||||
import type { HomeworldId } from './homeworld-cards';
|
||||
import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation';
|
||||
|
||||
export type OccupiedHomeworldDefenseQuote = {
|
||||
status: 'allowed' | 'prohibited' | 'unresolved';
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
function occupiedTargetQuote(
|
||||
game: Game,
|
||||
card: HomeworldId,
|
||||
target: string,
|
||||
reason: string,
|
||||
): OccupiedHomeworldDefenseQuote {
|
||||
const { entitlement, blocked } = quoteStableHomeworldOccupation(game, card);
|
||||
if (blocked) return { status: 'unresolved', blocked };
|
||||
return entitlement && (target === entitlement.occupier || target === entitlement.ally)
|
||||
? { status: 'prohibited', blocked: reason } : { status: 'allowed', blocked: null };
|
||||
}
|
||||
|
||||
/** Wallach IX's original occupied face protects the occupier and their current
|
||||
* reciprocal ally from native Bene Gesserit Voice, wherever the battle occurs. */
|
||||
export function quoteOccupiedHomeworldVoice(game: Game, target: string): OccupiedHomeworldDefenseQuote {
|
||||
return occupiedTargetQuote(game, 'wallach_ix', target,
|
||||
'The Wallach IX occupier and their ally are immune to Bene Gesserit Voice.');
|
||||
}
|
||||
|
||||
/** Tleilax's original occupied face restricts native Face Dancer reveals. */
|
||||
export function quoteOccupiedHomeworldFaceDancer(game: Game, target: string): OccupiedHomeworldDefenseQuote {
|
||||
return occupiedTargetQuote(game, 'tleilax', target,
|
||||
'Tleilaxu cannot reveal Face Dancers against the Tleilax occupier or their ally.');
|
||||
}
|
||||
|
||||
/** Grumman's original occupied face applies to the entrant, not the token's
|
||||
* territory or Moritani's own alliance. The separate low entry-size rule remains. */
|
||||
export function quoteOccupiedHomeworldTerror(game: Game, entrant: string): OccupiedHomeworldDefenseQuote {
|
||||
return occupiedTargetQuote(game, 'grumman', entrant,
|
||||
'Moritani cannot reveal Terror when the Grumman occupier or their ally enters.');
|
||||
}
|
||||
|
||||
/** Salusa's original occupied face removes native Sardaukar's advantage only.
|
||||
* This quote never converts, relocates or removes actual starred counters.
|
||||
* A blocked quote is not permission to select either possible combat strength. */
|
||||
export function occupiedHomeworldSardaukarStatus(
|
||||
game: Game,
|
||||
): { suppressed: boolean; blocked: string | null } {
|
||||
const { entitlement, blocked } = quoteStableHomeworldOccupation(game, 'salusa_secundus');
|
||||
return { suppressed: entitlement !== null, blocked };
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { HOMEWORLD_CARDS, homeworldCard, type HomeworldId } from './homeworld-cards';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import {
|
||||
quoteStableHomeworldOccupation,
|
||||
type StableHomeworldOccupationContext,
|
||||
type StableHomeworldOccupier,
|
||||
} from './homeworld-stable-occupation';
|
||||
|
||||
export type HomeworldOccupiedIncomeCredit = { player: string; amount: number };
|
||||
export type HomeworldOccupiedIncomeReceipt =
|
||||
| { world: string; status: 'pending' }
|
||||
| { world: string; status: 'settled'; ownAmount: number; credits: HomeworldOccupiedIncomeCredit[] };
|
||||
export type HomeworldOccupiedIncomeState = {
|
||||
version: 1;
|
||||
event: string;
|
||||
turn: number;
|
||||
/** Frozen at Collection entry: neither current armies nor a new alliance may replace a recipient. */
|
||||
queue: readonly Readonly<StableHomeworldOccupier>[];
|
||||
cursor: number;
|
||||
receipts: HomeworldOccupiedIncomeReceipt[];
|
||||
signature: string;
|
||||
};
|
||||
export type HomeworldOccupiedIncomeOffer = {
|
||||
event: string;
|
||||
turn: number;
|
||||
world: string;
|
||||
card: HomeworldId;
|
||||
name: string;
|
||||
owner: string;
|
||||
ownerName: string;
|
||||
ally: string | null;
|
||||
allyName: string | null;
|
||||
amount: number;
|
||||
minOwnAmount: number;
|
||||
maxOwnAmount: number;
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
const whole = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
|
||||
function requireIncome(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new HomeworldCustodyError(`Occupied Homeworld Collection: ${message}`);
|
||||
}
|
||||
function keys(value: unknown, expected: string[]): boolean {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key));
|
||||
}
|
||||
function identity(row: StableHomeworldOccupier) {
|
||||
return [row.world, row.card, row.native, row.occupier, row.ally, row.qualification, row.turn, row.spice];
|
||||
}
|
||||
function allocation(row: StableHomeworldOccupier, ownAmount: number): HomeworldOccupiedIncomeCredit[] {
|
||||
const credits: HomeworldOccupiedIncomeCredit[] = [];
|
||||
if (ownAmount > 0) credits.push({ player: row.occupier, amount: ownAmount });
|
||||
if (row.ally && ownAmount < row.spice) credits.push({ player: row.ally, amount: row.spice - ownAmount });
|
||||
return credits;
|
||||
}
|
||||
function signature(state: HomeworldOccupiedIncomeState): string {
|
||||
return JSON.stringify([state.version, state.event, state.turn, state.queue.map(identity), state.cursor,
|
||||
state.receipts.map(receipt => receipt.status === 'pending'
|
||||
? [receipt.world, receipt.status]
|
||||
: [receipt.world, receipt.status, receipt.ownAmount, receipt.credits.map(credit => [credit.player, credit.amount])])]);
|
||||
}
|
||||
/** Historical receipt validation does not re-award or reinterpret its old entitlement. */
|
||||
export function validateHomeworldOccupiedIncomeState(
|
||||
state: HomeworldOccupiedIncomeState, game: StableHomeworldOccupationContext, turn: number,
|
||||
): void {
|
||||
requireIncome(keys(state, ['version', 'event', 'turn', 'queue', 'cursor', 'receipts', 'signature']) &&
|
||||
state.version === 1 && text(state.event) && whole(state.turn) && state.turn > 0 && state.turn === turn &&
|
||||
Array.isArray(state.queue) && whole(state.cursor) && state.cursor <= state.queue.length &&
|
||||
Array.isArray(state.receipts) && state.receipts.length === state.queue.length && typeof state.signature === 'string',
|
||||
'invalid or stale saved event, turn, queue or receipts.');
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < state.queue.length; index++) {
|
||||
const row = state.queue[index], receipt = state.receipts[index];
|
||||
const card = row && homeworldCard(row.card);
|
||||
requireIncome(keys(row, ['world', 'card', 'native', 'occupier', 'ally', 'qualification', 'turn', 'spice']) &&
|
||||
card && text(row.world) && text(row.qualification) && row.turn === state.turn &&
|
||||
row.spice === card.occupied.spiceIcons && !seen.has(row.world) &&
|
||||
game.players.some(player => player.id === row.native && player.faction === card.faction) &&
|
||||
game.players.some(player => player.id === row.occupier && player.id !== row.native) &&
|
||||
row.world === (card.id === 'salusa_secundus' ? 'homeworld:emperor:salusa' : `homeworld:${card.faction}`) &&
|
||||
(card.id !== 'salusa_secundus' || game.advanced) &&
|
||||
(row.ally === null || game.players.some(player => player.id === row.ally && player.id !== row.occupier)),
|
||||
'invalid frozen world entitlement.');
|
||||
seen.add(row.world);
|
||||
requireIncome(receipt && receipt.world === row.world, 'receipt lost its original world.');
|
||||
if (index >= state.cursor) {
|
||||
requireIncome(keys(receipt, ['world', 'status']) && receipt.status === 'pending', 'pending receipt/cursor mismatch.');
|
||||
} else {
|
||||
requireIncome(keys(receipt, ['world', 'status', 'ownAmount', 'credits']) && receipt.status === 'settled',
|
||||
'settled receipt/cursor mismatch.');
|
||||
requireIncome(whole(receipt.ownAmount) && receipt.ownAmount <= row.spice &&
|
||||
(row.ally !== null || receipt.ownAmount === row.spice) && Array.isArray(receipt.credits), 'invalid settled split.');
|
||||
const expected = allocation(row, receipt.ownAmount);
|
||||
requireIncome(receipt.credits.length === expected.length && receipt.credits.every((credit, i) =>
|
||||
keys(credit, ['player', 'amount']) && credit.player === expected[i].player && credit.amount === expected[i].amount),
|
||||
'settled credits do not conserve the printed bank award.');
|
||||
}
|
||||
}
|
||||
requireIncome(state.signature === signature(state), 'saved income signature changed.');
|
||||
}
|
||||
function currentBlock(row: StableHomeworldOccupier, game: StableHomeworldOccupationContext): string | null {
|
||||
const current = quoteStableHomeworldOccupation(game, row.card);
|
||||
if (current.blocked) return current.blocked;
|
||||
if (!current.entitlement || JSON.stringify(identity(current.entitlement)) !== JSON.stringify(identity(row)))
|
||||
return 'The original occupied Homeworld entitlement or reciprocal ally changed before settlement.';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Printed BANK icons only, including Basic and zero-icon Salusa. Percentage
|
||||
* payment/Collection receipts are separate sources. No read, begin or quote
|
||||
* changes wallets. The engine persists one queue per turn and atomically applies
|
||||
* the returned credits with its next state. Sources: HOMEWORLD_OCCUPATION_RULES
|
||||
* 7–33,35–56; HOMEWORLD_RULES83–105; the original thirteen occupied card faces. */
|
||||
export function beginHomeworldOccupiedIncome(
|
||||
game: StableHomeworldOccupationContext,
|
||||
event: string,
|
||||
): HomeworldOccupiedIncomeState {
|
||||
requireIncome(text(event) && whole(game.turn) && game.turn > 0, 'a Collection event and turn are required.');
|
||||
const queue: StableHomeworldOccupier[] = [];
|
||||
for (const card of HOMEWORLD_CARDS) {
|
||||
const quote = quoteStableHomeworldOccupation(game, card.id);
|
||||
if (card.occupied.spiceIcons > 0)
|
||||
requireIncome(!quote.blocked, quote.blocked ?? 'ambiguous occupied world.');
|
||||
if (quote.entitlement) queue.push(Object.freeze({ ...quote.entitlement }));
|
||||
}
|
||||
const state: HomeworldOccupiedIncomeState = { version: 1, event, turn: game.turn, queue: Object.freeze(queue), cursor: 0,
|
||||
receipts: queue.map(row => ({ world: row.world, status: 'pending' })), signature: '' };
|
||||
state.signature = signature(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** A public offer is a read-only projection, never a bank award or Karama window. */
|
||||
export function homeworldOccupiedIncomeOffer(
|
||||
state: HomeworldOccupiedIncomeState,
|
||||
game: StableHomeworldOccupationContext,
|
||||
actor: string,
|
||||
): HomeworldOccupiedIncomeOffer | null {
|
||||
validateHomeworldOccupiedIncomeState(state, game, game.turn);
|
||||
const row = state.queue[state.cursor];
|
||||
if (!row || row.occupier !== actor) return null;
|
||||
return { event: state.event, turn: state.turn, world: row.world, card: row.card,
|
||||
name: homeworldCard(row.card)!.name, owner: row.occupier,
|
||||
ownerName: game.players.find(player => player.id === row.occupier)!.name,
|
||||
ally: row.ally, allyName: row.ally ? game.players.find(player => player.id === row.ally)!.name : null,
|
||||
amount: row.spice, minOwnAmount: row.ally ? 0 : row.spice, maxOwnAmount: row.spice,
|
||||
blocked: currentBlock(row, game) };
|
||||
}
|
||||
|
||||
export function quoteHomeworldOccupiedIncomeChoice(
|
||||
state: HomeworldOccupiedIncomeState,
|
||||
game: StableHomeworldOccupationContext,
|
||||
actor: string,
|
||||
event: string,
|
||||
world: string,
|
||||
ownAmount: number,
|
||||
): { state: HomeworldOccupiedIncomeState; credits: HomeworldOccupiedIncomeCredit[] } {
|
||||
validateHomeworldOccupiedIncomeState(state, game, game.turn);
|
||||
const row = state.queue[state.cursor];
|
||||
requireIncome(row && event === state.event && world === row.world && actor === row.occupier,
|
||||
'wrong owner, stale event/world, or already settled receipt.');
|
||||
const blocked = currentBlock(row, game);
|
||||
requireIncome(!blocked, blocked ?? 'occupied entitlement changed.');
|
||||
requireIncome(whole(ownAmount) && ownAmount <= row.spice && (row.ally !== null || ownAmount === row.spice),
|
||||
'keep an integer from zero through the printed award; without an ally keep the full award.');
|
||||
const credits = allocation(row, ownAmount);
|
||||
const next: HomeworldOccupiedIncomeState = { ...state, cursor: state.cursor + 1,
|
||||
receipts: state.receipts.map((receipt, index) => index === state.cursor
|
||||
? { world: row.world, status: 'settled', ownAmount, credits: credits.map(credit => ({ ...credit })) }
|
||||
: receipt.status === 'settled' ? { ...receipt, credits: receipt.credits.map(credit => ({ ...credit })) } : { ...receipt }),
|
||||
signature: '' };
|
||||
next.signature = signature(next);
|
||||
return { state: next, credits };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Game } from './engine';
|
||||
import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldForceGroups, HomeworldCustodyError } from './homeworld-custody';
|
||||
import { validateHomeworldOccupationHistory } from './homeworld-occupation-history';
|
||||
|
||||
export type StableHomeworldOccupationContext = Pick<Game,
|
||||
'advanced' | 'turn' | 'players' | 'homeworlds' | 'homeworldOccupationHistory' | 'homeworldOccupationPreview'>;
|
||||
export type StableHomeworldOccupier = {
|
||||
world: string;
|
||||
card: HomeworldId;
|
||||
native: string;
|
||||
occupier: string;
|
||||
ally: string | null;
|
||||
qualification: string;
|
||||
turn: number;
|
||||
spice: number;
|
||||
};
|
||||
export type StableHomeworldOccupationQuote = {
|
||||
entitlement: StableHomeworldOccupier | null;
|
||||
blocked: string | null;
|
||||
};
|
||||
type SourceWorld = [string, [string, number, number][]];
|
||||
|
||||
/** Printed qualification is not a current-controller shortcut. This fresh
|
||||
* profile admits only one continuously present qualifier, with no competing
|
||||
* foreign garrison or restored native high threshold since qualification.
|
||||
* Departure/turnover/contested/repopulation questions remain unanswered.
|
||||
* Sources: HOMEWORLD_OCCUPATION_RULES7–33,35–56; original occupied card faces. */
|
||||
export function quoteStableHomeworldOccupation(
|
||||
game: StableHomeworldOccupationContext,
|
||||
cardId: HomeworldId,
|
||||
): StableHomeworldOccupationQuote {
|
||||
const none: StableHomeworldOccupationQuote = { entitlement: null, blocked: null };
|
||||
if (!game.homeworldOccupationPreview) return none;
|
||||
const custody = game.homeworlds?.custody, history = game.homeworldOccupationHistory;
|
||||
if (!custody || !history || game.homeworlds?.historyVersion !== 1)
|
||||
return { entitlement: null, blocked: 'Occupation benefits require the original fresh Homeworld qualification history.' };
|
||||
const context = homeworldContext(game);
|
||||
validateHomeworldOccupationHistory(history, context, game.turn);
|
||||
const card = HOMEWORLD_CARDS.find(c => c.id === cardId);
|
||||
if (!card) throw new HomeworldCustodyError('Unknown occupied Homeworld card.');
|
||||
const native = game.players.find(p => p.faction === card.faction);
|
||||
if (!native || cardId === 'salusa_secundus' && !game.advanced) return none;
|
||||
const world = homeworldForceGroups(context, custody).find(home => home.native === native.id &&
|
||||
home.secondary === (cardId === 'salusa_secundus'));
|
||||
if (!world) throw new HomeworldCustodyError('The occupied card lost its original native Homeworld.');
|
||||
const first = history.qualifications.find(f => f.world === world.id);
|
||||
if (!first) return none;
|
||||
const blocked = (detail: string): StableHomeworldOccupationQuote => ({ entitlement: null,
|
||||
blocked: `${card.name} occupied benefits await the pending ${detail} ruling.` });
|
||||
let qualifiedThisTurn = false;
|
||||
for (const fact of history.qualifications) {
|
||||
if (fact.world !== world.id) continue;
|
||||
if (fact.player !== first.player) return blocked('competing-occupier');
|
||||
if (fact.turn === game.turn) qualifiedThisTurn = true;
|
||||
}
|
||||
if (!qualifiedThisTurn) return blocked('entitlement/expiry');
|
||||
const start = history.sources.findIndex(source => source.event === first.event);
|
||||
if (start < 0) throw new HomeworldCustodyError('The occupier lost its original qualifying source.');
|
||||
const check = (groups: readonly [string, number, number][]): string | null => {
|
||||
let present = false;
|
||||
for (const [id, normal, elite] of groups) {
|
||||
const amount = normal + elite;
|
||||
if (id === native.id) {
|
||||
const population = card.reserveType === 'sardaukar' ? elite : amount;
|
||||
if (population >= card.high.reserves.min) return 'native-repopulation';
|
||||
} else if (amount > 0) {
|
||||
if (id !== first.player) return 'contested-occupation';
|
||||
present = true;
|
||||
}
|
||||
}
|
||||
return present ? null : 'departure/expiry';
|
||||
};
|
||||
let previous = -1;
|
||||
for (let i = start; i < history.sources.length; i++) {
|
||||
const index = history.sources[i].snapshot;
|
||||
if (index === previous) continue;
|
||||
previous = index;
|
||||
const snapshot = JSON.parse(history.snapshots[index]) as SourceWorld[];
|
||||
const row = snapshot.find(source => source[0] === world.id)!;
|
||||
const reason = check(row[1]);
|
||||
if (reason) return blocked(reason);
|
||||
}
|
||||
// Actions can query an effect after a physical change but before its semantic
|
||||
// observation. Never let the last recorded snapshot substitute for now.
|
||||
const current = Object.entries(world.forces).map(([id, forces]): [string, number, number] =>
|
||||
[id, forces.normal, forces.elite]);
|
||||
const reason = check(current);
|
||||
if (reason) return blocked(reason);
|
||||
const occupier = game.players.find(p => p.id === first.player)!;
|
||||
const ally = occupier.ally && game.players.some(p => p.id === occupier.ally && p.ally === occupier.id)
|
||||
? occupier.ally : null;
|
||||
return { entitlement: { world: world.id, card: cardId, native: native.id, occupier: occupier.id,
|
||||
ally, qualification: first.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null };
|
||||
}
|
||||
+4
-1
@@ -3846,6 +3846,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'When several Terror tokens share a stronghold, Moritani privately selects one for an eligible entry, then chooses whether to reveal it or offer an alliance. The other tokens remain hidden. The board shows the public stack count; refresh preserves the selected token and original arrival.',
|
||||
'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.',
|
||||
'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.',
|
||||
'The fresh local homeworld-occupation profile now connects continuously proved, unambiguous occupation with original selected decks and no other modules. Qualification history must prove one still-present occupier without departure, competing foreigners or restored native high population; a current garrison alone grants nothing. Ambiguous histories retain the source guards, not a new expiry or replacement policy.',
|
||||
'In that profile, printed occupied bank income is collected once and may immediately be allocated to a reciprocal ally through the original Collection choice. Actual credit receipts survive refresh; views and replay cannot pay again. Actual shared Homeworld credits feed the existing once-per-phase Giedi bonus. Percentage receipts remain a separate unfinished source.',
|
||||
'Proved Wallach IX occupation protects the occupier and ally from native Voice; Tleilax prevents native Face Dancers against them; Grumman prevents revealing Terror on their entry. Occupied Salusa removes Sardaukar advantage without changing starred physical identity. These printed effects cannot be restored or canceled with Karama. Other occupied powers and full Homeworld acceptance remain open.',
|
||||
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
|
||||
],
|
||||
example:
|
||||
@@ -3863,7 +3866,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Concealed transport, occupation and remaining card effects are unfinished; game starts stay disabled.',
|
||||
'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh stable occupation additionally connects printed bank Collection/immediate ally allocation, actual Giedi Homeworld sources and Wallach/Tleilax/Grumman/Salusa protections. Departure/replacement/contested/repopulation, percentage receipts, other occupied powers and concealed transport remain unfinished; public starts stay disabled.',
|
||||
evidence: [
|
||||
'game/homeworld-cards.ts',
|
||||
'game/homeworld-game.ts',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
||||
// are envelope checks, not a duplicate implementation of each future choice.
|
||||
const DECISIONS = {
|
||||
ecazBattleLead: true,
|
||||
homeworldOccupiedIncome: true,
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
moritaniAssassinate: true,
|
||||
|
||||
Reference in new issue
Block a user