Enable Basic Kull by default and suspend Sapho turns
This commit is contained in:
1 parent
f96183f350
commit
e9154a90f1
20 files changed
+669
-27
No files matched your search
+39
-9
@@ -1,4 +1,5 @@
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { kullDefaultEligible } from './kull-default-profile';
|
||||
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
|
||||
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
|
||||
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
|
||||
@@ -2685,6 +2686,8 @@ export type Game = {
|
||||
ecazTreachery?: true;
|
||||
/** Lobby-only module selections; consumed by the genuine setup at start. */
|
||||
lobbyOptions?: LobbyOptions;
|
||||
/** New-lobby policy marker; absent on pre-existing lobbies. */
|
||||
kullDefaultPolicy?: true;
|
||||
/** Public, turn-scoped receipt for the discarded Recruits card. */
|
||||
recruits?: {
|
||||
turn: number;
|
||||
@@ -5604,6 +5607,7 @@ export function createGame(
|
||||
version: 0,
|
||||
host: host.id,
|
||||
status: 'lobby',
|
||||
kullDefaultPolicy: true,
|
||||
advanced,
|
||||
expansions: [...new Set(expansions)],
|
||||
players: [host],
|
||||
@@ -10085,14 +10089,22 @@ function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
|
||||
return lostCyborgs;
|
||||
}
|
||||
/** Lobby-only selections whose saved setup shape is created at start. */
|
||||
type LobbyOptions = { leaderSkills?: true; ecazTreachery?: true; kullPreview?: true };
|
||||
type LobbyOptions = { leaderSkills?: true; ecazTreachery?: true; kullPreview?: true; kullOptOut?: true };
|
||||
function lobbyOptionsIntegrity(g: Game) {
|
||||
requireRule(g.kullDefaultPolicy === undefined || g.kullDefaultPolicy === true, 'Invalid Kull lobby policy.');
|
||||
const options = g.lobbyOptions;
|
||||
if (options === undefined) return;
|
||||
requireRule(g.status === 'lobby' && !!options && typeof options === 'object' && !Array.isArray(options) &&
|
||||
Object.entries(options).every(([key, value]) => ['leaderSkills', 'ecazTreachery', 'kullPreview'].includes(key) && value === true),
|
||||
Object.entries(options).every(([key, value]) => ['leaderSkills', 'ecazTreachery', 'kullPreview', 'kullOptOut'].includes(key) && value === true) &&
|
||||
(!options.kullOptOut || !!g.kullDefaultPolicy && !options.kullPreview),
|
||||
'Invalid lobby module options.');
|
||||
}
|
||||
function lobbyKullDefault(g: Game): boolean {
|
||||
return !!g.kullDefaultPolicy && kullDefaultEligible(g);
|
||||
}
|
||||
function lobbyKullEnabled(g: Game): boolean {
|
||||
return !!g.lobbyOptions?.kullPreview || lobbyKullDefault(g) && !g.lobbyOptions?.kullOptOut;
|
||||
}
|
||||
function lobbyModulesSelected(g: Game): boolean {
|
||||
return !!g.lobbyOptions?.leaderSkills || !!g.lobbyOptions?.ecazTreachery || !!g.lobbyOptions?.kullPreview ||
|
||||
!!g.nexusCards || g.discoveryEnabled === true || !!g.homeworlds;
|
||||
@@ -10102,7 +10114,7 @@ function startModules(state: Game): Game {
|
||||
const options = state.lobbyOptions ?? {};
|
||||
const g = structuredClone(state);
|
||||
delete g.lobbyOptions;
|
||||
if (options.kullPreview) {
|
||||
if (lobbyKullEnabled(state)) {
|
||||
requireRule(g.advanced === false && (g.expansions.length === 2 ||
|
||||
g.expansions.length === 3 && g.expansions.includes('ecaz')) && g.expansions.includes('choam') &&
|
||||
g.expansions.includes('ix') && !!byFaction(g,'choam') &&
|
||||
@@ -10196,7 +10208,7 @@ function requireFreshBaseRuntime(g: Game) {
|
||||
// unowned optional components are checked separately; gameplay fields stay fresh.
|
||||
const configuration = new Set<keyof Game>(['code', 'version', 'host', 'players', 'playerPositions',
|
||||
'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards',
|
||||
'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery']);
|
||||
'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy']);
|
||||
const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[];
|
||||
requireRule(keys.every(key => configuration.has(key) ||
|
||||
JSON.stringify(g[key]) === JSON.stringify(fresh[key])),
|
||||
@@ -11079,6 +11091,9 @@ function finishMandatorySkillVisibility(g: Game) {
|
||||
}
|
||||
|
||||
function initializeSetup(g: Game, strongholds = !!g.strongholdCards) {
|
||||
// Lobby defaults are selections for a new setup, never active game state.
|
||||
delete g.lobbyOptions;
|
||||
delete g.kullDefaultPolicy;
|
||||
const ecaz = byFaction(g, 'ecaz');
|
||||
if (ecaz && g.advanced) g.ecazLoyalty = { player: ecaz.id, card: null };
|
||||
if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random);
|
||||
@@ -26803,6 +26818,7 @@ type SaphoOption = {
|
||||
mode: 'first' | 'last';
|
||||
/** Display only; action submission keeps scope/event/mode. */
|
||||
afterCurrent?: string;
|
||||
resumeOwn?: true;
|
||||
} | { scope: 'battleAggressor'; event: string; mode: 'aggressor' };
|
||||
function saphoCyclicRule<T>(quote: () => T): T {
|
||||
try { return quote(); }
|
||||
@@ -27020,7 +27036,14 @@ function saphoMovementOrderContext(g: Game): SaphoMovementOrderContext | null {
|
||||
if (queue.currentStarted && !active.shipped && active.moved===0) return null;
|
||||
const guild=g.advanced ? byFaction(g,'guild') : undefined;
|
||||
return {queue,started:queue.remaining.filter(id=>saphoMovementStarted(g,getPlayer(g,id))),
|
||||
guild:guild ? {player:guild.id,granted:!!g.guildTimingGranted,locked:!!g.guildTimingLocked} : null};
|
||||
guild:guild ? {player:guild.id,granted:!!g.guildTimingGranted,locked:!!g.guildTimingLocked} : null,
|
||||
// These shared preparations are not yet owned/resumed across another
|
||||
// player's turn. Only a clean Basic physical shipment/move may suspend.
|
||||
allowStartedLast: !g.advanced && !g.karamaShipping && !g.ornithopter && !g.hajr.includes(active.id) &&
|
||||
!(active.faction === 'choam' && g.choamMovement?.turn === g.turn) &&
|
||||
!g.pendingTreacheryDiscard && !g.pendingKull && !g.pendingHomeworldShipment && !g.pendingGuildTransport &&
|
||||
!g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingAdvisorMatching &&
|
||||
!g.pendingAutomaticAdvisorFlips?.length};
|
||||
}
|
||||
function saphoMovementRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
@@ -27123,7 +27146,8 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
|
||||
try {
|
||||
const quote=reorderSaphoMovementOrder(movement,{event:saphoMovementOrderEvent(movement),player:p.id,mode});
|
||||
options.push({scope:'movement',event:quote.event,mode,
|
||||
...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {})});
|
||||
...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {}),
|
||||
...(quote.suspended ? {resumeOwn:true as const} : {})});
|
||||
} catch(error) {
|
||||
if (!(error instanceof SaphoMovementOrderError)) throw error;
|
||||
}
|
||||
@@ -27344,7 +27368,7 @@ function playSapho(g: Game, p: Player, action: Action) {
|
||||
const used = discard(g, p, 'richese-juice-of-sapho');
|
||||
log(
|
||||
g,
|
||||
`${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}.${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the current combined turn unchanged; only later unstarted turns reorder.` : ''} Completed turns and physical storm order are unchanged.${option.mode === 'first' && g.advanced && g.movementRemaining.some(id => id !== p.id && getPlayer(g, id).faction === 'guild') ? ' The Guild retains its next legal timing choice and may still take its turn before this reordered turn.' : ''}`,
|
||||
`${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}.${quote.suspended ? ' Their started turn pauses provisionally; spent shipment and movement stay spent until it resumes.' : ''}${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the current combined turn unchanged; only later unstarted turns reorder.` : ''} Completed turns and physical storm order are unchanged.${option.mode === 'first' && g.advanced && g.movementRemaining.some(id => id !== p.id && getPlayer(g, id).faction === 'guild') ? ' The Guild retains its next legal timing choice and may still take its turn before this reordered turn.' : ''}`,
|
||||
{ faction: p.faction, name: 'Juice of Sapho' },
|
||||
);
|
||||
if (g.semutaPreview) {
|
||||
@@ -42699,7 +42723,13 @@ function applyActionInner(
|
||||
if (action.enabled) g.discoveryEnabled = true; else delete g.discoveryEnabled;
|
||||
} else {
|
||||
const options: LobbyOptions = { ...g.lobbyOptions };
|
||||
if (action.enabled) options[option as keyof LobbyOptions] = true;
|
||||
if (option === 'kullPreview') {
|
||||
if (action.enabled) {
|
||||
if (lobbyKullDefault(g)) delete options.kullPreview; else options.kullPreview = true;
|
||||
delete options.kullOptOut;
|
||||
}
|
||||
else { delete options.kullPreview; if (g.kullDefaultPolicy) options.kullOptOut = true; }
|
||||
} else if (action.enabled) options[option as keyof LobbyOptions] = true;
|
||||
else delete options[option as keyof LobbyOptions];
|
||||
if (Object.keys(options).length) g.lobbyOptions = options; else delete g.lobbyOptions;
|
||||
}
|
||||
@@ -45016,7 +45046,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
techTokens: g.techTokens ?? null,
|
||||
lobbyModules: g.status === 'lobby' ? {
|
||||
leaderSkills: !!g.lobbyOptions?.leaderSkills, ecazTreachery: !!g.lobbyOptions?.ecazTreachery,
|
||||
kullPreview: !!g.lobbyOptions?.kullPreview,
|
||||
kullPreview: lobbyKullEnabled(g), kullDefault: lobbyKullDefault(g),
|
||||
nexusCards: !!g.nexusCards, discoveries: g.discoveryEnabled === true,
|
||||
} : null,
|
||||
ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Public lobby facts only. This controls fresh Basic setup and never examines
|
||||
* a player's cards, spice or any hidden Karama/counter eligibility. */
|
||||
export type KullDefaultProfile = {
|
||||
status: string;
|
||||
advanced: boolean;
|
||||
expansions: readonly string[];
|
||||
players: readonly { faction: string }[];
|
||||
lobbyOptions?: { leaderSkills?: unknown; ecazTreachery?: unknown } | null;
|
||||
/** Same public selections on a projected lobby view. */
|
||||
lobbyModules?: { leaderSkills?: unknown; ecazTreachery?: unknown; nexusCards?: unknown; discoveries?: unknown } | null;
|
||||
leaderSkills?: unknown;
|
||||
ecazTreachery?: unknown;
|
||||
nexusCards?: unknown;
|
||||
discoveryEnabled?: unknown;
|
||||
discoveries?: unknown;
|
||||
discoveryStash?: unknown;
|
||||
greatMaker?: unknown;
|
||||
homeworlds?: unknown;
|
||||
techTokens?: unknown;
|
||||
strongholdCards?: unknown;
|
||||
};
|
||||
export type KullDefaultEligibility = { eligible: boolean; reason: string | null };
|
||||
|
||||
const classic = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']);
|
||||
const ix = new Set(['ixians', 'tleilaxu']);
|
||||
const choam = new Set(['choam', 'richese']);
|
||||
const ecaz = new Set(['ecaz', 'moritani']);
|
||||
const blocked = (reason: string): KullDefaultEligibility => ({ eligible: false, reason });
|
||||
|
||||
/** Public profile eligibility is independent of the host's explicit opt-out.
|
||||
* A lone CHOAM seat can see the choice while waiting for others; the native
|
||||
* start still checks readiness, minimum seats and all fresh private inventory.
|
||||
* This quote cannot authorize a start or inspect pending/private game state. */
|
||||
export function kullDefaultProfile(game: KullDefaultProfile): KullDefaultEligibility {
|
||||
if (game.status !== 'lobby') return blocked('Automatic Kull applies only to a fresh lobby.');
|
||||
if (game.advanced !== false) return blocked('Automatic Kull currently supports Basic rules only.');
|
||||
if (!Array.isArray(game.expansions) ||
|
||||
!(game.expansions.length === 2 || game.expansions.length === 3 && game.expansions.includes('ecaz')) ||
|
||||
!game.expansions.includes('choam') || !game.expansions.includes('ix') ||
|
||||
new Set(game.expansions).size !== game.expansions.length)
|
||||
return blocked('Automatic Kull currently needs the CHOAM and Ix decks, with Ecaz optional.');
|
||||
if (!Array.isArray(game.players) || game.players.length < 1 || game.players.length > 6 ||
|
||||
!game.players.some(player => player?.faction === 'choam') ||
|
||||
!game.players.every(player => !!player && typeof player.faction === 'string' &&
|
||||
(classic.has(player.faction) || ix.has(player.faction) && game.expansions.includes('ix') ||
|
||||
choam.has(player.faction) && game.expansions.includes('choam') ||
|
||||
ecaz.has(player.faction) && game.expansions.includes('ecaz'))) ||
|
||||
new Set(game.players.map(player => player.faction)).size !== game.players.length)
|
||||
return blocked('Automatic Kull needs one to six distinct factions, including CHOAM, with their selected decks.');
|
||||
const options = game.lobbyOptions, projected = game.lobbyModules;
|
||||
if (options?.leaderSkills || options?.ecazTreachery || projected?.leaderSkills || projected?.ecazTreachery ||
|
||||
projected?.nexusCards || projected?.discoveries || game.leaderSkills || game.ecazTreachery || game.nexusCards ||
|
||||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||
game.homeworlds || game.techTokens || game.strongholdCards)
|
||||
return blocked('Automatic Kull currently excludes optional modules.');
|
||||
return { eligible: true, reason: null };
|
||||
}
|
||||
|
||||
export function kullDefaultEligible(game: KullDefaultProfile): boolean {
|
||||
return kullDefaultProfile(game).eligible;
|
||||
}
|
||||
+9
-9
@@ -72,7 +72,7 @@ export const HELP = {
|
||||
summonedWorm:
|
||||
'Fremen’s special Karama summons a worm in sand once per game during Spice Blow and Nexus. Resolve destruction and protection immediately, then resume the spice blow. The worm causes a Nexus at the end of that blow, followed by Fremen rides. Calling the worm does not draw or discard a spice card.',
|
||||
choamWorthless:
|
||||
'Kulon adds one territory of movement range on CHOAM’s turn. La La La prevents free force revival. Baliset blocks movement into a CHOAM-occupied territory, not shipment. Trip to Gamont returns another player’s force during Mentat. Jubba Cloak protects one territory from a moving storm. In the opt-in Kull preview, Kull Wahad intercepts a Karama attempt; only successful settlement activates the phase ban. A distinct counter may prevent it. Canceled Worthless costs remain held.',
|
||||
'Kulon adds one territory of movement range on CHOAM’s turn. La La La prevents free force revival. Baliset blocks movement into a CHOAM-occupied territory, not shipment. Trip to Gamont returns another player’s force during Mentat. Jubba Cloak protects one territory from a moving storm. In the selectable Kull preview, Kull Wahad intercepts a Karama attempt; only successful settlement activates the phase ban. A distinct counter may prevent it. Canceled Worthless costs remain held.',
|
||||
choamCombat:
|
||||
'CHOAM may reserve spice for an ally’s advanced battle support. The ally chooses the payment split when sealing. A traitor victory costs the winner no support; any revealed traitor prevents CHOAM force-payment income.',
|
||||
fullPlan:
|
||||
@@ -499,17 +499,17 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'In ordinary cyclic bidding or native Advanced normal Black Market, provisional first/last changes the current lot only, anchored at the live next bidder. Later circuits use that local cycle. Keep standing bids, allied funding and passes; a passed holder waits for a real raise, and the high bidder has no pending bid to reorder. Every purchase still requires a strict raise. Full effective-cycle no-ops cannot consume the card. The next lot uses its normal physical opener.',
|
||||
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.',
|
||||
'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, provisional first ordering retains an unspent Guild’s native next-boundary take/wait choice; Guild may still act ahead. An already granted current Guild turn cannot be displaced, and a Karama-locked privilege stays locked. Last stays after the Guild even if the Guild later chooses to wait.',
|
||||
'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. The current actor keeps its entire turn and original counters/resources. This does not split your own started turn or interrupt pending card/arrival/payment preparations. Guild retains its next-boundary timing choice. The panel names the actor who must finish.',
|
||||
'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. In Basic, the holder may also provisionally pause its own started turn and resume last, retaining already spent shipment and movement. Pending card, arrival, payment and shared movement preparations must finish first. Advanced Guild retains its next-boundary timing choice.',
|
||||
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
|
||||
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
|
||||
'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.',
|
||||
'Finish existing shipment, movement, card preparation and pending 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.',
|
||||
'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.',
|
||||
'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure, cyclic lot duration and queued movement timing are provisional conventions. Other phase ordering and splitting your own started combined turn remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
|
||||
'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure, cyclic lot duration, queued movement and the Basic own-turn pause are provisional conventions. Other phase ordering and Advanced own-turn suspension remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
|
||||
],
|
||||
example:
|
||||
'Atreides bids 2. Before taking its bid, the Emperor uses Sapho to go last. Richese bids 3, then the Emperor must bid at least 4 to win. Atreides does not receive another bid.',
|
||||
@@ -2998,7 +2998,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Advanced Bene Gesserit may spend Worthless cards as Karama. Each opens a separate native cancellation response and stays spent if the power is denied. In the Semuta preview, its physical cost offers recovery before that response; the card remains Worthless for its new faction. A later printed cancellation cost is a separate fresh event whose recovery does not undo denial. Allowed converted prices retain the original owner/beneficiary and claimed-cost refund boundary. Normal printed Karama has no BG conversion response.',
|
||||
'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.',
|
||||
'Atreides full-plan inspection, Emperor revival, Fremen’s summoned worm, Harkonnen hand exchange the Guild shipment stop and Tleilaxu revival prevention are implemented as once-per-game uses. Other cancellations and special Karamas are still being implemented.',
|
||||
'In the opt-in Kull development preview, another player’s legal ordinary Karama or supported special activation pauses before its costs for CHOAM’s neutral reaction. A declared Kull is pending, not yet a phase ban. Only a different eligible physical Karama can counter it; the original stays reserved. Successful Kull retains the unplayed original, including an advanced Bene Gesserit Worthless card intercepted before conversion, and prevents further Karama activations for that phase in Basic. New Advanced attempts instead restrict that specific effect; phase expiry is provisional. See Kull Wahad for the explicit preview policy and deferred winning-overbid warning.',
|
||||
'In the selectable Kull development preview, another player’s legal ordinary Karama or supported special activation pauses before its costs for CHOAM’s neutral reaction. A declared Kull is pending, not yet a phase ban. Only a different eligible physical Karama can counter it; the original stays reserved. Successful Kull retains the unplayed original, including an advanced Bene Gesserit Worthless card intercepted before conversion, and prevents further Karama activations for that phase in Basic. New Advanced attempts instead restrict that specific effect; phase expiry is provisional. See Kull Wahad for the preview policy and deferred winning-overbid warning.',
|
||||
],
|
||||
related: ['bidding', 'battle', 'advanced-combat', 'special-karama', 'choam-kull', 'nexus-choam-kull'],
|
||||
},
|
||||
@@ -4751,7 +4751,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Six printed effects cover range, revival, movement, storm protection, force return and Kull Wahad’s opt-in Karama reaction.',
|
||||
'Six printed effects cover range, revival, movement, storm protection, force return and Kull Wahad’s selectable Karama reaction.',
|
||||
steps: [
|
||||
'These CHOAM abilities work in basic and advanced games. Choose a printed Worthless card from the phase controls. Its name and intended effect are announced, then a Karama response occurs before discard. A canceled card remains in hand and cannot be retried for its effect during that phase.',
|
||||
'On your own Shipment and Movement turn, play Kulon while a movement remains. It adds one territory to normal movement, including ordinary stronghold ornithopter access. Under the provisional Richese card policy, it does not raise the fixed one-group maximum of three, but extends both normal-range groups. It does not add a movement action or bypass storm/alliance/stronghold rules. Applying the bonus to both Hajr movements remains under audit.',
|
||||
@@ -4759,7 +4759,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Whenever another faction requests a normal revival with a free portion, CHOAM receives a response decision independently of its private hand. Allow the request, or declare La La La for that requester. If the effect succeeds, the whole pending request stops without payment, force movement or quota use; a faction permitted to purchase normal revivals can submit a paid request. If the effect is canceled, the original quote resumes through any Tleilaxu and other applicable responses.',
|
||||
'La La La does not prevent otherwise legal paid revivals or separate Ghola card effects. With Tleilaxu in the game, Fremen may submit a paid request within their current limit after free revival is prevented. Without Tleilaxu, Fremen’s base purchase restriction leaves no normal force return available. Emperor-funded extras remain separate. The prohibition resets when the next Revival phase begins. Card custody is checked at settlement: if CHOAM cashed in the declared card during the response, no Worthless effect occurs and the interrupted revival resumes.',
|
||||
'During Shipment and Movement, use Baliset to select another player and a territory CHOAM occupies. The current implementation prevents that player from entering that territory by movement for the phase while CHOAM remains there; shipment is allowed. A declared move into a CHOAM territory pauses for CHOAM regardless of its private hand. Allow it or declare Baliset. A successful effect leaves the moving forces in place and the movement unspent; a canceled or unavailable card resumes the original move after checking its legality again.',
|
||||
'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has an opt-in Basic lobby preview for CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with the CHOAM and Ix decks, each seated faction’s deck and no other modules; the explicit Nexus Kull profile adds Cunning fuel choices at that same reaction, not in every Nexus game. See both Kull guides for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Complete expansion play remains unfinished.',
|
||||
'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has a selectable Basic preview, on by default in new eligible lobbies, for CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with the CHOAM and Ix decks, each seated faction’s deck and no other modules; the explicit Nexus Kull profile adds Cunning fuel choices at that same reaction, not in every Nexus game. See both Kull guides for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Complete expansion play remains unfinished.',
|
||||
],
|
||||
related: [
|
||||
'faction-choam',
|
||||
@@ -4782,7 +4782,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.',
|
||||
steps: [
|
||||
'In the lobby, the host can opt into this Basic development preview with CHOAM, Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions, the CHOAM and Ix decks, each seated faction’s deck, and no other optional modules. It uses printed ordinary Karama. The preview withholds some structured Truthtrance question types and defers unfunded non-CHOAM Karama-dependent bids; leave it off to retain the ordinary current game. A separate Advanced development profile supports Bene Gesserit substitutions and legal special powers. Existing games are not retrofitted, and the warning is not rules-completion approval.',
|
||||
'In a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. The supported roster includes Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. It uses printed ordinary Karama. The preview withholds some structured questions and defers unfunded non-CHOAM Karama-dependent bids. A separate Advanced development profile supports Bene Gesserit substitutions and legal special powers. Existing lobbies and games are not retrofitted, and this warning does not certify complete rules.',
|
||||
'In the supported Basic printed Kull preview without Richese, existing current-lot ordinary bid, Revival, alliance and bribe Truthtrance questions are available, including supported grouped and mixed forms. A pending Kull response holds their controls while the original transaction resolves. The existing Richese Semuta preview does not yet admit these structured commitments; phase-wide bid and combined turn-action questions and wider interactions remain incomplete.',
|
||||
'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.',
|
||||
'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.',
|
||||
@@ -4797,7 +4797,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
|
||||
related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Opt-in Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Without Richese, supported current-lot bid, Revival, alliance and bribe commitments can coexist with the preview; deferred overbid recovery, phase-wide and whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Without Richese, supported current-lot bid, Revival, alliance and bribe commitments can coexist with the preview; deferred overbid recovery, phase-wide and whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] },
|
||||
|
||||
@@ -8,6 +8,8 @@ export type SaphoMovementOrderContext = {
|
||||
started: readonly string[];
|
||||
/** Present only for Advanced Guild timing. */
|
||||
guild: { player: string; granted: boolean; locked: boolean } | null;
|
||||
/** Explicit Basic clean-boundary prototype; the engine owns all action state. */
|
||||
allowStartedLast?: boolean;
|
||||
};
|
||||
export class SaphoMovementOrderError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -39,6 +41,8 @@ function validate(context: SaphoMovementOrderContext): OrderedOpportunity {
|
||||
identifier(context.guild.player) && queue.eligible.includes(context.guild.player) &&
|
||||
typeof context.guild.granted === 'boolean' && typeof context.guild.locked === 'boolean',
|
||||
'Guild timing must retain its original participant and public grant state.');
|
||||
requireMovement(context.allowStartedLast === undefined || typeof context.allowStartedLast === 'boolean',
|
||||
'The started-turn Sapho scope must be an explicit boolean.');
|
||||
return queue;
|
||||
}
|
||||
function eventFor(queue: OrderedOpportunity): string {
|
||||
@@ -65,13 +69,28 @@ export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, re
|
||||
event: string;
|
||||
player: string;
|
||||
mode: 'first' | 'last';
|
||||
}): { event: string; afterCurrent: string | null; next: OrderedOpportunity } {
|
||||
}): { event: string; afterCurrent: string | null; suspended?: string; next: OrderedOpportunity } {
|
||||
return wrap(() => {
|
||||
const queue = validate(context), event = eventFor(queue);
|
||||
requireMovement(request && request.event === event, 'This Sapho movement opportunity has expired.');
|
||||
requireMovement(request.mode === 'first' || request.mode === 'last', 'Choose first or last.');
|
||||
requireMovement(identifier(request.player) && queue.remaining.includes(request.player),
|
||||
'Only a pending participant may change its movement position.');
|
||||
if (context.started.includes(request.player) && request.player === queue.current &&
|
||||
context.allowStartedLast === true) {
|
||||
requireMovement(context.guild === null, 'The started-turn Sapho preview supports Basic timing only.');
|
||||
requireMovement(request.mode === 'last', 'An already-started turn can only be moved to last.');
|
||||
requireMovement(queue.remaining.length > 1, 'This participant is already last.');
|
||||
// Suspend this same opportunity; never synthesize a completed turn or a
|
||||
// second opportunity. The engine retains its committed action counters.
|
||||
const remaining = [...queue.remaining.slice(1), request.player];
|
||||
const next: OrderedOpportunity = {
|
||||
...queue, eligible: [...queue.eligible], completed: [...queue.completed], remaining,
|
||||
current: remaining[0], currentStarted: false, protectedLast: request.player,
|
||||
};
|
||||
validateOrderedOpportunity(next);
|
||||
return { event, afterCurrent: null, suspended: request.player, next };
|
||||
}
|
||||
requireMovement(!context.started.includes(request.player),
|
||||
'Juice of Sapho cannot split a combined turn that has already started.');
|
||||
const grantedGuild = context.guild && context.guild.player === queue.current &&
|
||||
|
||||
Reference in new issue
Block a user