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
+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,
|
||||
|
||||
Reference in new issue
Block a user