Compose native Richese No-Field and Stone with Advanced Occupy
This commit is contained in:
1 parent
03c77604de
commit
113667da55
23 files changed
+985
-246
No files matched your search
@@ -444,6 +444,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
const tie = battleTieOwner(a.id, d.id, input.aggressor ?? a.id,
|
||||
[a, d].filter(side => side.stronghold === 'habbanya_ridge_sietch').map(side => side.id)) === a.id
|
||||
? 'attacker' : 'defender';
|
||||
const stoneOccupy = hasStone && input.ecazOccupy ? {
|
||||
fixedEcazDial: input.ecazOccupy.fixedEcazDial,
|
||||
ecazUndialed: input.ecazOccupy.canceled ? 0 : Math.floor(input.ecazOccupy.ecazForces.normal / 2),
|
||||
} : undefined;
|
||||
const stone = hasStone
|
||||
? stoneBurnerComparison(
|
||||
a.forces,
|
||||
@@ -453,6 +457,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
d.plan.dial,
|
||||
d.plan.support,
|
||||
tie,
|
||||
input.ecazOccupy ? {
|
||||
attacker: input.ecazOccupy.lead === a.id ? stoneOccupy : undefined,
|
||||
defender: input.ecazOccupy.lead === d.id ? stoneOccupy : undefined,
|
||||
} : undefined,
|
||||
)
|
||||
: null;
|
||||
if (stone) {
|
||||
|
||||
@@ -963,6 +963,7 @@ function plans(g: GameView): Action[] {
|
||||
pool,
|
||||
b.attacker === me.id ? 'attacker' : 'defender',
|
||||
b.tieWinner === b.attacker ? 'attacker' : 'defender',
|
||||
b.stoneBurnerContext?.occupy,
|
||||
),
|
||||
))
|
||||
)
|
||||
|
||||
@@ -143,7 +143,8 @@ function calculate(
|
||||
const profile = quoteEcazOccupyBattle({
|
||||
battleOrderActor: occupy.battleOrderActor,
|
||||
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: occupy.ecazForces },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces,
|
||||
...(occupy.allyNoFieldPresence ? { noFieldPresence: true as const } : {}) },
|
||||
lead: occupy.lead, canceled: occupy.canceled,
|
||||
});
|
||||
requireCombat(profile.forceOwner === occupy.forceOwner, 'Occupy lost its actual physical force owner.');
|
||||
|
||||
@@ -22,6 +22,9 @@ export type EcazOccupyBattleSeat = Readonly<{
|
||||
faction: FactionId;
|
||||
ally?: string | null;
|
||||
forces: CombatForces;
|
||||
/** Original concealed Richese marker: one battle presence even when the
|
||||
* reserve-limited prospective pool is zero. Never ordinary zero fighters. */
|
||||
noFieldPresence?: true;
|
||||
}>;
|
||||
export type EcazOccupyBattleInput = Readonly<{
|
||||
/** Original battle chooser, before Ecaz selects the coalition's plan actor. */
|
||||
@@ -42,6 +45,7 @@ export type EcazOccupyBattleProfile = Readonly<{
|
||||
canceled: boolean;
|
||||
ecazForces: CombatForces;
|
||||
allyForces: CombatForces;
|
||||
allyNoFieldPresence?: true;
|
||||
/** Only the variable dial's physical counters, never a merged army. */
|
||||
forces: CombatForces;
|
||||
fixedEcazDial: number;
|
||||
@@ -103,10 +107,16 @@ export function quoteEcazOccupyBattle(
|
||||
validCombatForces(ecaz.forces) && validCombatForces(ally.forces),
|
||||
'An Occupy battle needs valid native physical fighter pools.',
|
||||
);
|
||||
requireBattle(
|
||||
ecaz.noFieldPresence === undefined &&
|
||||
(ally.noFieldPresence === undefined ||
|
||||
ally.noFieldPresence === true && ally.faction === 'richese'),
|
||||
'Only an original concealed Richese ally may supply No-Field battle presence.',
|
||||
);
|
||||
requireBattle(
|
||||
ecaz.forces.elite === 0 && !ecaz.forces.temporaryElite &&
|
||||
!ecaz.forces.normalFixedHalf && ecaz.forces.normal > 0 &&
|
||||
ally.forces.normal + ally.forces.elite > 0,
|
||||
(ally.forces.normal + ally.forces.elite > 0 || ally.noFieldPresence === true),
|
||||
'Both Occupy members need storm-connected fighters; Ecaz uses ordinary counters, not advisors or an elite pool.',
|
||||
);
|
||||
requireBattle(
|
||||
@@ -135,6 +145,7 @@ export function quoteEcazOccupyBattle(
|
||||
canceled,
|
||||
ecazForces: ecaz.forces,
|
||||
allyForces: ally.forces,
|
||||
...(ally.noFieldPresence ? { allyNoFieldPresence: true as const } : {}),
|
||||
forces,
|
||||
fixedEcazDial,
|
||||
maxDial: fixedEcazDial + maxCombatDial(forces),
|
||||
|
||||
+72
-41
@@ -357,7 +357,7 @@ import {
|
||||
auditCount,
|
||||
sampleAuditCards,
|
||||
} from './choam-auditor';
|
||||
import { stoneBurnerPlanBlock } from './stone-burner';
|
||||
import { stoneBurnerPlanBlock, type StoneBurnerContext } from './stone-burner';
|
||||
import {
|
||||
STRONGHOLD_CARDS,
|
||||
createStrongholdCards,
|
||||
@@ -3598,7 +3598,7 @@ function nexusAtreidesOffer(g: Game, id: string) {
|
||||
const target = b && (b.attacker === id ? b.defender : b.attacker);
|
||||
const fields = mode === 'betrayal' ? [] : (['leader', 'weapon', 'defense', 'dial'] as PlanField[])
|
||||
.filter(field => !(mode === 'cunning' && (field === b?.prescience?.field ||
|
||||
(field === 'dial' && target && b?.noFieldPlayers?.includes(target)))));
|
||||
(field === 'dial' && target && battleNoFieldPlan(g, target)))));
|
||||
return { event: b?.event ?? '', mode, fields, blocked };
|
||||
}
|
||||
function finishInspectionAnswers(g: Game) {
|
||||
@@ -7307,6 +7307,29 @@ function combatForces(
|
||||
(p.faction === 'fremen' && !g.battle?.fremenSupportBlocked),
|
||||
};
|
||||
}
|
||||
function occupyArmiesConnected(g: Game, ecaz: Player, ally: Player, t: string): boolean {
|
||||
const marker = ally.noField?.deployed;
|
||||
for (const x in ecaz.forces) {
|
||||
if (ecaz.forces[x] <= 0 || splitLocation(x).territory !== t) continue;
|
||||
for (const y in ally.forces)
|
||||
if (ally.forces[y] > 0 && splitLocation(y).territory === t &&
|
||||
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0) return true;
|
||||
if (marker?.location.territory === t &&
|
||||
gameDistance(g, x, location(t, marker.location.sector), key => splitLocation(key).sector === g.storm) === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Inspection follows the selected plan actor; reveal still belongs to the physical marker owner. */
|
||||
function battleNoFieldPlan(g: Game, id: string): boolean {
|
||||
const b = g.battle, occupy = b?.ecazOccupy;
|
||||
const owner = occupy?.lead === id ? occupy.canceled ? id : occupy.ally : id;
|
||||
return !!b?.noFieldPlayers?.includes(owner);
|
||||
}
|
||||
function battleConcealedForces(g: Game, id: string): boolean {
|
||||
const b = g.battle, occupy = b?.ecazOccupy;
|
||||
const owner = occupy?.lead === id ? occupy.canceled ? id : occupy.ally : id;
|
||||
return !!b && getPlayer(g, owner).noField?.deployed?.location.territory === b.territory;
|
||||
}
|
||||
function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
|
||||
const b = g.battle, occupy = b?.ecazOccupy;
|
||||
if (!b || !occupy?.lead) return null;
|
||||
@@ -7315,15 +7338,17 @@ function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
|
||||
requireRule(g.advanced && occupy.event === b.event && ecaz.faction === 'ecaz' &&
|
||||
ecaz.ally === ally.id && ally.ally === ecaz.id && [b.attacker, b.defender].includes(occupy.lead) &&
|
||||
!isAdvisor(ecaz, b.territory) && !isAdvisor(ally, b.territory) &&
|
||||
Object.entries(ecaz.forces).some(([x, n]) => n > 0 && splitLocation(x).territory === b.territory &&
|
||||
Object.entries(ally.forces).some(([y, m]) => m > 0 && splitLocation(y).territory === b.territory &&
|
||||
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0)),
|
||||
(occupyArmiesConnected(g, ecaz, ally, b.territory) ||
|
||||
(b.revealed && b.noFieldPlayers?.includes(ally.id) && fighterCount(ecaz, b.territory) > 0 &&
|
||||
fighterCount(ally, b.territory) === 0)),
|
||||
'Occupy needs the original battle and physically connected allied fighters.');
|
||||
const opponent = getPlayer(g, b.attacker === occupy.lead ? b.defender : b.attacker);
|
||||
return nexusRule(() => quoteEcazOccupyBattle({
|
||||
battleOrderActor: b.chooser ?? b.attacker,
|
||||
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: combatForces(g, ecaz, b.territory, opponent) },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: combatForces(g, ally, b.territory, opponent) },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally,
|
||||
...(b.noFieldPlayers?.includes(ally.id) ? { noFieldPresence: true as const } : {}),
|
||||
forces: combatForces(g, occupy.canceled && lead === ecaz.id ? { ...ally, noField: undefined } : ally, b.territory, opponent) },
|
||||
lead, canceled: occupy.canceled,
|
||||
}));
|
||||
}
|
||||
@@ -9158,7 +9183,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.players.some(p => p.faction === 'ecaz') &&
|
||||
g.players.every(p => faction(p.faction).expansion === 'base' ||
|
||||
(['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam'].includes(p.faction) &&
|
||||
(['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'].includes(p.faction) &&
|
||||
g.expansions.includes(faction(p.faction).expansion))) &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.strongholdCards &&
|
||||
!g.techTokens && !g.discoveryEnabled && !g.discoveries &&
|
||||
@@ -9168,7 +9193,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
|
||||
/** Fresh native Advanced Occupy profile; public starts and Basic are unchanged. */
|
||||
export function initializeEcazOccupyGameForAudit(state: Game): Game {
|
||||
requireRule(ecazOccupyCompositionSupported(state),
|
||||
'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM factions and their distinct selected family decks, without Richese or optional overlays.');
|
||||
'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM/Richese factions and their distinct selected family decks, without optional overlays.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
|
||||
@@ -18432,14 +18457,11 @@ function stonePublicPools(
|
||||
p: Player,
|
||||
opponent: Player,
|
||||
): CombatForces[] {
|
||||
const t = g.battle!.territory;
|
||||
const hiddenMarker = p.noField?.deployed?.location.territory === t;
|
||||
const pool = combatForces(
|
||||
g,
|
||||
hiddenMarker ? { ...p, noField: undefined } : p,
|
||||
t,
|
||||
opponent,
|
||||
);
|
||||
const b = g.battle!, t = b.territory, occupy = b.ecazOccupy;
|
||||
const owner = occupy?.lead === p.id && !occupy.canceled ? getPlayer(g, occupy.ally) : p;
|
||||
const enemy = occupy?.lead === opponent.id && !occupy.canceled ? getPlayer(g, occupy.ally) : opponent;
|
||||
const hiddenMarker = owner.noField?.deployed?.location.territory === t;
|
||||
const pool = combatForces(g, hiddenMarker ? { ...owner, noField: undefined } : owner, t, enemy);
|
||||
return hiddenMarker
|
||||
? Array.from({ length: 6 }, (_, n) => ({
|
||||
...pool,
|
||||
@@ -18452,6 +18474,12 @@ function stoneTimingBlock(g: Game): string | null {
|
||||
? 'Stone Burner with the Ix expansion is awaiting a ruling on its simultaneous revealed choice with Poison Tooth. This implementation boundary applies before either hidden plan is submitted.'
|
||||
: null;
|
||||
}
|
||||
function stoneOccupyContext(g: Game): StoneBurnerContext | undefined {
|
||||
const b = g.battle!, profile = ecazBattleProfile(g);
|
||||
if (!profile || profile.canceled) return undefined;
|
||||
const side = { fixedEcazDial: profile.fixedEcazDial, ecazUndialed: Math.floor(profile.ecazForces.normal / 2) };
|
||||
return profile.lead === b.attacker ? { attacker: side } : { defender: side };
|
||||
}
|
||||
function stonePlanBlock(
|
||||
g: Game,
|
||||
p: Player,
|
||||
@@ -18460,17 +18488,19 @@ function stonePlanBlock(
|
||||
): string | null {
|
||||
const b = g.battle!;
|
||||
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
const context = stoneOccupyContext(g);
|
||||
return (
|
||||
stoneTimingBlock(g) ??
|
||||
stonePublicPools(g, opponent, p)
|
||||
.map((pool) =>
|
||||
stoneBurnerPlanBlock(
|
||||
combatForces(g, p, b.territory, opponent),
|
||||
planCombatForces(g, p, opponent),
|
||||
dial,
|
||||
support,
|
||||
pool,
|
||||
b.attacker === p.id ? 'attacker' : 'defender',
|
||||
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
|
||||
context,
|
||||
),
|
||||
)
|
||||
.find(Boolean) ??
|
||||
@@ -18486,13 +18516,15 @@ function stoneCompulsionBlock(g: Game, target: Player): string | null {
|
||||
const timing = stoneTimingBlock(g);
|
||||
if (timing) return timing;
|
||||
const opponents = stonePublicPools(g, other, target);
|
||||
const context = stoneOccupyContext(g);
|
||||
const fixedDial = (b.attacker === target.id ? context?.attacker : context?.defender)?.fixedEcazDial ?? 0;
|
||||
for (const own of stonePublicPools(g, target, other)) {
|
||||
const supported = Array.from(
|
||||
{ length: maxCombatDial(own) * 2 + 1 },
|
||||
(_, n) => n / 2,
|
||||
(_, n) => fixedDial + n / 2,
|
||||
).some(
|
||||
(dial) =>
|
||||
casualtyOptions(own, dial, 0).length &&
|
||||
casualtyOptions(own, dial - fixedDial, 0).length &&
|
||||
opponents.every(
|
||||
(opponent) =>
|
||||
!stoneBurnerPlanBlock(
|
||||
@@ -18502,6 +18534,7 @@ function stoneCompulsionBlock(g: Game, target: Player): string | null {
|
||||
opponent,
|
||||
b.attacker === target.id ? 'attacker' : 'defender',
|
||||
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
|
||||
context,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -25570,7 +25603,7 @@ export function prepareSpecialKaramaIntent(
|
||||
'Choose a player in this battle.',
|
||||
);
|
||||
requireRule(
|
||||
!b.noFieldPlayers?.includes(target),
|
||||
!battleNoFieldPlan(g, target),
|
||||
'Whole-plan special prescience against a No-Field awaits its disclosure ruling. Ordinary permitted elements remain available.',
|
||||
);
|
||||
return {
|
||||
@@ -29984,11 +30017,14 @@ function applyActionInner(
|
||||
requireRule(choice, 'Choose one of your unresolved battles.');
|
||||
g.auditorInsight = null;
|
||||
retireNexusChoamInsight(g);
|
||||
const noFieldPlayers = [choice.attacker, choice.defender].filter(
|
||||
(playerId) =>
|
||||
getPlayer(g, playerId).noField?.deployed?.location.territory ===
|
||||
choice.territory,
|
||||
);
|
||||
const ecaz = byFaction(g, 'ecaz');
|
||||
const ally = ecaz?.ally ? getPlayer(g, ecaz.ally) : null;
|
||||
const connected = ecaz && ally && occupyArmiesConnected(g, ecaz, ally, choice.territory);
|
||||
const noFieldPlayers = g.players.filter(player =>
|
||||
player.noField?.deployed?.location.territory === choice.territory &&
|
||||
(player.id === choice.attacker || player.id === choice.defender ||
|
||||
(g.advanced && connected && ecaz && ally?.ally === ecaz.id && player.id === ally.id &&
|
||||
(choice.attacker === ecaz.id || choice.defender === ecaz.id)))).map(player => player.id);
|
||||
for (const playerId of noFieldPlayers)
|
||||
requireRule(
|
||||
Object.entries(getPlayer(g, playerId).forces).every(
|
||||
@@ -30015,18 +30051,10 @@ function applyActionInner(
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
const ecaz = byFaction(g, 'ecaz');
|
||||
const ally = ecaz?.ally ? getPlayer(g, ecaz.ally) : null;
|
||||
const connected = ecaz && ally && Object.entries(ecaz.forces).some(([x, n]) =>
|
||||
n > 0 && splitLocation(x).territory === choice.territory && Object.entries(ally.forces).some(([y, m]) =>
|
||||
m > 0 && splitLocation(y).territory === choice.territory &&
|
||||
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0));
|
||||
if (g.advanced && ecaz && ally?.ally === ecaz.id && connected &&
|
||||
!isAdvisor(ally, choice.territory) && [choice.attacker, choice.defender].some(seat => seat === ecaz.id || seat === ally.id)) {
|
||||
requireRule(ecazOccupyCompositionSupported(g) &&
|
||||
(g.ecazOccupyPreview === true || g.players.every(player =>
|
||||
faction(player.faction).expansion === 'base' || ['ecaz', 'moritani'].includes(player.faction))),
|
||||
'Advanced combined Occupy supports source-selected native factions only in the fresh Occupy profile; Richese/No-Field and optional overlays remain gated.');
|
||||
requireRule(ecazOccupyCompositionSupported(g),
|
||||
'Advanced combined Occupy requires source-selected native factions; optional overlays remain gated.');
|
||||
g.battle.ecazOccupy = { event: battleEvent, ecaz: ecaz.id, ally: ally.id, lead: null, canceled: false };
|
||||
g.decision = { kind: 'ecazBattleLead', player: ecaz.id, event: battleEvent, choices: [ecaz.id, ally.id] };
|
||||
} else {
|
||||
@@ -30067,7 +30095,7 @@ function applyActionInner(
|
||||
const beneficiary = b.preparation.beneficiary;
|
||||
const target = beneficiary === b.attacker ? b.defender : b.attacker;
|
||||
requireRule(
|
||||
action.field !== 'dial' || !b.noFieldPlayers?.includes(target),
|
||||
action.field !== 'dial' || !battleNoFieldPlan(g, target),
|
||||
'Atreides may not inspect the number dialed in a No-Field battle.',
|
||||
);
|
||||
b.prescience = { player: beneficiary, field: action.field as PlanField };
|
||||
@@ -30085,7 +30113,7 @@ function applyActionInner(
|
||||
b.preparation.owner === id && record?.stage === 'answer' && record.target === id &&
|
||||
action.event === record.event && Object.keys(action).sort().join(',') === 'event,type,value',
|
||||
'This Nexus answer does not match your current inspection.');
|
||||
requireRule(record.mode !== 'cunning' || record.field !== 'dial' || !b.noFieldPlayers?.includes(id),
|
||||
requireRule(record.mode !== 'cunning' || record.field !== 'dial' || !battleNoFieldPlan(g, id),
|
||||
'Atreides may not inspect the number dialed in a No-Field battle.');
|
||||
const value = record.field === 'dial'
|
||||
? typeof action.value === 'number' ? action.value : NaN
|
||||
@@ -30107,7 +30135,7 @@ function applyActionInner(
|
||||
);
|
||||
const field = b.prescience.field;
|
||||
requireRule(
|
||||
field !== 'dial' || !b.noFieldPlayers?.includes(id),
|
||||
field !== 'dial' || !battleNoFieldPlan(g, id),
|
||||
'Atreides may not inspect the number dialed in a No-Field battle.',
|
||||
);
|
||||
const value =
|
||||
@@ -31453,11 +31481,13 @@ export function viewGame(state: Game, id: string) {
|
||||
battle: b
|
||||
? {
|
||||
locationName: combatLocationName(g, b.territory),
|
||||
ecazOccupy: b.ecazOccupy ? { ...b.ecazOccupy, profile: ecazBattleProfile(g) } : null,
|
||||
ecazOccupy: b.ecazOccupy ? { ...b.ecazOccupy,
|
||||
profile: !b.revealed && b.ecazOccupy.lead && battleConcealedForces(g, b.ecazOccupy.lead) &&
|
||||
id !== b.ecazOccupy.lead ? null : ecazBattleProfile(g) } : null,
|
||||
native: homeworldBattleLocation(g, b.territory)?.native ?? null,
|
||||
nativeBattleStrength: homeworldBattleLocation(g, b.territory)?.nativeBattleStrength ?? 0,
|
||||
opponentForces: [b.attacker, b.defender].includes(id) &&
|
||||
!getPlayer(g, b.attacker === id ? b.defender : b.attacker).noField?.deployed
|
||||
!battleConcealedForces(g, b.attacker === id ? b.defender : b.attacker)
|
||||
? planCombatForces(g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), me)
|
||||
: null,
|
||||
strongholdCopy: b.strongholdCopy ?? null,
|
||||
@@ -31468,7 +31498,7 @@ export function viewGame(state: Game, id: string) {
|
||||
]),
|
||||
) as Record<string, StrongholdId | null>,
|
||||
tieWinner: battleTieWinner(g),
|
||||
noFieldPlayers: b.noFieldPlayers ?? [],
|
||||
noFieldPlayers: [b.attacker, b.defender].filter(player => battleNoFieldPlan(g, player)),
|
||||
ownForces: [b.attacker, b.defender].includes(id)
|
||||
? planCombatForces(g, me, getPlayer(g, b.attacker === id ? b.defender : b.attacker))
|
||||
: null,
|
||||
@@ -31489,6 +31519,7 @@ export function viewGame(state: Game, id: string) {
|
||||
stoneBurnerContext: [b.attacker, b.defender].includes(id)
|
||||
? {
|
||||
blocked: stoneTimingBlock(g),
|
||||
occupy: stoneOccupyContext(g),
|
||||
opponentPools: stonePublicPools(
|
||||
g,
|
||||
getPlayer(g, b.attacker === id ? b.defender : b.attacker),
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { FactionId } from './catalog';
|
||||
|
||||
const supported = new Set<FactionId>([
|
||||
'atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen',
|
||||
'choam', 'richese',
|
||||
'choam', 'richese', 'ecaz', 'moritani',
|
||||
]);
|
||||
|
||||
/** Public configuration only: never inspect an opponent's hand or sealed plan. */
|
||||
export function mirrorWeaponModeBlock(g: {
|
||||
expansions: readonly string[];
|
||||
advanced?: boolean;
|
||||
players: readonly { faction: FactionId }[];
|
||||
homeworlds?: unknown;
|
||||
nexusCards?: unknown;
|
||||
@@ -15,9 +16,11 @@ export function mirrorWeaponModeBlock(g: {
|
||||
discoveries?: unknown;
|
||||
sandtrout?: boolean;
|
||||
}): string | null {
|
||||
if (g.expansions.length !== 1 || g.expansions[0] !== 'choam' ||
|
||||
if (!g.expansions.includes('choam') ||
|
||||
g.expansions.some(id => id !== 'choam' && id !== 'ecaz') ||
|
||||
(g.expansions.includes('ecaz') && g.advanced !== true) ||
|
||||
g.players.some((p) => !supported.has(p.faction)))
|
||||
return 'Mirror Weapon currently needs the CHOAM/Richese expansion with classic or CHOAM/Richese factions.';
|
||||
return 'Mirror Weapon currently needs CHOAM/Richese with classic or native Ecaz/Moritani factions; Ix timing remains unresolved.';
|
||||
if (g.homeworlds || g.nexusCards || g.leaderSkills || g.discoveries || g.sandtrout)
|
||||
return 'Mirror Weapon with combined optional modules is still being integrated.';
|
||||
return null;
|
||||
|
||||
+5
-3
@@ -1265,16 +1265,18 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Eligible Karama can prevent Occupy after the lead choice and before powers/plans. The chosen lead remains lead and dials its own army; the other ally contributes zero. The plan editor then uses the new force pool and no fixed Ecaz strength.',
|
||||
'An Ixian variable army takes its own typed losses and chooses native surviving-Suboid substitution, including when Ecaz formed the plan. Actual Ixians own the exchange/counter; the selected lead still owns winner cards and continuation. CHOAM support income uses original eligible payer/donor shares, not the free fixed contribution.',
|
||||
'A Tleilaxu winning co-side cannot Face Dance itself. External Face Dance returns both actual winning allied armies with a combined survivor maximum, while the selected winner keeps original rewards and card cleanup. Reserve/board zero/partial/full replacements remain native choices, not a copied player.',
|
||||
'A native Richese ally may supply ordinary counters or its concealed No-Field alone. A zero or reserve-limited marker remains battle presence; the original Richese owner materializes it only after both plans seal. The selected lead receives the private variable pool needed for its plan; rivals do not. Dial prescience and whole-plan restrictions follow the selected plan’s actual force owner, while cancellation to Ecaz’s own pool removes that concealed-dial restriction. Richese’s own ordinary-plus-marker dialing remains guarded.',
|
||||
'Stone Burner compares physical undialed counters, not total dial or paid strength: active Occupy contributes floor(Ecaz forces / 2) plus the ally’s native undialed tokens. Canceled Occupy compares only the selected lead’s own pool. Physical Mirror copies use the same comparison and retain their original cleanup. Hidden opposing No-Field preflight uses public possible pools, not its actual denomination; genuinely winner-changing typed allocations remain blocked.',
|
||||
'With the independent variant, Harass returns only the actual card user’s undialed forces. An Ecaz user keeps the original mandatory ceil contribution committed at free full strength while its floor count returns; ally support is separate. An allied user subtracts fixed strength before choosing its native typed commitment. Canceled Occupy uses its selected lead’s own pool. Returned reserves are outside later casualties/explosion/Face Dance.',
|
||||
'Reinforcements consumes three card-user reserves normal-first and adds +2 score without extra support or on-board arrivals. Its existing provisional cost/disposal on every revealed outcome is unchanged, not a new publisher ruling. Own same-plan Harass/Reinforcements and Stone remain guarded; Basic/unprofiled co-side cards remain unsupported.',
|
||||
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic and the specific Ecaz-only Duke/Ghola decision remain. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM two-to-six-seat Advanced with required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both. Explicit --ecaz-treachery independently adds all three cards for36/50/38; Richese mixed planning and other overlays remain open. Full factions/public starts stay gated.',
|
||||
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic and the specific Ecaz-only Duke/Ghola decision remain. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM/Richese two-to-six-seat Advanced with required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both; Richese retains its separate ten-card cache. Explicit --ecaz-treachery independently adds all three cards for36/50/38. Own mixed No-Field dialing, whole-plan disclosure, Ix Stone/Tooth timing, Harass/Stone and optional overlays remain open. Full factions/public starts stay gated.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, explicit lead choice, native Occupy response and owner-labelled physical dial/outcome integration are implemented. Optional/exotic compositions remain open.', evidence: ['game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original human390px rooms exercise mandatory/free total dials, native Karama own-army redial, physical Ix two-Suboid/two-Cyborg exchange after an Ecaz plan, and full three-force FaceDance replacement across both winning armies. Selected lead retains its cards and native Collection continuation.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original 390px controls cover mandatory/free dials, native Karama redial, Ix substitutions and whole-side Face Dance. Richese human T78EFNZK chose Ecaz lead, sealed total5/support2 with its real leader, revealed the ally’s original No-Field5 into only two available reserves, and reached Collection with Ecaz2 and two Richese losses. Other combinations remain open.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four native policies actually seal legal total plans for either lead, active or canceled, using native leaders/cards and physical support. Strategy remains deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic/cancellation, unchanged Basic and specific Duke override are separated from historical source gaps and optional-module gates.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Development Verified: original arithmetic/native force integration, independent-card native/pure67 cases, relevant284-case batch,27 actual programs/four policies and original390px human Harass/Reinforcements. Current46 ordinary/variant2..6 games complete26619 accepted actions/no rejection with nine actual combined lead choices in full results. Prior five/23-game full counters record one/three, correcting compact-summary misreading. Full factions/other combinations and comprehensive assurance remain after all rules.', evidence: ['tests/ecaz-occupy-cards-runtime.test.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/ecaz-occupy-ix-choam-runtime.test.ts', 'tests/ecaz-occupy-tleilaxu-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Development Verified: affected363 rules cases,26 actual native programs including all four legal policies, and original390px Richese/No-Field controls. The previously stalled six-native Advanced game completes2424 accepted actions, no rejections and65JSON continuations. Earlier46 ordinary/variant games and source-specific card evidence remain historical; complete factions/optional combinations and comprehensive assurance follow all rules.', evidence: ['tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
],
|
||||
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
|
||||
},
|
||||
|
||||
+43
-11
@@ -12,6 +12,16 @@ export type StoneBurnerComparison = {
|
||||
attacker: number[];
|
||||
defender: number[];
|
||||
};
|
||||
/** Public fixed contributions only; callers retain their own native force pools. */
|
||||
export type StoneBurnerOccupyContext = {
|
||||
fixedEcazDial: number;
|
||||
ecazUndialed: number;
|
||||
};
|
||||
/** Context belongs to physical battle slots, not the card holder or aggressor. */
|
||||
export type StoneBurnerContext = {
|
||||
attacker?: StoneBurnerOccupyContext;
|
||||
defender?: StoneBurnerOccupyContext;
|
||||
};
|
||||
|
||||
const opposingChoices = new Map<string, readonly (readonly number[])[]>();
|
||||
const MAX_CACHED_POOLS = 128;
|
||||
@@ -22,7 +32,14 @@ function validSide(side: StoneBurnerSide): boolean {
|
||||
function sorted(values: Iterable<number>): number[] {
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
function totals(forces: CombatForces, dial: number, support: number): number[] {
|
||||
function totals(
|
||||
forces: CombatForces,
|
||||
dial: number,
|
||||
support: number,
|
||||
occupy?: StoneBurnerOccupyContext,
|
||||
): number[] {
|
||||
dial -= occupy?.fixedEcazDial ?? 0;
|
||||
const fixedUndialed = occupy?.ecazUndialed ?? 0;
|
||||
if (
|
||||
!validCombatForces(forces) ||
|
||||
!Number.isFinite(dial) ||
|
||||
@@ -35,7 +52,7 @@ function totals(forces: CombatForces, dial: number, support: number): number[] {
|
||||
return [];
|
||||
return sorted(
|
||||
casualtyOptions(forces, dial, support).map(
|
||||
(loss) => forces.normal + forces.elite - loss.normal - loss.elite,
|
||||
(loss) => fixedUndialed + forces.normal + forces.elite - loss.normal - loss.elite,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -68,9 +85,10 @@ export function stoneBurnerComparison(
|
||||
dDial: number,
|
||||
dSupport: number,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
): StoneBurnerComparison {
|
||||
const attacker = totals(a, aDial, aSupport),
|
||||
defender = totals(d, dDial, dSupport);
|
||||
const attacker = totals(a, aDial, aSupport, context?.attacker),
|
||||
defender = totals(d, dDial, dSupport, context?.defender);
|
||||
return {
|
||||
winner: invariantWinner(attacker, defender, aggressor),
|
||||
attacker,
|
||||
@@ -85,7 +103,9 @@ export function stoneBurnerComparison(
|
||||
*/
|
||||
function allOpposingTotals(
|
||||
forces: CombatForces,
|
||||
occupy?: StoneBurnerOccupyContext,
|
||||
): readonly (readonly number[])[] {
|
||||
const fixedUndialed = occupy?.ecazUndialed ?? 0;
|
||||
const key = [
|
||||
forces.normal,
|
||||
forces.elite,
|
||||
@@ -95,6 +115,7 @@ function allOpposingTotals(
|
||||
!!forces.normalFreeSupport,
|
||||
forces.freeSupport,
|
||||
!!forces.eliteFreeSupport,
|
||||
fixedUndialed,
|
||||
].join(':');
|
||||
const cached = opposingChoices.get(key);
|
||||
if (cached) return cached;
|
||||
@@ -106,7 +127,12 @@ function allOpposingTotals(
|
||||
for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
|
||||
for (let support = 0; support <= maximumSupport; support++) {
|
||||
const choices = totals(forces, halfDial / 2, support);
|
||||
if (choices.length) result.push(choices);
|
||||
if (choices.length) {
|
||||
if (fixedUndialed)
|
||||
for (let index = 0; index < choices.length; index++)
|
||||
choices[index] += fixedUndialed;
|
||||
result.push(choices);
|
||||
}
|
||||
}
|
||||
if (opposingChoices.size >= MAX_CACHED_POOLS)
|
||||
opposingChoices.delete(opposingChoices.keys().next().value!);
|
||||
@@ -125,15 +151,18 @@ export function stoneBurnerPlanBlock(
|
||||
opponent: CombatForces,
|
||||
side: StoneBurnerSide,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
const ownOccupy = context?.[side];
|
||||
const opponentOccupy = context?.[side === 'attacker' ? 'defender' : 'attacker'];
|
||||
if (!validCombatForces(own) || !validCombatForces(opponent))
|
||||
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
|
||||
const ownTotals = totals(own, dial, support);
|
||||
const ownTotals = totals(own, dial, support, ownOccupy);
|
||||
if (!ownTotals.length)
|
||||
return 'Your dial and support do not permit a legal Stone Burner force allocation.';
|
||||
for (const otherTotals of allOpposingTotals(opponent)) {
|
||||
for (const otherTotals of allOpposingTotals(opponent, opponentOccupy)) {
|
||||
const winner =
|
||||
side === 'attacker'
|
||||
? invariantWinner(ownTotals, otherTotals, aggressor)
|
||||
@@ -150,16 +179,19 @@ export function stoneBurnerCompulsionBlock(
|
||||
opponent: CombatForces,
|
||||
side: StoneBurnerSide,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
const ownOccupy = context?.[side];
|
||||
if (!validCombatForces(own) || !validCombatForces(opponent))
|
||||
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
|
||||
const maximumStrength = maxCombatDial(own);
|
||||
for (let halfDial = 0; halfDial <= maximumStrength * 2; halfDial++) {
|
||||
if (!totals(own, halfDial / 2, 0).length) continue;
|
||||
const fixedDial = ownOccupy?.fixedEcazDial ?? 0;
|
||||
const maximumStrength = fixedDial + maxCombatDial(own);
|
||||
for (let halfDial = fixedDial * 2; halfDial <= maximumStrength * 2; halfDial++) {
|
||||
if (!totals(own, halfDial / 2, 0, ownOccupy).length) continue;
|
||||
if (
|
||||
stoneBurnerPlanBlock(own, halfDial / 2, 0, opponent, side, aggressor) ===
|
||||
stoneBurnerPlanBlock(own, halfDial / 2, 0, opponent, side, aggressor, context) ===
|
||||
null
|
||||
)
|
||||
return null;
|
||||
|
||||
@@ -84,7 +84,8 @@ function validateContext(context: TraitorDeclarationContext): void {
|
||||
quoteEcazOccupyBattle({
|
||||
battleOrderActor: occupy.battleOrderActor,
|
||||
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: occupy.ecazForces },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces },
|
||||
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces,
|
||||
...(occupy.allyNoFieldPresence ? { noFieldPresence: true as const } : {}) },
|
||||
lead: occupy.lead, canceled: occupy.canceled,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user