Honor allied Fremen victory and prevent deferred shipment deadlocks

Reciprocal allied Ecaz and Fremen with fighters in Sietch Tabr now satisfy that part of the Fremen final-turn victory, as explicitly clarified by the GF9 Ecaz/Moritani p15 FAQ. One public board quote drives automatic victory and the table guide; the chronicle explains the result. Other blockers, ordinary stronghold/BG prediction precedence, Guild fallback, advisor posture and concealed marker privacy remain intact. Habbanya symmetry and full mode/publication acceptance remain unfinished.

A genuine Advanced sample also exposed a pre-existing accepted Guild shipment which could never finish: allowance opened BG accompaniment and then hit the Ambassador arrival guard. Prior HEAD replay reproduced the exact 90-action failure. Fresh shipment preflight now rejects unsupported arrival combinations before accepting their pending windows. A valid legacy ordinary Guild allowance can return its unspent declaration to choose another destination; special parent/card receipts retain their separate gates. The change does not invent simultaneous reaction order or refund spent cards.

Independent rules/privacy/persistence review and focused engine/controls tests pass. Seventeen Fremen/Ecaz tests include all four profiles and authenticated final readiness/restart/concurrent writes. Shipment regressions cover pre-declaration immutability, supported arrival, malformed saved declarations and authenticated unspent return/replacement/reconnect/concurrency. All 4716 offline tests, types, lint, production build and 43 HTTP tests passed against unchanged source.

The original fixed seeds were repeated after repair and the exact failed snapshot resumed with documented new continuation seed 202609147: resume-advanced-202609147: 842 accepted, 109 guarded rejections, 22 periodic JSON restores; genuine-basic-202609145: 563 accepted, 14 guarded rejections, 15 periodic JSON restores; genuine-advanced-202609146: 806 accepted, 81 guarded rejections, 21 periodic JSON restores. All three finish and preserve physical custody/private views. These samples do not establish naturally reached allied Fremen special wins; a separate conserved browser final-turn fixture verifies the actual Ready-to-win transition, public guide/log, private hand and pending/finished refresh.

All 835 opening games and original seat/recovery records remain unchanged (902 after checks). All 869 rooms and records present after the reported outage also remain unchanged; database integrity and foreign keys pass. The healthy server and finished browser seat recovered without restart, reset or recurring automation. Full rules coverage and release gates remain open work.

Verified source tree: 1d9fcf30b54132f66be5f994f410dec6ec019683ee66b24fd4efb7a6aa7f7bbd
This commit is contained in:
Quintine committed 2026-09-14 02:59:50 +10:00
1 parent 58ae84f8e2
commit 9ca17a66a0
19 files changed
+1333 -31

No files matched your search

