Complete Advanced Revival suspension and solo win ranking

This commit is contained in:
Quintine committed 2026-10-27 23:15:16 +11:00
1 parent 812657bb35
commit 3c62c284a7
17 files changed
+598 -47

No files matched your search

+7 -5
View File
@@ -7709,8 +7709,8 @@ function treacheryDiscardIntegrity(g: Game) {
charityOrderIntegrity(g);
} else if (continuation?.kind === 'saphoRevivalDiscard') {
const c=continuation,entry=batch.entries[0],frame=g.revivalOrder;
const latest=frame?.version===2 ? frame.uses.at(-1) : null;
requireRule(g.advanced && g.phase===4 && frame?.version===2 &&
const latest=frame && frame.version!==1 ? frame.uses.at(-1) : null;
requireRule(g.advanced && g.phase===4 && frame && frame.version!==1 &&
latest?.event===c.event && latest.player===c.player && latest.mode===c.mode &&
batch.cause==='sapho:revival' && batch.entries.length===1 &&
entry.publicFace && entry.discardedBy===c.player && entry.card.id===c.card &&
@@ -9048,7 +9048,7 @@ function semutaOfferSupported(
continuation.resume.pendingKarama || continuation.resume.phaseOpening ||
g.response || g.decision || g.pendingRevival)) return false;
if (continuation.kind === 'saphoRevivalDiscard' &&
(!g.advanced || g.phase!==4 || g.revivalOrder?.version!==2 ||
(!g.advanced || g.phase!==4 || !g.revivalOrder || g.revivalOrder.version===1 ||
continuation.resume.response || continuation.resume.decision ||
continuation.resume.pendingKarama || continuation.resume.phaseOpening ||
g.response || g.decision || g.pendingRevival)) return false;
@@ -27174,12 +27174,14 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
}
}
if (g.phase===4 && g.advanced && g.revivalOrder) {
if (g.pendingRevival) return options;
const context=revivalOrderContext(g);
const event=revivalOrderRule(()=>saphoRevivalOrderEvent(context,g.revivalOrder!));
for (const mode of ['first','last'] as const) {
try {
const quote=reorderSaphoRevivalOrder(context,g.revivalOrder,{event,player:p.id,mode});
options.push({scope:'revival',event,mode,...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {})});
options.push({scope:'revival',event,mode,...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {}),
...(quote.suspended ? {resumeOwn:true as const} : {})});
} catch(error) {
if (!(error instanceof RevivalOrderError)) throw error;
}
@@ -27389,7 +27391,7 @@ function playSapho(g: Game, p: Player, action: Action) {
const used=discard(g,p,'richese-juice-of-sapho');
g.revivalOrder=quote.order;
g.active=quote.order.remaining[0] ?? null;
log(g,`${p.name} discarded Juice of Sapho to take their remaining Revival turn ${option.mode}.${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the started turn first.` : ''} Completed turns, native allowances and physical First Player order stay unchanged.`,
log(g,`${p.name} discarded Juice of Sapho to take their remaining Revival turn ${option.mode}.${quote.suspended ? ' Their started turn pauses provisionally and resumes last with its used allowances unchanged.' : ''}${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the started turn first.` : ''} Completed turns, native allowances and physical First Player order stay unchanged.`,
{faction:p.faction,name:'Juice of Sapho'});
if (g.semutaPreview) {
const continuation:SaphoRevivalDiscardContinuation={kind:'saphoRevivalDiscard',player:p.id,card:used.id,
+3 -3
View File
@@ -505,7 +505,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.',
'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.',
'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. Each player still claims at most once, completed turns stay closed, and the holder cannot split its own started turn. Physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.',
'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If the current player has already begun a native return or negotiation, provisional queued timing lets that player finish unchanged; first means first among the later unstarted turns. You cannot split your own started turn, repeat a completed turn or consume the card for a position you already hold. Basic and older concurrent Revival phases have no ordered scope.',
'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If another player has started, provisional queued timing lets that player finish unchanged. At a clean boundary, you may instead pause your own started turn and resume last, retaining used allowances. Pending requests must finish first. Completed turns and already-held positions cannot be repeated. Basic and older concurrent Revival phases have no ordered scope.',
'Finish pending shipment, movement and card preparations and decisions before changing order. The exact Extortion payer window is a supported exception for its own order only. Your hand panel lists available choices. Reserved cards and positions you already hold cannot be selected.',
'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.',
'In the explicitly opted-in Semuta preview, clean Once Around, movement and ordered Charity/Revival discards pause after their queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; an aggressor discard pauses after changing ordinary tie priority. Charity/Revival retain their current participant, claims or requests and allowances through Semuta claim/pass. Fresh late continuations also bind revealed choices and the public final revision; old continuation bytes remain unchanged. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unresolved battle continues once. Direct combined late claim/capture proof remains open.',
@@ -4445,7 +4445,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A correct Bene Gesserit faction-and-turn prediction replaces a normal win, including an allied win, with a Bene Gesserit solo win. Guild and Fremen special wins cannot be predicted.',
'On turn ten, a normal stronghold victory comes first. Otherwise, check Fremen’s special condition, then award the game to Guild and its ally if present. If Guild is absent, Fremen and its ally win even when the usual Fremen special condition fails.',
'When neither Guild nor Fremen is playing and nobody has reached the normal target, compare individual qualifying stronghold counts. Every player tied for the highest count wins. A correct Bene Gesserit prediction can replace this stronghold win.',
'With tech tokens enabled, owning all three adds one stronghold. One player must hold all three, even in an alliance. If several sides reach their target at the same check, they share a normal win before any correct prediction replaces it.',
'With tech tokens enabled, owning all three adds one stronghold. One player must hold all three, even in an alliance. In Advanced, when multiple unallied players qualify, the highest stronghold count wins; equal counts provisionally share. A qualifying alliance and other qualifying sides share before a correct prediction replaces the result. The optional unanimous decision to play another turn remains unfinished.',
'Stronghold victory progress shows current territories, a complete Tech Token set, Ecaz joint occupation and Fremen final-turn conditions. Basic permits reciprocal Fremen/Ecaz cooccupation at Tabr; supplied Advanced p32 permits both protected sietches. Solitary/nonallied Ecaz still blocks. Public counts do not award victory before the proper checkpoint; Tech Tokens cannot supply joint occupation.',
'Other expansion additions to victory remain in development. Advanced preview supports selected factions and combinations; complete rules acceptance remains open.',
],
@@ -5484,7 +5484,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
"Fresh Basic Southern multi-star returns have provisional per-star allocation with exact child arrivals, a fixed original grant and one deferred income. 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. Ghola can raise Salusa before full seals, retaining feasible inspections or reopening impossible original fields. Feasible Truthtrance Yes/No/grouped promises remain; self-impossible returns reject with exact force-group control reasons. Full-seal timing remains guarded. 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. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
"Fresh Basic Southern multi-star returns have provisional per-star allocation with exact child arrivals, a fixed original grant and one deferred income. 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. Low Salusa prohibits free Sardaukar revival from ordinary, Ghola, Emperor special Karama or Tleilaxu Ambassador sources; paid ordinary Sardaukar and eligible normal or leader returns remain. Retained occupation after repopulation is provisional. 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. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
evidence: [
'game/homeworld-module-profile.ts',
'game/faction-module-profile.ts',
+81 -13
View File
@@ -16,10 +16,13 @@ export type SaphoRevivalOrderUse = {
mode: 'first' | 'last';
completed: string[];
started: boolean;
/** Version 3 only: the current started player voluntarily paused last. */
suspended?: true;
};
export type RevivalOrder = RevivalOrderFields & (
| { version: 1 }
| { version: 2; uses: SaphoRevivalOrderUse[] }
| { version: 3; uses: SaphoRevivalOrderUse[]; paused: string[] }
);
export type RevivalOrderContext = {
turn: number;
@@ -53,8 +56,10 @@ function validateContext(context: RevivalOrderContext): void {
function copy(frame: RevivalOrder): RevivalOrder {
const fields = { turn: frame.turn, original: [...frame.original],
remaining: [...frame.remaining], completed: [...frame.completed], started: frame.started };
return frame.version === 1 ? { version: 1, ...fields }
: { version: 2, ...fields, uses: frame.uses.map(use => ({ ...use, completed: [...use.completed] })) };
if (frame.version === 1) return { version: 1, ...fields };
const uses = frame.uses.map(use => ({ ...use, completed: [...use.completed] }));
return frame.version === 2 ? { version: 2, ...fields, uses }
: { version: 3, ...fields, uses, paused: [...frame.paused] };
}
export function createRevivalOrder(context: RevivalOrderContext): RevivalOrder {
@@ -67,9 +72,11 @@ export function createRevivalOrder(context: RevivalOrderContext): RevivalOrder {
export function validateRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder): void {
validateContext(context);
requireOrder(record(frame) && (frame.version === 1 || frame.version === 2) &&
requireOrder(record(frame) && (frame.version === 1 || frame.version === 2 || frame.version === 3) &&
Object.keys(frame).sort().join(',') === (frame.version === 1
? 'completed,original,remaining,started,turn,version' : 'completed,original,remaining,started,turn,uses,version') &&
? 'completed,original,remaining,started,turn,version' : frame.version === 2
? 'completed,original,remaining,started,turn,uses,version'
: 'completed,original,paused,remaining,started,turn,uses,version') &&
Number.isSafeInteger(frame.turn) && frame.turn === context.turn &&
ids(frame.original) && ids(frame.remaining) && ids(frame.completed) && typeof frame.started === 'boolean',
'The current Revival order is malformed or belongs to another turn.');
@@ -78,7 +85,8 @@ export function validateRevivalOrder(context: RevivalOrderContext, frame: Reviva
if (frame.version === 1) requireOrder(same(frame.completed, frame.original.slice(0, frame.completed.length)) &&
same(frame.remaining, frame.original.slice(frame.completed.length)),
'The Revival order must retain the original public order and its completed prefix.');
else validateSaphoHistory(frame);
else if (frame.version === 2) validateSaphoHistory(frame);
else validateSuspendedSaphoHistory(frame);
requireOrder(frame.remaining.length > 0 || !frame.started,
'Completed Revival cannot retain a started opportunity.');
}
@@ -138,28 +146,84 @@ function validateSaphoHistory(frame: Extract<RevivalOrder, { version: 2 }>): voi
'The Revival frame contradicts its Sapho use history or unlocks a started opportunity.');
}
/** Version 3 extends the same public use history with a voluntary suspension.
* Pending native requests are checked by the engine before this quote. A
* suspended faction stays started, even if later uses reorder its position. */
function validateSuspendedSaphoHistory(frame: Extract<RevivalOrder, { version: 3 }>): void {
requireOrder(Array.isArray(frame.uses) && frame.uses.length > 0 && ids(frame.paused) &&
new Set(frame.paused).size === frame.paused.length,
'A suspended Revival needs its actual Sapho use history and unique paused factions.');
let remaining = [...frame.original], completed: string[] = [], started = false, paused: string[] = [];
const advance = (next: readonly string[]) => {
const advanced = advanceCompleted(remaining, completed, started, next);
const count = next.length - completed.length;
paused = paused.filter(player => !next.includes(player));
remaining = advanced.remaining;
completed = advanced.completed;
started = count === 0 ? advanced.started : !!remaining.length && paused.includes(remaining[0]);
};
for (const [index, use] of frame.uses.entries()) {
requireOrder(record(use) &&
(Object.keys(use).sort().join(',') === 'completed,event,mode,player,started' ||
Object.keys(use).sort().join(',') === 'completed,event,mode,player,started,suspended' && use.suspended === true) &&
typeof use.event === 'string' && id(use.player) && (use.mode === 'first' || use.mode === 'last') &&
ids(use.completed) && typeof use.started === 'boolean',
'The saved Sapho Revival use is malformed.');
advance(use.completed);
requireOrder(!started || use.started, 'A later Sapho use cannot unlock the current started Revival opportunity.');
requireOrder(!use.started || remaining.length > 0, 'Only a current Revival opportunity can have started.');
requireOrder(use.event === eventFor(frame.turn, frame.original, remaining, completed, use.started, index),
'The saved Sapho Revival use lost its original public boundary.');
if (use.suspended) {
requireOrder(use.started && use.player === remaining[0] && use.mode === 'last' && remaining.length > 1,
'Only the current started faction can suspend its own Revival turn last.');
if (!paused.includes(use.player)) paused.push(use.player);
remaining = [...remaining.slice(1), use.player];
started = paused.includes(remaining[0]);
} else {
remaining = reordered(frame.original, remaining, completed, use.started, use);
started = use.started || paused.includes(remaining[0]);
}
}
advance(frame.completed);
requireOrder(same(frame.remaining, remaining) && same(frame.paused, paused) && (!started || frame.started),
'The Revival frame contradicts its Sapho history or loses a suspended opportunity.');
requireOrder(frame.uses.some(use => use.suspended === true), 'Version 3 requires an actual suspended Revival turn.');
}
/** This boundary binds the actual public queue and every prior genuine use's
* revision. It grants no card ownership, payment or additional opportunity. */
export function saphoRevivalOrderEvent(context: RevivalOrderContext, frame: RevivalOrder): string {
validateRevivalOrder(context, frame);
return eventFor(frame.turn, frame.original, frame.remaining, frame.completed,
frame.started, frame.version === 2 ? frame.uses.length : 0);
frame.started, frame.version === 1 ? 0 : frame.uses.length);
}
export function reorderSaphoRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder, request: {
event: string; player: string; mode: 'first' | 'last';
}): { order: RevivalOrder; afterCurrent?: string } {
}): { order: RevivalOrder; afterCurrent?: string; suspended?: string } {
validateRevivalOrder(context, frame);
const event = saphoRevivalOrderEvent(context, frame);
requireOrder(record(request) && Object.keys(request).sort().join(',') === 'event,mode,player' &&
request.event === event && id(request.player) && (request.mode === 'first' || request.mode === 'last'),
'Choose first or last at the current Sapho Revival opportunity.');
const remaining = reordered(frame.original, frame.remaining, frame.completed, frame.started, request);
const order: RevivalOrder = { version: 2, turn: frame.turn, original: [...frame.original], remaining,
completed: [...frame.completed], started: frame.started,
uses: [...(frame.version === 2 ? frame.uses.map(use => ({ ...use, completed: [...use.completed] })) : []),
{ event, player: request.player, mode: request.mode, completed: [...frame.completed], started: frame.started }] };
const suspended = frame.started && request.player === frame.remaining[0];
if (suspended) requireOrder(request.mode === 'last' && frame.remaining.length > 1,
'A started Revival turn can only be suspended last when another opportunity remains.');
const remaining = suspended ? [...frame.remaining.slice(1), request.player]
: reordered(frame.original, frame.remaining, frame.completed, frame.started, request);
const paused = frame.version === 3 ? [...frame.paused] : [];
if (suspended && !paused.includes(request.player)) paused.push(request.player);
const uses = [...(frame.version === 1 ? [] : frame.uses.map(use => ({ ...use, completed: [...use.completed] }))),
{ event, player: request.player, mode: request.mode, completed: [...frame.completed], started: frame.started,
...(suspended ? { suspended: true as const } : {}) }];
const fields = { turn: frame.turn, original: [...frame.original], remaining,
completed: [...frame.completed], started: suspended ? paused.includes(remaining[0]) : frame.started || paused.includes(remaining[0]) };
const order: RevivalOrder = frame.version === 3 || suspended
? { version: 3, ...fields, uses, paused }
: { version: 2, ...fields, uses };
validateRevivalOrder(context, order);
return { order, ...(frame.started ? { afterCurrent: frame.remaining[0] } : {}) };
return { order, ...(suspended ? { suspended: request.player } :
frame.started ? { afterCurrent: frame.remaining[0] } : {}) };
}
export function revivalOrderBlock(context: RevivalOrderContext, frame: RevivalOrder, player: string): string | null {
@@ -186,6 +250,10 @@ export function finishRevivalOrder(context: RevivalOrderContext, frame: RevivalO
requireOrder(!blocked, blocked ?? 'This Revival opportunity is unavailable.');
const next = { ...copy(frame), remaining: frame.remaining.slice(1),
completed: [...frame.completed, player], started: false };
if (next.version === 3) {
next.paused = next.paused.filter(id => id !== player);
next.started = next.remaining.length > 0 && next.paused.includes(next.remaining[0]);
}
validateRevivalOrder({ ...context, ready: [...context.ready, player] }, next);
return next;
}
+15 -2
View File
@@ -179,8 +179,21 @@ export function quoteVictory(g: VictoryContext, ecazContext?: EcazHomeworldVicto
const predicted = () =>
bg?.prediction?.turn === g.turn &&
winner.some((id) => player(id).faction === bg.prediction!.faction);
for (const row of progress)
if (row.qualifies) winner = [...new Set([...winner, ...row.members])];
const qualifying = progress.filter((row) => row.qualifies);
// The authorized Advanced rules first compare the holdings of qualifying
// unallied players; their general shared-win rule covers ties and any
// competition that includes a qualifying alliance. A complete Tech set is
// the same additional stronghold used in the ordinary victory threshold.
// Keep existing settled winners and the separate Homeworld victory source
// intact rather than retroactively ranking a recorded result.
const rankSolo = g.advanced && winner.length === 0 && !ecazHomeworld &&
qualifying.length > 1 && qualifying.every((row) => row.members.length === 1);
const most = rankSolo
? Math.max(...qualifying.map((row) => row.strongholds.length + Number(row.techStronghold)))
: null;
for (const row of qualifying)
if (most === null || row.strongholds.length + Number(row.techStronghold) === most)
winner = [...new Set([...winner, ...row.members])];
if (ecazHomeworld) winner = [...new Set([...winner, ...ecazHomeworld.members])];
if (predicted()) winner = [bg!.id];
let status = g.status;