Connect native Richese normal-purchase Ixian Nexus replacement
This commit is contained in:
1 parent
7ef00e0a65
commit
634ed293af
13 files changed
+720
-33
No files matched your search
+10
-4
@@ -9910,15 +9910,15 @@ function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void {
|
||||
if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Betrayal to prevent the original native ${frame.source.kind} attempt.`);
|
||||
finishIxianNativeAttempt(g,frame.continuation,use);
|
||||
}
|
||||
/** Fresh classic or native Tleilaxu profile; no public start or retrofit. */
|
||||
/** Fresh classic, Tleilaxu or Richese profile; no public start or retrofit. */
|
||||
export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
|
||||
requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement &&
|
||||
state.nexusIxianReplacementHistory === undefined && state.nexusIxianReplacementCursor === undefined &&
|
||||
ixianNexusReplacementModeSupported(state),
|
||||
'Ixian Nexus replacement needs a fresh classic/base or Tleilaxu/Ix lobby with Nexus alone.');
|
||||
'Ixian Nexus replacement needs a fresh classic/base, Tleilaxu/Ix or Richese/CHOAM lobby with Nexus alone.');
|
||||
const g = initializeNexusGameForAudit(state);
|
||||
// Ix supplies Treachery here, not the optional Sandtrout Spice card.
|
||||
if (g.expansions.length) g.spiceDeck = shuffle(spiceDeck());
|
||||
if (g.expansions.includes('ix')) g.spiceDeck = shuffle(spiceDeck());
|
||||
g.nexusIxianReplacementPreview = true;
|
||||
g.pendingNexusIxianReplacement = null;
|
||||
g.nexusIxianReplacementHistory = [];
|
||||
@@ -9931,7 +9931,10 @@ function ixianNexusReplacementModeSupported(g: Game): boolean {
|
||||
((!g.expansions.length && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true)) ||
|
||||
(g.expansions.length === 1 && g.expansions[0] === 'ix' &&
|
||||
g.players.some(p => p.faction === 'tleilaxu') &&
|
||||
g.players.every(p => p.faction === 'tleilaxu' || CLASSIC_FACTIONS[p.faction] === true))) &&
|
||||
g.players.every(p => p.faction === 'tleilaxu' || CLASSIC_FACTIONS[p.faction] === true)) ||
|
||||
(g.expansions.length === 1 && g.expansions[0] === 'choam' &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
g.players.every(p => p.faction === 'richese' || CLASSIC_FACTIONS[p.faction] === true))) &&
|
||||
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
|
||||
!g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.mobileStronghold && !g.sandtrout &&
|
||||
!g.kullPreview && !g.nexusKullPreview && !g.semutaPreview && !g.richeseBetrayalPreview &&
|
||||
@@ -9942,11 +9945,14 @@ function ixianNexusReplacementParent(g: Game): string {
|
||||
order:g.order,active:g.active,auction:g.auction,sale:g.currentAuctionSale,
|
||||
players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand})),
|
||||
deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards,
|
||||
...(byFaction(g, 'richese') ? { richeseBidding:g.richeseBidding,
|
||||
richeseCache:g.richeseCache,richeseRemoved:g.richeseRemoved } : {}),
|
||||
decision:g.decision,response:g.response,phaseOpening:g.phaseOpening});
|
||||
}
|
||||
function ixianNexusReplacementContext(g: Game) {
|
||||
requireRule(g.auction, 'The Nexus purchased-card choice lost its normal auction.');
|
||||
return {status:g.status,phase:g.phase,turn:g.turn,sequence:g.nexusIxianReplacementCursor!.sequence,
|
||||
expansions:g.expansions,richese:!!byFaction(g,'richese'),
|
||||
auction:g.auction,sale:g.currentAuctionSale ?? null,
|
||||
players:g.players.map(p => ({id:p.id,hand:p.hand,handLimit:handLimit(p)})),
|
||||
physicalCards:physicalTreacheryCards(g)};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { treacheryDeck, type Card } from './cards';
|
||||
import { baseDeck, choamDeck, ixDeck, type Card } from './cards';
|
||||
import { richeseCards } from './richese-cards';
|
||||
|
||||
export class IxianReplacementError extends Error {}
|
||||
|
||||
@@ -7,6 +8,8 @@ export type IxianReplacementContext = {
|
||||
phase: number;
|
||||
turn: number;
|
||||
sequence: number;
|
||||
expansions: readonly string[];
|
||||
richese: boolean;
|
||||
auction: {
|
||||
cards: readonly Card[];
|
||||
index: number;
|
||||
@@ -51,8 +54,16 @@ export type IxianReplacementReceipt = {
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const canonical: Readonly<Record<string, Card>> =
|
||||
Object.fromEntries(treacheryDeck(['ix']).map(card => [card.id, card]));
|
||||
// CHOAM shares two physical Ix identities; family membership follows the actual
|
||||
// catalogues rather than identity prefixes. Build these once, never per card.
|
||||
function catalogue(cards: readonly Card[]): Readonly<Record<string, Card>> {
|
||||
return Object.fromEntries(cards.map(card => [card.id, card]));
|
||||
}
|
||||
const base = catalogue(baseDeck());
|
||||
const ix = catalogue(ixDeck());
|
||||
const choam = catalogue(choamDeck());
|
||||
const cache = catalogue(richeseCards());
|
||||
const canonical: Readonly<Record<string, Card>> = { ...base, ...ix, ...choam, ...cache };
|
||||
const genesis = 'ixian-replacement:0';
|
||||
const plain = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value) &&
|
||||
@@ -76,6 +87,14 @@ function canonicalCard(value: unknown): value is Card {
|
||||
value.name === expected.name && value.kind === expected.kind &&
|
||||
value.effect === expected.effect;
|
||||
}
|
||||
function selectedCard(value: unknown, ctx: IxianReplacementContext): value is Card {
|
||||
return canonicalCard(value) && (
|
||||
Object.hasOwn(base, value.id) ||
|
||||
(Object.hasOwn(ix, value.id) && ctx.expansions.includes('ix')) ||
|
||||
(Object.hasOwn(choam, value.id) && ctx.expansions.includes('choam')) ||
|
||||
(Object.hasOwn(cache, value.id) && ctx.expansions.includes('choam') && ctx.richese)
|
||||
);
|
||||
}
|
||||
function sameCard(one: Card, two: Card): boolean {
|
||||
return one.id === two.id && one.name === two.name &&
|
||||
one.kind === two.kind && one.effect === two.effect;
|
||||
@@ -116,9 +135,9 @@ function validateSavedSource(value: unknown): asserts value is IxianReplacementS
|
||||
'The replacement purchase receipt changed its original source.',
|
||||
);
|
||||
}
|
||||
function validatePhysical(physical: readonly Card[]): void {
|
||||
function validatePhysical(physical: readonly Card[], ctx?: IxianReplacementContext): void {
|
||||
requireReplacement(
|
||||
Array.isArray(physical) && physical.every(canonicalCard) &&
|
||||
Array.isArray(physical) && physical.every(card => ctx ? selectedCard(card, ctx) : canonicalCard(card)) &&
|
||||
new Set(physical.map(card => card.id)).size === physical.length,
|
||||
'Replacement requires canonical cards with single physical custody.',
|
||||
);
|
||||
@@ -143,14 +162,14 @@ export function validateIxianReplacementSource(
|
||||
sale.winner === source.buyer && sale.amount === source.amount && sale.free === source.free &&
|
||||
auction.bidder === source.buyer && auction.bid === source.amount &&
|
||||
whole(auction.index) && auction.index === source.auctionIndex && Array.isArray(auction.cards) &&
|
||||
canonicalCard(auction.cards[auction.index]) && sameCard(auction.cards[auction.index], source.card),
|
||||
selectedCard(auction.cards[auction.index], ctx) && sameCard(auction.cards[auction.index], source.card),
|
||||
'Replacement must retain its exact original normal auction purchase.',
|
||||
);
|
||||
validatePhysical(ctx.physicalCards);
|
||||
validatePhysical(ctx.physicalCards, ctx);
|
||||
requireReplacement(
|
||||
array(ctx.players) && ctx.players.every(player =>
|
||||
plain(player) && text(player.id) && whole(player.handLimit) && player.handLimit > 0 &&
|
||||
Array.isArray(player.hand) && player.hand.every(canonicalCard)) &&
|
||||
Array.isArray(player.hand) && player.hand.every(card => selectedCard(card, ctx))) &&
|
||||
new Set(ctx.players.map(player => player.id)).size === ctx.players.length,
|
||||
'Replacement needs its original buyer and canonical hand custody.',
|
||||
);
|
||||
|
||||
+6
-6
@@ -4513,7 +4513,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'In the fresh local ixian-replacement preview, an unallied holder with Ixians absent may spend the physical Ixian Nexus to discard exactly the card just bought in a normal auction and draw the next Treachery deck card privately.',
|
||||
searchText: 'Ixian Nexus Secret Ally purchased card replacement normal auction Karama full hand empty deck same card',
|
||||
steps: [
|
||||
'This fresh local profile admits two-to-six unique classic factions with base Treachery or native Tleilaxu plus classics with original Ix47, in Basic or Advanced, with Ixians absent and Nexus alone. Optional Sandtrout is omitted only at fresh native setup. Original native setup, Face Dancers, actual qualifying Nexus draw and normal purchased-card controls remain. Other native expansions, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other overlays remain guarded. No public start or played-save upgrade.',
|
||||
'This fresh local profile admits two-to-six unique seats in Basic/Advanced: classics with base33, native Tleilaxu plus classics with Ix47, or native Richese plus classics with CHOAM35 and its separate ten-card cache, always with Ixians absent and Nexus alone. Only normal-auction purchases qualify; original cache setup/auctions remain distinct from a cache-origin replacement. Optional Sandtrout is omitted only for fresh native Ix setup. Other natives, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and overlays remain guarded. No public start or played-save upgrade.',
|
||||
'Nexus has no starting deal. A draw requires a qualifying closing Nexus with an alliance. A two-seat profile can continue its base game but cannot naturally create an unallied Secret Ally holder; actual replacement needs at least three seats, or four for two simultaneous unallied recipients.',
|
||||
'The photographed publisher face grants an independent Secret Ally operation when Ixians are absent. Remain unallied and hold the actual singleton Ixian Nexus. Native Ixian allied replacement is a different cancelable advantage and is not removed or used as a fallback. The adopted unofficial Advanced rulebook supplies relevant core rules, not this absent Nexus face or a new Nexus ruling.',
|
||||
'Wait for a genuine normal-auction purchase, paid with spice or through Karama, including direct acquisition before a positive bid. A successful native Advanced BG Worthless-as-Karama conversion gets the same independent replacement choice after its native counter resolves. Canceling direct acquisition supplies no purchase; a canceled winning-payment conversion follows native funded spice recovery and, if bought, supplies a paid receipt instead. Cache, Black Market and special-purchase origins retain their separate guarded Nexus scope. Gifts, bonus draws, Technology swaps and old hand cards are not purchases.',
|
||||
@@ -4529,11 +4529,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
example: 'You buy a card for four spice and inspect it privately. Pass keeps it and the Nexus. Use keeps the original four-spice purchase settled, spends Ixian Nexus, discards that exact card and draws the next deck card. If the draw deck is empty, its new discard joins the actual recycled pile and can be drawn again.',
|
||||
related: ['nexus-cards', 'bidding', 'ix-technology', 'card-karama', 'alliance-funding', 'privacy', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'The bounded profile binds the exact normal purchase, physical replacement and closed history to native settlement. BG conversion and paid recovery remain source-specific. Harkonnen composes replacement before original income/bonus, preserving native bonus cancellation and capacity. Special origins and combined modules remain guarded.', evidence: ['game/nexus-ixian-replacement.ts', 'game/engine.ts', 'tools/prototype-room.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Neutral Purchased card choice separates buyer-only inspection and Use/Keep from public waiting. Harkonnen sees the provisional replacement-before-income/bonus explanation. Two original390px journeys exercise Use then bonus cancellation, or Keep then one allowed bonus; no overflow/browser error. Broader acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal legal policies consume only their own projected event while the underlying auction waits. Eight original actions cover BG conversion, eight cover Harkonnen bonus continuation, and eight cover Advanced native Tleilaxu-profile buyers. Full strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed physical operation, settlement, full-hand/empty-deck rules and explicit provisional Harkonnen replacement-before-bonus policy are recorded. Cache scope, competing effects and complete-family acceptance remain separate; no publisher priority is invented.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Earlier evidence remains historical. BG composition passes affected74/74 and Harkonnen64/64, with their original runtime/control proof. Native Tleilaxu now exercises both modes:7 native cases and affected47/47; four original Advanced Use/Keep paths and eight legal policy actions preserve actual Thumper/Amal purchase, income and next/final-lot continuation. CLI help names both modes. No new UI or saved/deployed assurance; full families/modules remain open.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/fixture-native-ixian-replacement.ts', 'tests/native-ixian-replacement.test.ts', 'tests/bg-ixian-replacement.test.ts', 'tests/fixture-bg-ixian-replacement.ts', 'tests/harkonnen-ixian-replacement.test.ts', 'tests/fixture-harkonnen-ixian-replacement.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Normal purchase, physical replacement and closed history bind original settlement. Native Richese adds selected CHOAM35/cache custody without enabling special-origin sales; unselected card families still reject. BG conversion/paid recovery and Harkonnen replacement-before-bonus remain source-specific. Other modules remain guarded.', evidence: ['game/nexus-ixian-replacement.ts', 'game/engine.ts', 'tools/prototype-room.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Neutral Purchased card choice separates buyer-only inspection and Use/Keep from waiting. Original390px Richese-family normal purchases exercise Basic Atreides Use and Advanced Richese Keep, preserving original income/cache and next lot. Two tool-only stale-node races were recovered without replay; no application error or overflow. Harkonnen retains its visible provisional ordering. Broader acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal legal policies use only their own projected event while native settlement waits. Sixteen original-state actions cover native Richese normal purchases across both modes and buyer factions; earlier BG/Harkonnen/Tleilaxu paths remain source-qualified in the contract. Full strategy/calibration remain unfinished.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed replacement, original settlement, selected catalogue/cache membership, full-hand/same-card semantics and provisional Harkonnen order are documented. Native Richese normal purchases are distinct from still-gated cache/Black Market/special origins, competing effects and full-family acceptance; no publisher priority is invented.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Earlier source-qualified evidence remains in the contract. Native Richese adds six selected-catalogue and twelve original behavior cases in affected65/65, eight original runtime paths, sixteen legal policy actions and two390px normal-purchase journeys. Original final-lot/cache continuation and the still-gated real cache purchase are exercised. Source repairs follow actual automatic response advancement; typing annotations do not alter behavior. No new build, saved/deployed or broad assurance; all families/modules remain Partial.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/fixture-native-ixian-replacement.ts', 'tests/native-ixian-replacement.test.ts', 'tests/bg-ixian-replacement.test.ts', 'tests/fixture-bg-ixian-replacement.ts', 'tests/harkonnen-ixian-replacement.test.ts', 'tests/fixture-harkonnen-ixian-replacement.ts', 'tests/richese-ixian-replacement.test.ts', 'tests/fixture-richese-ixian-replacement.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user