+124 -2
View File
@@ -168,6 +168,7 @@ import {
VictoryQuoteError,
type VictoryQuote,
} from './victory-quote';
import { fremenSpecialVictory, type FremenVictoryProgress } from './fremen-victory';
import {
createSpiceAllocation,
quoteSpiceAllocation,
@@ -9405,6 +9406,95 @@ function validateMovementArrival(g: Game, move: MovementOrder, cancelingChoam =
throw error;
}
}
function quoteShipmentArrival(
g: Game,
shipment: PendingShipment,
ignoreGuildDecision = false,
) {
// Guild Ambassador arrivals have a staged continuation which deliberately
// owns pendingAmbassador throughout shipment, Terror and accompaniment.
// Its source-specific validator handles those gates in their settled order.
if (shipment.source === 'ambassador') return;
const p = getPlayer(g, shipment.player);
const amount = shipment.alliedNoField
? Math.max(1, shipment.amount)
: shipment.amount + (shipment.smugglerCompanion ? 1 : 0);
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
guild: guild?.id,
shipper: p.id,
ally: p.ally,
cost: shipment.cost,
allyPayment: shipment.allyPayment,
bankOnly: g.karamaShipping?.player === p.id,
});
const bg = byFaction(g, 'beneGesserit');
const advisorFollowup = !!(
p.faction !== 'fremen' &&
bg &&
bg.id !== p.id &&
spiritualAdvisorMaximum(g, bg.id) > 0
);
const currentDecision = !!(
g.decision &&
!(
ignoreGuildDecision &&
g.decision.kind === 'guildShipment' &&
g.decision.player === guild?.id &&
g.decision.shipper === shipment.player &&
g.decision.territory === shipment.territory &&
g.decision.sector === shipment.sector
)
);
return quoteCompletedMovementArrival({
advanced: g.advanced,
players: g.players,
order: {
player: p.id,
origin: 'reserves',
to: shipment.territory,
advisors: shipment.advisors,
wantsFighters: false,
},
ambassadors: g.ecazAmbassadors?.tokens ?? [],
terror: homeworldTerrorEntryBlock(g, amount)
? []
: (g.moritaniTerror?.tokens ?? []),
controls: {
response:
!!g.response ||
guildPayment > 0 ||
(g.advanced && p.faction === 'fremen' && shipment.sector === g.storm),
decision: currentDecision || advisorFollowup,
pendingTerror: !!g.pendingTerrorEntry,
pendingAmbassador: !!g.pendingAmbassador,
paidBox: !!g.pendingNullentropy,
},
flight: null,
});
}
function validateShipmentArrival(g: Game, shipment: PendingShipment) {
try {
return quoteShipmentArrival(g, shipment);
} catch (error) {
if (error instanceof MovementArrivalError) throw new RuleError(error.message);
throw error;
}
}
function recoverableUnsupportedShipment(shipment: PendingShipment) {
return (
shipment.source === undefined &&
shipment.ambassadorEvent === undefined &&
shipment.noField === undefined &&
shipment.noFieldSkillProof === undefined &&
shipment.alliedNoField === undefined &&
shipment.smuggler === undefined &&
shipment.smugglerCompanion === undefined &&
shipment.nexusEvent === undefined &&
shipment.guildNexusEvent === undefined &&
shipment.guildSecretEvent === undefined
);
}
function completeMove(g: Game, move: MovementOrder) {
if (move.noField) {
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, move.player));
@@ -13152,16 +13242,24 @@ function victory(g: Game, quote: VictoryQuote = currentVictoryQuote(g)) {
g.status = quote.status;
if (g.winner.length) {
g.status = 'finished';
const occupy = strongholdProgress(g).progress.find(
const progress = strongholdProgress(g).progress;
const occupy = progress.find(
(row) =>
row.occupyTarget !== null &&
row.jointlyOccupied.length >= row.occupyTarget &&
row.members.length === g.winner.length &&
row.members.every((id) => g.winner.includes(id)),
);
const fremen = g.turn === 10 && !progress.some((row) => row.qualifies)
? fremenSpecialVictory(g) : null;
const fremenWon = fremen?.qualifies && fremen.members.length === g.winner.length &&
fremen.members.every((id) => g.winner.includes(id));
const specialReason = fremenWon
? ` Fremen special victory: both protected sietches meet the occupation condition and no prohibited faction occupies Tuek’s Sietch.${fremen.sietches.some((site) => site.ecazCooccupation) ? ' Allied Ecaz and Fremen may co-occupy Sietch Tabr without blocking this victory.' : ''}`
: '';
log(
g,
`${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.${occupy ? ` Ecaz Occupy: both allies occupy ${occupy.jointlyOccupied.map((id) => territory(id).name).join(', ')} without opposing fighters, meeting the three-stronghold alliance target.` : ''}`,
`${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.${occupy ? ` Ecaz Occupy: both allies occupy ${occupy.jointlyOccupied.map((id) => territory(id).name).join(', ')} without opposing fighters, meeting the three-stronghold alliance target.` : ''}${specialReason}`,
);
} else if (g.status === 'finished')
log(g, 'The tenth turn ended without a winner.');
@@ -18179,6 +18277,7 @@ function validateGuildShipmentDecision(
}
function offerShipment(g: Game, shipment: PendingShipment) {
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
checkShipmentIncomeRounding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
const p = getPlayer(g, shipment.player),
id = p.id;
@@ -18216,6 +18315,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
return;
}
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment);
finishNexusRicheseShipment(g,shipment,'shipped');
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',shipment);
@@ -21287,6 +21387,22 @@ function applyActionInner(
'Allow the shipment, or play the Guild special Karama.',
);
const shipment = validateGuildShipmentDecision(g, decision);
try {
quoteShipmentArrival(g, shipment, true);
} catch (error) {
if (!(error instanceof MovementArrivalError)) throw error;
requireRule(
recoverableUnsupportedShipment(shipment),
error.message,
);
g.pendingShipment = null;
g.decision = null;
log(
g,
`${getPlayer(g, shipment.player).name}'s uncommitted shipment returned for another choice. ${error.message} No forces, spice, cards or shipment allowance were spent.`,
);
return g;
}
g.pendingShipment = null;
commitShipment(g, shipment);
} else if (decision.kind === 'nullentropy') {
@@ -22773,6 +22889,9 @@ function applyActionInner(
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
bindGuildCunningShipment(g,p,'reserve',shipment);
if (shipment.noField) {
// This cancellation window bypasses offerShipment, so reject known
// unsupported arrival combinations before saving its private intent.
validateShipmentArrival(g, shipment);
checkShipmentIncomeRounding(g, p, cost, allyPayment);
g.pendingShipment = shipment;
g.response = { kind: 'richeseNoField', owner: p.id, passed: [],
@@ -23621,9 +23740,11 @@ export function viewGame(state: Game, id: string) {
normalizeBattle(g);
const me = getPlayer(g, id);
let victoryProgress: StrongholdProgress[] = [];
let fremenVictory: FremenVictoryProgress | null = null;
if (g.status === 'playing' || g.status === 'finished') {
try {
victoryProgress = strongholdProgress(state).progress;
fremenVictory = fremenSpecialVictory(state);
} catch (error) {
// A malformed restored board has no reliable preview. Authoritative
// victory still rejects it through its strict quote.
@@ -23993,6 +24114,7 @@ export function viewGame(state: Game, id: string) {
: null,
winner: g.winner,
victoryProgress,
fremenVictory,
shieldWallDestroyed: g.shieldWallDestroyed,
stormDialers: g.stormDialers,
stormSubmitted: Object.keys(g.stormDials),
+99
View File
@@ -0,0 +1,99 @@
import { fighterCount } from './advisors';
import { settledBoard, type BoardContext } from './board-resolution-quote';
import { ecazOccupancyIdentity } from './ecaz-occupy';
import { presenceAt } from './force-presence';
import { VictoryProgressError } from './victory-progress';
const PROTECTED_SIETCHES = ['sietch_tabr', 'habbanya_ridge_sietch'] as const;
export type FremenVictoryProgress = {
player: string;
members: string[];
qualifies: boolean;
sietches: {
territory: string;
blockers: string[];
ecazCooccupation: boolean;
}[];
tueksBlockers: string[];
};
/** Public current-board facts for the Fremen final-turn victory condition.
* Turn timing, prior stronghold winners and the Guild fallback belong to the
* victory quote. The only Ecaz exception established here is co-occupied
* Sietch Tabr; the same forces still block Habbanya Ridge Sietch. */
export function fremenSpecialVictory(
g: BoardContext,
): FremenVictoryProgress | null {
try {
const { players } = settledBoard(g);
if (!players.length)
throw new VictoryProgressError(
'Fremen victory progress requires seated players.',
);
// Reuse the public occupancy validator for faction uniqueness and exact
// reciprocal alliances without reading any private player state.
ecazOccupancyIdentity(players, players[0].id, {
kind: 'territory',
id: 'polar_sink',
});
const fremen = players.find((player) => player.faction === 'fremen');
if (!fremen) return null;
const ally = players.find((player) => player.id === fremen.ally);
const ecaz = ally?.faction === 'ecaz' ? ally : null;
const members = g.order.filter(
(player) => player === fremen.id || player === ally?.id,
);
const sietches = PROTECTED_SIETCHES.map((territory) => {
const ecazCooccupation = !!(
ecaz &&
fighterCount(fremen, territory) > 0 &&
fighterCount(ecaz, territory) > 0
);
const blockers = g.order.filter((id) => {
const player = players.find((candidate) => candidate.id === id)!;
if (player.id === fremen.id || fighterCount(player, territory) === 0)
return false;
return !(
territory === 'sietch_tabr' &&
ecazCooccupation &&
player.id === ecaz!.id
);
});
return { territory, blockers, ecazCooccupation };
});
const prohibited = new Set([
'atreides',
'harkonnen',
'emperor',
...(g.advanced ? ['richese'] : []),
]);
const tueksBlockers = g.order.filter((id) => {
const player = players.find((candidate) => candidate.id === id)!;
return (
prohibited.has(player.faction) && presenceAt(player, 'tueks_sietch') > 0
);
});
return {
player: fremen.id,
members,
qualifies:
sietches.every((sietch) => sietch.blockers.length === 0) &&
tueksBlockers.length === 0,
sietches,
tueksBlockers,
};
} catch (error) {
if (error instanceof VictoryProgressError) throw error;
throw new VictoryProgressError(
error instanceof Error
? error.message
: 'Invalid public Fremen victory state.',
);
}
}
+26 -3
View File
@@ -143,7 +143,7 @@ const phaseDetails = [
'A Fremen move using the two-territory advantage has a Karama response. Cancellation leaves the forces in place and limits this move to one territory; city ornithopters and a later extra move keep their normal rules.',
'Guild transport can move a group across the planet or return it to reserves. Ordinary movement is checked against range, storm and stronghold occupancy.',
'You may pass through an ally’s territory on a legal route that ends elsewhere. The printed clarification also permits shipping into allied territory followed by departure; that temporary arrival and its departure obligation are not yet supported by the table.',
'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory is supported; shared desert allocation is also supported. Combined combat and the Fremen endgame exception remain unfinished.',
'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory and shared desert allocation are supported. Allied Ecaz and Fremen together in Sietch Tabr do not block the Fremen final-turn victory; its other conditions still apply. Combined combat and the Habbanya extension remain unfinished.',
'A restored pending physical shipment is checked again before forces or spice are committed. If its saved turn, force allocation, destination, price or authorized contribution no longer matches the table, it is rejected without changing the saved declaration.',
'Finish shipment and movement explicitly. Any Bene Gesserit free-shipment decision and its response must finish first.',
'Advanced Guild may choose when to take its complete shipment-and-movement turn. The table offers a choice before another player starts. Guild cannot interrupt a turn already underway.',
@@ -2645,7 +2645,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'The table’s Stronghold victory progress shows current qualifying territories, a complete Tech Token set and Ecaz joint occupation. Tech Tokens cannot supply a jointly occupied territory. These public counts do not resolve a private prediction or award victory before the proper checkpoint.',
'The table’s Stronghold victory progress shows current qualifying territories, a complete Tech Token set, Ecaz joint occupation and Fremen’s final-turn conditions. Allied Ecaz and Fremen may co-occupy Sietch Tabr without blocking that special win; solitary or non-allied Ecaz does not receive this exception. Tech Tokens cannot supply a jointly occupied territory. These public counts do not resolve a private prediction or award victory before the proper checkpoint.',
'Other expansion additions to victory remain in development. Advanced and expansion faction starts remain disabled.',
],
example:
@@ -2658,6 +2658,28 @@ export const RULE_TOPICS: RuleTopic[] = [
'faction-beneGesserit',
],
},
{
id: 'fremen-ecaz-endgame',
title: 'Fremen and Ecaz: the final-turn exception',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Integrated',
summary: 'Allied Ecaz and Fremen may share Sietch Tabr while qualifying for the Fremen special victory.',
steps: [
'At the final-turn victory check, ordinary stronghold victories come first. If none qualifies, Fremen’s special occupation conditions are checked before the Guild fallback. A qualifying Fremen victory includes its ally and is not replaced by a Bene Gesserit prediction.',
'Ecaz and Fremen must be current reciprocal allies and both have fighters in Sietch Tabr. Their co-occupation does not block the Fremen special condition. Ecaz alone, non-allied Ecaz or another faction’s fighters still block it. Accompanying Bene Gesserit advisors do not count as fighters.',
'Habbanya Sietch retains its existing empty-or-Fremen condition; extending the Ecaz exception there is not yet supported. Tuek’s Sietch must contain no Atreides, Harkonnen or Emperor presence, and Advanced play also excludes Richese presence. A concealed No-Field counts without revealing its hidden value.',
'The public victory guide lists the three territories and any blocking factions. It uses the same settled-board facts as automatic victory resolution. No extra confirmation is required; the table chronicle explains a successful special victory.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'The source-confirmed Sietch Tabr exception is connected in Basic and Advanced. Habbanya symmetry and broader expansion acceptance remain incomplete.', evidence: ['game/fremen-victory.ts', 'game/victory-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'Read-only public territory conditions and blockers; victory resolves at the existing checkpoint without a new decision.', evidence: ['components/victory-progress.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles reach the automatic outcome through legal final-turn actions. Strategic defense of this allied exception is not separately calibrated.', evidence: ['tests/fremen-ecaz-victory-engine.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The explicit FAQ exception is separated from the unsupported Habbanya extension.', evidence: ['docs/ECAZ_VICTORY_RULES.md', 'game/reference.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Public presence, precedence, all-profile continuation, JSON and authenticated SQLite recovery are covered. Final-turn positions are explicitly staged after genuine setup.', evidence: ['tests/fremen-victory.test.ts', 'tests/fremen-victory-controls.test.tsx', 'tests/fremen-ecaz-victory-engine.test.ts', 'tests/fremen-ecaz-victory-recovery.test.ts'] },
],
related: ['victory', 'faction-fremen', 'faction-ecaz'],
},
{
id: 'stronghold-cards',
title: 'Stronghold Cards: control and battle advantages',
@@ -3661,6 +3683,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: Recruits, Reinforcements and Harass & Withdraw form a separate unfinished optional variant.',
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.',
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent to its shipper when allowance encounters that arrival guard, so the shipper can choose another destination. Older No-Field, Smuggler, Nexus and Ambassador shipments are excluded from this recovery. Simultaneous arrival order remains unfinished.',
'Remaining abilities and combined timing can still encounter explicit development guards. Setup and saved-continuation evidence does not certify complete expansion play.',
],
related: ['setup', 'ix-modules', 'choam-modules', 'ecaz-modules', 'implementation-checklist'],
@@ -3669,7 +3692,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Existing faction choices and private hands are reused. Ecaz has an owned sector-allocation control and setup waiting messages; Ixians have an explicit per-lot decline control. Public starts and full acceptance remain gated.', evidence: ['components/ecaz-setup.tsx', 'components/ix-richese-technology.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use genuine setup choices and the shared Ecaz allocation quote, and can decline this Richese lot without using Technology. Combined gameplay strategy, guarded interactions and full-game reliability remain unfinished.', evidence: ['game/bots.ts', 'game/ecaz-setup.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The faction profile records ordinary deck composition, independent variants, Ecaz territory allocation and deterministic setup-order interpretations, with unfinished play explicit.', evidence: ['docs/EXPANSION_FACTIONS_PROTOTYPE.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Setup, inventory, private views, JSON/SQLite continuation, concurrent start and dirty-lobby rejection have focused checks. Ixian per-lot decline has ownership, stale/corrupt decision, private card custody and concurrent recovery checks. Sample gameplay and browser evidence are recorded separately, with remaining guards retained. Deferred movement checks cover preflight, reserved cards and saved concurrent recovery.', evidence: ['tests/deferred-movement-arrival.test.ts', 'tests/deferred-movement-arrival-recovery.test.ts', 'tools/faction-games.ts', 'tests/expansion-factions-prototype.test.ts', 'tests/expansion-factions-recovery.test.ts', 'tests/ecaz-setup-controls.test.tsx', 'tests/ecaz-cards.test.ts', 'tests/ix-richese-technology.test.ts', 'tests/ix-richese-technology-controls.test.tsx', 'tests/ix-richese-technology-recovery.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Setup, inventory, private views, JSON/SQLite continuation, concurrent start and dirty-lobby rejection have focused checks. Ixian per-lot decline has ownership, stale/corrupt decision, private card custody and concurrent recovery checks. Sample gameplay and browser evidence are recorded separately, with remaining guards retained. Deferred movement and ordinary shipment checks cover arrival preflight, retained resources and saved concurrent recovery; special shipment receipts keep their separate guards.', evidence: ['tests/deferred-movement-arrival.test.ts', 'tests/deferred-movement-arrival-recovery.test.ts', 'tests/deferred-shipment-arrival.test.ts', 'tests/deferred-shipment-arrival-recovery.test.ts', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'tools/faction-games.ts', 'tests/expansion-factions-prototype.test.ts', 'tests/expansion-factions-recovery.test.ts', 'tests/ecaz-setup-controls.test.tsx', 'tests/ecaz-cards.test.ts', 'tests/ix-richese-technology.test.ts', 'tests/ix-richese-technology-controls.test.tsx', 'tests/ix-richese-technology-recovery.test.ts'] },
],
},
...[
+2 -20
View File
@@ -2,7 +2,7 @@ import type { Game, Player } from './engine';
import { FACTIONS } from './catalog';
import { gameTerritories, validLocation } from './board';
import { fighterCount } from './advisors';
import { presenceAt } from './force-presence';
import { fremenSpecialVictory } from './fremen-victory';
import {
settledBoard,
BoardResolutionError,
@@ -165,25 +165,7 @@ export function quoteVictory(g: VictoryContext): VictoryQuote {
if (!winner.length && g.turn === 10) {
const fremen = players.find((p) => p.faction === 'fremen');
const guild = players.find((p) => p.faction === 'guild');
if (
fremen &&
players.every(
(p) =>
p.id === fremen.id ||
(!fighterCount(p, 'sietch_tabr') &&
!fighterCount(p, 'habbanya_ridge_sietch')),
) &&
players
.filter((p) =>
[
'atreides',
'harkonnen',
'emperor',
...(g.advanced ? ['richese'] : []),
].includes(p.faction),
)
.every((p) => !presenceAt(p, 'tueks_sietch'))
)
if (fremen && fremenSpecialVictory(g)?.qualifies)
winner = fremen.ally ? [fremen.id, fremen.ally] : [fremen.id];
else if (guild) winner = guild.ally ? [guild.id, guild.ally] : [guild.id];
else if (fremen)