Allow Richese No-Field reveal during Basic openings and markets
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
ac7db3cb35
commit
0d55333d5d
9 files changed
+617
-3
No files matched your search
+14
-1
@@ -664,6 +664,7 @@ import { resolveBattleWeapons } from './effective-weapons';
|
||||
import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
|
||||
import { quoteAdvisorPreparationWindow, quoteAdvisorPreparation, validateAdvisorPreparation, projectAdvisorPreparationWindow, AdvisorPreparationError, type AdvisorPreparationWindow, type AdvisorPreparationSource } from './advisor-preparation-window';
|
||||
import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, projectFremenGroupRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant';
|
||||
import { richeseRevealInterruptSupported } from './richese-reveal-interruption';
|
||||
import { splitOrdinaryPayments, type OrdinaryPayment } from './ordinary-payment-plan';
|
||||
import {
|
||||
charityAmount,
|
||||
@@ -4905,7 +4906,8 @@ function noFieldRevealBlock(g: Game, p: Player): string | null {
|
||||
if (g.status !== 'playing' || g.phase >= 6)
|
||||
return 'Voluntary No-Field reveal is available before the Battle phase.';
|
||||
if (!p.noField?.deployed) return 'No concealed No-Field is deployed.';
|
||||
if (g.truthtrance || g.phaseOpening || g.response || g.decision)
|
||||
if ((g.truthtrance || g.phaseOpening || g.response || g.decision) &&
|
||||
!richeseRevealInterruptSupported(g))
|
||||
return 'Resolve the pending interaction before revealing the No-Field.';
|
||||
return null;
|
||||
}
|
||||
@@ -5813,6 +5815,7 @@ export function createGame(
|
||||
spiceDiscard: [[], []],
|
||||
spice: {},
|
||||
log: [{ seq: 1, text: `${host.name} opened the table.` }],
|
||||
truthHistory: [],
|
||||
stormDials: {},
|
||||
stormPending: null,
|
||||
stormCard: null,
|
||||
@@ -43285,6 +43288,16 @@ function applyActionInner(
|
||||
);
|
||||
requireRule(g.status !== 'finished', 'This game has ended.');
|
||||
const t = action.type;
|
||||
if (t === 'revealNoField' && richeseRevealInterruptSupported(g)) {
|
||||
requireRule(p.faction === 'richese', 'Only Richese controls the concealed token.');
|
||||
const blocked = noFieldRevealBlock(g, p);
|
||||
requireRule(!blocked, blocked ?? 'Voluntary reveal is unavailable.');
|
||||
requireRule(Object.keys(action).sort().join(',') === 'event,token,type' &&
|
||||
action.event === p.noFieldEvent && action.token === p.noField?.deployed?.tokenId,
|
||||
'This No-Field reveal selection is stale.');
|
||||
revealPlayerNoField(g, p, 'voluntary');
|
||||
return g;
|
||||
}
|
||||
if (g.pendingEmperorAuctionGift && !g.response && !g.pendingKarama) {
|
||||
const pending = g.pendingEmperorAuctionGift;
|
||||
if (t === 'advanceBots') return g;
|
||||
|
||||
+6
-2
@@ -1127,6 +1127,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
|
||||
'In Basic play, your own single concealed No-Field may provisionally ship into an ally’s territory if it can legally enter; move it and any paid accompanying forces out before your turn ends. Paid companions require the High Richese Homeworld power. If the marker remains, reveal it and send its newly materialized forces to Tanks under the provisional no-departure policy. A zero marker reveals but contributes no casualties. Existing physical forces and the ally are not part of that new-visitor loss. Paired markers keep their separate guards.',
|
||||
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
|
||||
'In supported Basic native games, voluntary revelation is also available during an Ix phase opening or selected auction and market decisions without replacing that decision. Other interruption windows remain unfinished.',
|
||||
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
|
||||
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible Homeworlds, alone or with selected physical Richese counters from the marker’s current World; its own Homeworld is an eligible endpoint only in Advanced. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal; available reserves are placed there unless it is already on its own World, where the physical pool stays unchanged. Only one marker may be deployed globally. Fresh placement from the screen to your own Homeworld and optional-module combinations remain unavailable; an Advanced native return uses the provisional physical-reserve interpretation described below. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
|
||||
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
|
||||
@@ -1142,7 +1143,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Basic and Advanced ordinary mixed battles have a provisional private pool: physical forces already here plus the token value capped by current reserves. The Battle Wheel labels that interpretation explicitly. Both plans seal before materialization, then native support, casualties and aftermath use actual forces. This first version does not adjudicate the FAQ mixed dial cap.',
|
||||
'In supported card profiles, provisional materialization-first order forecasts the own marker privately without moving forces early. Both plans seal, the original token reveals and materializes its reserve-limited ordinary counters at its sector, then Harass selects/returns actual undialed counters. Reinforcements must leave three original reserves after that reveal and before any future Harass return; an unfunded card cannot seal a plan. Marker0, last-used identity and original reveal-event history remain native.',
|
||||
'Concealed collection, occupancy and control use one-force presence. Exposed storm sectors and unprotected worm destruction reveal and remove the resulting actual forces. Storm shelter and other sectors remain separate. Trip to Gamont at a location with a marker reveals it, even if ordinary forces also occupy that sector, then returns one actual force if present. It is still used if zero forces remain. Canceling the CHOAM power leaves the marker concealed.',
|
||||
'All four AI profiles choose from their own private token inventory, pay only the one-force shipment price, move the marker explicitly and reveal positive tokens when useful. They retain zero-token presence and use only public information to assess opponents. They also offer safe positive tokens from their own inventory during the allied opportunity, fund them with their own spice and accept or decline private offers using their own force and payment information. A human Richese player’s offer window holds an AI ally’s shipment. Pending responses, decisions and Truthtrance retain priority.',
|
||||
'All four AI profiles choose from their own private token inventory, pay only the one-force shipment price, move the marker explicitly and reveal positive tokens when useful. They retain zero-token presence and use only public information to assess opponents. They also offer safe positive tokens from their own inventory during the allied opportunity, fund them with their own spice and accept or decline private offers using their own force and payment information. A human Richese player’s offer window holds an AI ally’s shipment. Unimplemented interruptions, including pending responses and Truthtrance, retain priority.',
|
||||
'Marker-only and ordinary-plus-marker No-Fields compose with the selected Advanced Occupy profile under the same provisional additive pool. Either selected lead gets its actual variable-owner quote; original fixed Ecaz strength stays separate. Prevention restores only the chosen lead’s own pool. Basic mixed co-side, Homeworld custody, wider Truthtrance and other profile/module boundaries remain. Complete Richese starts and final acceptance stay gated.',
|
||||
],
|
||||
related: [
|
||||
@@ -1159,7 +1160,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range or two-origin gathering uses current trainer/token/event and exact per-origin route/entry. Provisional Guild cross and actual Secret Ally cross/face-down return keep marker custody/history distinct from physical counters, with one rounded price and native response continuations. Broader special transports remain separate.',
|
||||
evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts', 'game/guild-no-field-selection.ts', 'game/transport-quote.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-guild-secret-ally.ts'],
|
||||
evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/richese-reveal-interruption.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts', 'game/guild-no-field-selection.ts', 'game/transport-quote.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-guild-secret-ally.ts'],
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
@@ -1201,6 +1202,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/planetologist-movement.test.ts',
|
||||
'tests/planetologist-movement-controls.test.tsx',
|
||||
'tests/richese-no-field.test.ts',
|
||||
'tests/richese-reveal-interruption-runtime.test.ts',
|
||||
'tests/richese-reveal-interruption-controls.test.tsx',
|
||||
'tests/richese-reveal-interruption-privacy.test.ts',
|
||||
'tests/richese-no-field-engine.test.ts',
|
||||
'tests/richese-no-field-review.test.ts',
|
||||
'tests/richese-no-field-bots.test.ts',
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Game } from './engine';
|
||||
|
||||
const factions = new Set([
|
||||
'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen',
|
||||
'richese', 'choam', 'ixians', 'tleilaxu',
|
||||
]);
|
||||
|
||||
/** A deliberately bounded admission for an existing before-Battle parent.
|
||||
* This does not authorize a seat, marker or event, and does not mutate the
|
||||
* game. The normal No-Field action and view own those checks. */
|
||||
export function richeseRevealInterruptSupported(g: Game): boolean {
|
||||
if (g.status !== 'playing' || g.advanced || !Number.isSafeInteger(g.phase) ||
|
||||
g.phase < 0 || g.phase >= 6 || !Number.isSafeInteger(g.turn) || g.turn < 1 ||
|
||||
!Array.isArray(g.expansions) || !g.expansions.includes('choam') ||
|
||||
g.expansions.some(deck => deck !== 'choam' && deck !== 'ix') ||
|
||||
new Set(g.expansions).size !== g.expansions.length ||
|
||||
!Array.isArray(g.players) || !g.players.every(player => factions.has(player.faction)) ||
|
||||
g.players.some(player => (player.faction === 'ixians' || player.faction === 'tleilaxu') && !g.expansions.includes('ix')) ||
|
||||
g.coalitionState || g.coalitionPreview || g.techTokens || g.strongholdCards || g.leaderSkills ||
|
||||
g.homeworlds || g.nexusCards || g.discoveryEnabled || g.discoveries || g.discoveryStash ||
|
||||
g.greatMaker || g.sandtrout || g.ecazTreachery || g.truthtrance || g.response ||
|
||||
g.biddingEnd || g.battle || g.stormResolution || g.spiceResolution || g.spiceSequence ||
|
||||
g.victoryContinuation || g.currentAuctionSale || g.richeseAllyOffer ||
|
||||
g.bureaucratPayments?.pending) return false;
|
||||
|
||||
// A mixed Truthtrance answer can create a private residual only for some
|
||||
// hidden facts. Fence the whole public turn's history uniformly before
|
||||
// examining those ledgers, so this projected availability cannot reveal
|
||||
// which private branch was required.
|
||||
// Historical saves without a complete public history retain their existing
|
||||
// interruption behavior; fresh games initialize an explicit empty history.
|
||||
if (!Array.isArray(g.truthHistory) || g.truthHistory.some(entry => entry.turn === g.turn)) return false;
|
||||
|
||||
// Each pending field is an active child/continuation, not a completed
|
||||
// receipt. Empty pending arrays do not represent a live child.
|
||||
if (Object.entries(g).some(([key, value]) => key.startsWith('pending') &&
|
||||
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return false;
|
||||
|
||||
const live = (rows: readonly {turn: number; fulfilled?: boolean; released?: boolean}[] | undefined) =>
|
||||
!!rows?.some(row => row.turn === g.turn && !row.fulfilled && !row.released);
|
||||
if (live(g.charityPromises) || live(g.travelPromises) || live(g.turnActionPromises) ||
|
||||
live(g.shipmentPromises) || live(g.bidPromises) || live(g.phaseBidPromises) ||
|
||||
live(g.richeseBidPromises) || live(g.earlyRicheseBidPromises) || live(g.revivalPromises) ||
|
||||
live(g.leaderRevivalPromises) || live(g.revivalExpressionPromises) || live(g.bribePromises) ||
|
||||
live(g.pledgePromises) || live(g.bribeExpressionPromises) || live(g.alliancePromises) ||
|
||||
live(g.allianceExpressionPromises) || live(g.movementPromises) || live(g.movementExpressionPromises) ||
|
||||
g.futureBattleLeaderPromises?.some(row => row.turn === g.turn && (row.status === 'waiting' || row.status === 'bound')) ||
|
||||
g.deals?.some(deal => deal.status === 'active')) return false;
|
||||
|
||||
// Mixed-question receipts can own binding state separately from the ordinary
|
||||
// residual lists. Conservatively retain their whole current-turn boundary.
|
||||
const mixed = [g.mixedBidQuestions, g.mixedRevivalQuestions, g.mixedBribeQuestions,
|
||||
g.mixedAllianceQuestions, g.mixedMovementQuestions, g.mixedTraitorQuestions, g.mixedBattleQuestions];
|
||||
if (mixed.some(rows => rows?.some(row => row.turn === g.turn && row.binding))) return false;
|
||||
|
||||
if (g.phaseOpening)
|
||||
return g.expansions.includes('ix') && !!g.amalWindowOpen && !g.decision && !g.choamMarket;
|
||||
const decision = g.decision;
|
||||
if (!decision) return false;
|
||||
if (decision.kind === 'auctionPayment')
|
||||
return g.phase === 3 && !!g.auction && g.auction.bidder === decision.player && !g.richeseAuction && !g.choamMarket;
|
||||
if (decision.kind === 'richeseDeclaration' || decision.kind === 'richeseCacheTerms' ||
|
||||
decision.kind === 'richeseCache' || decision.kind === 'richeseUnbid') {
|
||||
const round = g.richeseBidding;
|
||||
if (g.phase !== 3 || !round || round.turn !== g.turn || round.owner !== decision.player || g.choamMarket)
|
||||
return false;
|
||||
if (decision.kind === 'richeseDeclaration') return round.stage === 'declaration' && !g.richeseAuction;
|
||||
if (decision.kind === 'richeseUnbid') return round.stage === 'lot' && g.richeseAuction?.source === 'cache' &&
|
||||
g.richeseAuction.outcome?.kind === 'unbid';
|
||||
return round.stage === 'cacheOffer' && !g.richeseAuction;
|
||||
}
|
||||
if (decision.kind === 'choamMarket' || decision.kind === 'choamTradeReply' || decision.kind === 'choamTradeConfirm') {
|
||||
const market = g.choamMarket;
|
||||
if (!market || market.sale) return false;
|
||||
if (decision.kind === 'choamMarket') return market.owner === decision.player && !market.trade;
|
||||
if (!market.trade) return false;
|
||||
return decision.kind === 'choamTradeReply'
|
||||
? market.trade.ally === decision.player && !market.trade.returned
|
||||
: market.owner === decision.player && !!market.trade.returned;
|
||||
}
|
||||
return decision.kind === 'richeseAllyOpportunity' && g.phase === 5 &&
|
||||
g.richeseAllyOpportunity?.turn === g.turn &&
|
||||
g.richeseAllyOpportunity.recipient === decision.recipient;
|
||||
}
|
||||
Reference in new issue
Block a user