Complete concealed No-Field allied separation endings

This commit is contained in:
Quintine committed 2026-10-27 12:11:45 +11:00
1 parent 57a59c1663
commit e41abdaf2e
17 files changed
+569 -30

No files matched your search

+2 -2
View File
@@ -3738,7 +3738,7 @@ function policyActions(g: GameView): Action[] {
const retained =
level > 0 &&
g.basicAlliedShipment?.territory !== territory(origin).name &&
!g.advancedAllySeparation?.territories.includes(territory(origin).name) &&
!(g.advancedAllySeparation ?? g.basicAllySeparation)?.territories.includes(territory(origin).name) &&
territory(origin).type === 'stronghold' &&
!isAdvisor(me, origin)
? 1
@@ -4454,7 +4454,7 @@ export function botActions(g: GameView): Action[] {
(sourced.type!=='guildShip'||!g.advisorTransportMatchingDenied?.length||!guildTransportQuote(g,sourced).unavailableReasons.length) ? [sourced] : [];
});
const me = g.players.find((p) => p.id === g.me)!;
const advancedDepartures = g.advancedAllySeparation?.territories;
const advancedDepartures = (g.advancedAllySeparation ?? g.basicAllySeparation)?.territories;
if ((g.basicAlliedShipment || advancedDepartures?.length) &&
g.phase === 5 && g.active === me.id && me.shipped && me.moved === 0) {
// Depart from shared groups before unrelated moves; do not retain a
+11 -16
View File
@@ -4622,7 +4622,7 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
observeOccupation(g);
log(
g,
`${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`,
`${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause === 'alliedSeparation' ? 'allied separation' : cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`,
{ faction: 'richese', name: 'No-Field revealed' },
);
return result;
@@ -21506,13 +21506,8 @@ function alliedSeparationTerritories(g: Game, p: Player) {
})),
});
}
function requireAlliedSeparationCustody(g: Game, p: Player) {
const deployed = p.noField?.deployed;
requireRule(!deployed || !alliedSeparationTerritories(g, p).includes(deployed.location.territory),
'This removal of a concealed No-Field awaits its specific reveal and casualty rules.');
}
function advancedAllySeparationView(g: Game, p: Player): {territories: string[]} | null {
if (!g.advanced || g.status !== 'playing' || g.phase !== 5 || g.active !== p.id ||
function alliedSeparationView(g: Game, p: Player): {territories: string[]; noField?: true} | null {
if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id ||
g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama ||
g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange ||
g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingShipment ||
@@ -21523,8 +21518,11 @@ function advancedAllySeparationView(g: Game, p: Player): {territories: string[]}
pendingNexusTraitors(g) || g.nexusCards?.phase?.stage === 'drawing' ||
currentGuildCunning(g, p.id)?.stage === 'pending' ||
currentGuildCunning(g, p.id)?.stage === 'secondShipment') return null;
const territories = alliedSeparationTerritories(g, p).map(id => territory(id).name);
return territories.length ? {territories} : null;
const places = alliedSeparationTerritories(g, p);
const territories = places.map(id => territory(id).name);
const marker = p.noField?.deployed;
return territories.length ? {territories,
...(marker?.controller === p.id && places.includes(marker.location.territory) ? {noField:true as const} : {})} : null;
}
function basicAlliedShipmentView(g: Game, p: Player) {
@@ -21580,7 +21578,6 @@ function finishMovementTurn(g: Game, id: string) {
if (error instanceof MovementClaimError) throw new RuleError(error.message);
throw error;
}
requireAlliedSeparationCustody(g, p);
const cunning = currentGuildCunning(g,id);
if (cunning) { requireRule(cunning.stage !== 'pending' && cunning.shipment?.stage !== 'pending', 'Finish the Guild Cunning response or shipment first.'); cunning.stage = 'completed'; saveGuildCunning(g,cunning); }
finishBasicAlliedShipment(g, p);
@@ -21602,7 +21599,7 @@ function finishMovementTurn(g: Game, id: string) {
{ faction: 'ecaz', name: 'Occupy' },
);
for (const terr of alliedSeparationTerritories(g, p))
killTerritory(g, p, terr);
killTerritory(g, p, terr, Infinity, false, 'alliedSeparation');
g.movementRemaining = g.movementRemaining.filter((other) => other !== id);
if (g.saphoMovementLast?.player === id) g.saphoMovementLast = null;
movementTurn(g);
@@ -42492,9 +42489,6 @@ function applyActionInner(
if (g.ornithopter?.player === id) finishOrnithopter(g, 'cunning');
return g;
}
// Reject unsupported concealed custody before retiring a played movement
// card and parking its native ending continuation.
requireAlliedSeparationCustody(g, p);
if (g.ornithopter?.player === id) finishOrnithopter(g, 'end');
else finishMovementTurn(g, id);
return g;
@@ -43761,7 +43755,8 @@ export function viewGame(state: Game, id: string, now?: number) {
g.guildRateBlocked.player === id,
...(guildTransportCanceled(g, id) ? { guildTransportCanceled: true as const } : {}),
stormPending: g.stormPending ?? null,
advancedAllySeparation: advancedAllySeparationView(g, me),
advancedAllySeparation: g.advanced ? alliedSeparationView(g, me) : null,
basicAllySeparation: !g.advanced ? alliedSeparationView(g, me) : null,
basicAlliedShipment: basicAlliedShipmentView(g, me),
stormForecast:
me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null,
+4 -3
View File
@@ -4371,16 +4371,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'Under the adopted unofficial Advanced revision, finish each player’s Shipment and Movement separately. Your own fighters still sharing a territory with your ally go to Tanks when your turn ends. Your ally’s fighters stay; its later turn does not postpone your loss. An alliance formed this turn has no Advanced grace period.',
'Polar Sink is exempt. If either allied group is Bene Gesserit advisors, coexistence sends neither group to Tanks. Bene Gesserit fighters have no advisor exemption. Native Ecaz peaceful coexistence keeps its own conditions, including applicable Karama effects.',
'Before an ordinary finish, the table warns which public territories would lose your forces. It does not reveal force amounts or concealed No-Field values. Finish movement uses the existing action, without another loss confirmation. If no legal escape is available, you may still finish and accept the mandatory consequence.',
'A concealed No-Field counts as one presence, including0. If its actual separation deadline is due, provisional destructive timing reveals it, places only available reserves, then sends your actual shared forces to Tanks. Your ally stays. A marker elsewhere, a not-yet-due Basic turn and existing exemptions retain concealment. The warning states that a marker is affected without exposing its value or quantity.',
'Complete any pending shipment, movement, arrival or response before ordinary finish controls become available. Extra-move and deferred-turn endings use the same separation rule; they do not replay the arrival or charge shipment again.',
'An ordinary physical reserve shipment may enter an allied territory before your movement on your own unused turn. Ship, then move out; stronghold capacity and storm still apply. The ending consequence covers your entire shared group, not only new visitors. No-Fields and optional Homeworld/mobile transport keep their separate legality.',
'Basic still uses its older later-player deadline and formation-turn exemption, plus its visibly provisional new-visitor consequence. Homeworlds keep their native allied-entry restrictions. The rule grants no extra shipment, movement or alliance formation window; completed turns are not reevaluated.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'A shared ordinary reserve-entry quote connects native preflight and commit. Canonical separation distinguishes Basic from Advanced timing and exceptions; native ending-group custody removes all Advanced shared forces.', evidence: ['game/allied-separation.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Pre-shipment notice explains the mode-specific consequence; own clean-turn warning contains public loss territories only. Mandatory loss adds no confirmation and pending-control locks retain priority.', evidence: ['components/game-table.tsx'] },
{ area: 'Implementation', status: 'Partial', detail: 'A shared ordinary reserve-entry quote connects native preflight and commit. Canonical separation distinguishes Basic from Advanced timing and exceptions; native ending-group custody removes all Advanced shared forces. Due concealed markers use provisional reserve-capped revelation instead of blocking Finish.', evidence: ['game/allied-separation.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Pre-shipment notice explains the mode-specific consequence; own clean-turn warnings in both modes contain public loss territories and optional marker presence only. Mandatory loss adds no confirmation and pending-control locks retain priority.', evidence: ['components/game-table.tsx', 'components/advanced-ally-separation.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four legal policies use projected movement/ending availability. Native departure takes priority, without retaining a doomed city sentinel; a Medium typed-arrival/departure smoke passes. No separate separation calculator or strategic calibration was added.', evidence: ['game/bots.ts', 'game/bot-mobility.ts', 'tests/bot-advanced-source.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The unofficial per-player/advisor departures are distinguished from the earlier publisher/FAQ and unchanged Basic policy.', evidence: ['docs/ALLIED_TRANSIT_RULES.md', 'docs/RULE_DECISIONS.md', 'docs/BASE_ADVANCED_READINESS_20260907.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine native overlaps cover first/later newly allied ends, exact normal/elite custody, Hajr, advisor/Ecaz/Polar exceptions and unchanged Basic policy. Authenticated SQLite covers restart, races, privacy and immutable rejects. Actual phone ending moved only the ending group to Tanks and refresh retained it; wider combinations, full modes and deployment remain open.', evidence: ['tests/allied-separation.test.ts', 'tests/advanced-source-engine.test.ts', 'tests/advanced-source-recovery.test.ts', 'tests/bot-advanced-source.test.ts', 'docs/ALLIED_TRANSIT_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine native overlaps cover first/later newly allied ends, exact normal/elite custody, Hajr, advisor/Ecaz/Polar exceptions and unchanged Basic policy. Authenticated SQLite covers restart, races, privacy and immutable rejects. Actual phone ending moved only the ending group to Tanks and refresh retained it; wider combinations, full modes and deployment remain open.', evidence: ['tests/fixture-no-field-allied-separation.ts', 'tests/no-field-allied-separation-engine.test.ts', 'tests/no-field-allied-separation-controls.test.tsx', 'tests/allied-separation.test.ts', 'tests/advanced-source-engine.test.ts', 'tests/advanced-source-recovery.test.ts', 'tests/bot-advanced-source.test.ts', 'docs/ALLIED_TRANSIT_RULES.md'] },
],
related: ['movement', 'spice-blow', 'advanced-advisors', 'ecaz-ambassadors', 'homeworlds'],
},
+3 -1
View File
@@ -25,7 +25,8 @@ export type NoFieldRevealCause =
| 'gamont'
| 'beforeAlly'
| 'allyShipment'
| 'atomics';
| 'atomics'
| 'alliedSeparation';
export type NoFieldReveal = {
state: RicheseNoField;
tokenId: string;
@@ -261,6 +262,7 @@ export function revealRicheseNoField(
'beforeAlly',
'allyShipment',
'atomics',
'alliedSeparation',
].includes(input.cause),
'Choose a valid No-Field reveal cause.',
);