Connect Basic Tleilaxu to the full Leader Skills prototype
Admit genuine Basic Tleilaxu/base setup with the full fourteen-skill deck, Ix card inventories and native three Face Dancers. Share public profile guards across existing skill effects while retaining public-start and Advanced gates. Record native own-leader skill offers at successful revival commitment so automatic Tleilaxu response chains preserve the optional private draw. Connect Rihani to unrevealed Face Dancer exchange with exact physical/status custody, private controls and existing legal AI choices. Release completed Rihani's live snapshot only after validated Face Dance admission; keep signed history and current census checks. Zoal Smuggler uses opposing printed disc value at reveal, excluding opposing skill bonuses and his separate revival value. Add genuine setup, response/cancellation, private choice, custody, Face Dance reset, Zoal, legal AI and authenticated concurrent recovery regressions. Add a reproducible two-through-six-player sample profile and saved continuation. Preserve unresolved skill effects, foreign-ghola boundaries and delayed AI tuning. Validation: types, lint, 5,058 offline tests and production build passed. Five full games completed with zero rejected actions and 74 JSON restores. Browser QA CRY4DD4J used genuine setup and an explicitly conserved staged battle; real Rihani draw/exchange and same-state refresh passed on mobile and desktop. Screenshot capture timed out; full visual acceptance remains open. No HTTP endpoint/auth/storage implementation changed; authenticated SQLite recovery was verified. All 1,581 prior rooms, 3,349 seats and auxiliary records were preserved. One new QA room/seat remains. No server restart, database reset or automation.
This commit is contained in:
1 parent
489bd3265d
commit
d3559f65ad
28 files changed
+1324
-97
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { basicMoritaniLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
import { basicExpansionLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe';
|
||||
export type BureaucratPaymentSource = {
|
||||
event: string;
|
||||
@@ -38,7 +38,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea
|
||||
return noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length ||
|
||||
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
|
||||
basicMoritaniLeaderSkillsProfile(game));
|
||||
basicExpansionLeaderSkillsProfile(game));
|
||||
}
|
||||
export function bureaucratUsed(
|
||||
used: readonly BureaucratPaymentUse[],
|
||||
|
||||
+39
-12
@@ -1,4 +1,4 @@
|
||||
import { basicMoritaniLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
|
||||
import { basicExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
|
||||
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
|
||||
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
|
||||
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
|
||||
@@ -518,6 +518,7 @@ import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, ty
|
||||
import { FACTIONS, faction, type FactionId } from './catalog';
|
||||
import { quoteLobbyBotConfiguration } from './lobby-bot-configuration';
|
||||
import {
|
||||
battleLeaderStrength,
|
||||
treacheryDeck,
|
||||
normalizeLegacyCardNames,
|
||||
leaders,
|
||||
@@ -1609,7 +1610,7 @@ export type Game = {
|
||||
cardRolesSignature?: string;
|
||||
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
|
||||
sukRescue?: { signature: string; completed: boolean };
|
||||
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean };
|
||||
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean; faceDanceStarted?: true };
|
||||
sandmaster?: { leader: string; key: string; before: number; after: number };
|
||||
smugglerCollection?: SmugglerBattleReceipt;
|
||||
caladanReinforcement?: HomeworldVictoryObligation;
|
||||
@@ -5904,7 +5905,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
const g = structuredClone(state);
|
||||
g.leaderSkills = createLeaderSkills(random);
|
||||
return initializeSetupGameForAudit(g, false, false, false, false, true,
|
||||
g.expansions.length === 1 && g.expansions[0] === 'choam', basicMoritaniLeaderSkillsProfile(g));
|
||||
g.expansions.length === 1 && g.expansions[0] === 'choam', basicExpansionLeaderSkillsProfile(g));
|
||||
}
|
||||
/** Gated development setup for the independent three-card Ecaz variant. */
|
||||
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||
@@ -15527,10 +15528,13 @@ function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null {
|
||||
skills: [b.attacker, b.defender].map(id => battleLeaderSkills(g, getPlayer(g, id))) });
|
||||
const receipts = [b.attacker, b.defender].flatMap(id => {
|
||||
const p = getPlayer(g, id), plan = b.plans[id];
|
||||
const opponent = getPlayer(g, id === b.attacker ? b.defender : b.attacker);
|
||||
const leader = controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead);
|
||||
const opposingLeader = controlledLeaders(g, opponent).find(l => l.id === b.plans[opponent.id].leader && !l.dead);
|
||||
const receipt = nexusRule(() => createSmugglerBattle({ event: b.event!, turn: g.turn,
|
||||
territory: b.territory, player: id, frame, spice: g.spice, supported: smugglerBattleModeSupported(g),
|
||||
plan: { assignments: battleLeaderSkills(g, p),
|
||||
leader: controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead),
|
||||
leader: leader ? { id: leader.id, strength: battleLeaderStrength(leader, opposingLeader) } : undefined,
|
||||
weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense), kwisatz: plan.kwisatz } }));
|
||||
return receipt ? [receipt] : [];
|
||||
});
|
||||
@@ -15618,11 +15622,11 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
|
||||
if (quote.sukGraduate) requireRule(
|
||||
ordinaryLeaderSkillModeSupported(g) &&
|
||||
(basicMoritaniLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
|
||||
(basicExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
|
||||
'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.');
|
||||
if (quote.rihani || quote.sandmaster) requireRule(
|
||||
ordinaryLeaderSkillModeSupported(g) &&
|
||||
(basicMoritaniLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
|
||||
(basicExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
|
||||
'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.');
|
||||
const smuggler = b.smugglerCollection ? nexusRule(() => settleSmugglerBattle(b.smugglerCollection!,
|
||||
!(b.smugglerCollection!.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender), g.spice)) : null;
|
||||
@@ -16253,7 +16257,8 @@ function winnerDiscardsIntegrity(g: Game) {
|
||||
}
|
||||
function rihaniObligationSignature(context: NonNullable<Game['lastBattleContext']>) {
|
||||
return JSON.stringify([context.event, context.turn, context.winner, context.territory,
|
||||
context.rihani?.skill, context.rihani?.cards, context.rihani?.completed]);
|
||||
context.rihani?.skill, context.rihani?.cards, context.rihani?.completed,
|
||||
...(context.rihani?.faceDanceStarted === undefined ? [] : [context.rihani.faceDanceStarted])]);
|
||||
}
|
||||
function pendingRihani(g: Game) {
|
||||
return g.rihaniHistory?.find((r) => r.stage !== 'complete') ?? null;
|
||||
@@ -16281,14 +16286,19 @@ function rihaniIntegrity(g: Game) {
|
||||
nexusRule(() => validateRihani(pending, nexusTraitorUniverse(g), nexusTraitorSnapshot(g)));
|
||||
} else requireRule(!decisions.length, 'The Rihani choice has lost its physical draw receipt.');
|
||||
if (!obligation) return;
|
||||
requireRule(obligation.faceDanceStarted === undefined ||
|
||||
(obligation.faceDanceStarted === true && obligation.completed), 'Invalid saved Rihani aftermath boundary.');
|
||||
requireRule(obligation.signature === rihaniObligationSignature(context!), 'The saved Rihani victory obligation changed.');
|
||||
const receipt = history.find((r) => r.event === context!.event);
|
||||
if (obligation.completed) {
|
||||
requireRule(receipt?.stage === 'complete' && receipt.owner === context!.winner &&
|
||||
receipt.turn === context!.turn && JSON.stringify(receipt.skill) === JSON.stringify(obligation.skill),
|
||||
'The completed Rihani victory lost its receipt.');
|
||||
if (g.phase === 6 && context!.turn === g.turn && !g.battle)
|
||||
nexusRule(() => validateRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g)));
|
||||
if (g.phase === 6 && context!.turn === g.turn && !g.battle) {
|
||||
if (obligation.faceDanceStarted)
|
||||
nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), nexusTraitorUniverse(g)));
|
||||
else nexusRule(() => validateRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g)));
|
||||
}
|
||||
} else {
|
||||
const winner = g.players.find((p) => p.id === context!.winner);
|
||||
requireRule(winner && g.status === 'playing' && g.phase === 6 && !g.battle && context!.turn === g.turn &&
|
||||
@@ -16316,7 +16326,7 @@ function startRihaniVictory(g: Game, winner: Player, territory: string, cards: s
|
||||
if (receipt.peeked.length) log(g, `${winner.name}'s Rihani Decipherer privately inspected two random Traitor Deck cards and shuffled them back. The identities are in that player's private skill history.`, { faction: winner.faction, name: 'Rihani Decipherer' });
|
||||
if (receipt.stage === 'complete') {
|
||||
completeRihaniObligation(g);
|
||||
if (receipt.skill.skilled && !receipt.eligible.length) log(g, `${winner.name} has no unused held Traitor Card to exchange, so Rihani offers no draw.`);
|
||||
if (receipt.skill.skilled && !receipt.eligible.length) log(g, `${winner.name} has no ${winner.faction === 'tleilaxu' ? 'unrevealed Face Dancer' : 'unused held Traitor Card'} to exchange, so Rihani offers no draw.`);
|
||||
return false;
|
||||
}
|
||||
g.decision = { kind: 'rihani', event: receipt.event, player: winner.id, stage: 'offer' };
|
||||
@@ -16341,12 +16351,12 @@ function actRihani(g: Game, player: Player, decision: Extract<Decision, { kind:
|
||||
applyNexusTraitorSnapshot(g, next.state);
|
||||
if (next.stage === 'return') {
|
||||
g.decision = { kind: 'rihani', event: next.event, player: player.id, stage: 'return' };
|
||||
log(g, `${player.name} drew two private Traitor Cards through Rihani and must keep one by revealing an unused old card.`);
|
||||
log(g, `${player.name} drew two private ${player.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} Cards through Rihani and must keep one by revealing an ${player.faction === 'tleilaxu' ? 'unrevealed' : 'unused'} old card.`);
|
||||
} else {
|
||||
completeRihaniObligation(g);
|
||||
if (next.given) {
|
||||
const name = next.given === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : g.players.flatMap((p) => p.leaders).find((l) => l.id === next.given)?.name;
|
||||
log(g, `${player.name} revealed the unused ${name ?? next.given} Traitor Card for Rihani and returned it with the unkept new card to the shuffled Traitor Deck. The kept card remains private.`);
|
||||
log(g, `${player.name} revealed the unused ${name ?? next.given} ${player.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor Card'} for Rihani and returned it with the unkept new card to the shuffled Traitor Deck. The kept card remains private.`);
|
||||
} else log(g, `${player.name} declined Rihani's optional Traitor exchange.`);
|
||||
finishWinner(g, player, context.territory, context.rihani!.cards);
|
||||
}
|
||||
@@ -16597,6 +16607,15 @@ function finishBattle(g: Game) {
|
||||
}
|
||||
}
|
||||
const next = quote.next;
|
||||
const rihani = g.lastBattleContext?.rihani;
|
||||
if (next.kind === 'faceDance' && rihani && !rihani.faceDanceStarted) {
|
||||
// Winner cleanup is complete. Face Dance may now reveal or recycle cards;
|
||||
// the earlier exchange remains immutable history, not current deck custody.
|
||||
requireRule(rihani.completed, 'Finish Rihani before Face Dance.');
|
||||
rihaniIntegrity(g);
|
||||
rihani.faceDanceStarted = true;
|
||||
rihani.signature = rihaniObligationSignature(g.lastBattleContext!);
|
||||
}
|
||||
if ((next.kind === 'faceDance' || next.kind === 'board') &&
|
||||
openHomeworldVictoryReturn(g, next.kind === 'faceDance')) return;
|
||||
if (next.kind === 'choamBattleIncome') {
|
||||
@@ -17151,6 +17170,14 @@ function finishRevival(
|
||||
else delete leader.gholaBy;
|
||||
leader.dead = false;
|
||||
delete leader.concealed;
|
||||
// Automatic Karama response chains may commit after the action's before/after
|
||||
// observer. Record the own-leader offer at the successful revival itself.
|
||||
if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader) &&
|
||||
!leader.capturedBy && p.leaders.some(own => own.id === leader.id)) {
|
||||
requireLeaderSkillRevival(g, p.id);
|
||||
g.leaderSkills = skillRule(() => offerRevivedLeaderSkill(
|
||||
g.leaderSkills!, p.id, leader.id, crypto.randomUUID()));
|
||||
}
|
||||
log(
|
||||
g,
|
||||
`${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.`,
|
||||
|
||||
@@ -28,8 +28,21 @@ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): bool
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
|
||||
}
|
||||
|
||||
/** Native Basic Tleilaxu, including Face Dancers; foreign gholas are Advanced. */
|
||||
export function basicTleilaxuLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return game.advanced === false && game.expansions.length === 1 &&
|
||||
game.expansions[0] === 'ix' && noOtherLeaderSkillModules(game) &&
|
||||
!!game.players?.some(player => player.faction === 'tleilaxu') &&
|
||||
game.players.every(player => player.faction === 'tleilaxu' ||
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
|
||||
}
|
||||
|
||||
export function basicExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game);
|
||||
}
|
||||
|
||||
/** Shared by rule quotes, controls and minimal legal bot participation. */
|
||||
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
|
||||
return noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length || basicMoritaniLeaderSkillsProfile(game));
|
||||
(!game.expansions.length || basicExpansionLeaderSkillsProfile(game));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { basicMoritaniLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { basicExpansionLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import type { Game, GameView } from './engine';
|
||||
|
||||
export type PlanetologistMoveMode = 'range' | 'gather';
|
||||
@@ -41,5 +41,5 @@ export function planetologistRange(
|
||||
/** Keep the existing base-roster boundary and add the connected Basic Moritani profile. */
|
||||
export function planetologistMovementModeSupported(game: Game | GameView): boolean {
|
||||
return game.players.every(player => FACTIONS.some(f => f.id === player.faction && f.expansion === 'base')) ||
|
||||
basicMoritaniLeaderSkillsProfile(game);
|
||||
basicExpansionLeaderSkillsProfile(game);
|
||||
}
|
||||
+23
-1
@@ -4000,6 +4000,28 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tleilaxu-leader-skills',
|
||||
title: 'Tleilaxu with Leader Skills',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'The Basic development combination preserves the full skill deck and private Face Dancers.',
|
||||
steps: [
|
||||
'Basic Tleilaxu with base opponents uses all fourteen skills. Starting cards precede private skill choices and the native three Face Dancers. Other optional modules, Advanced foreign gholas and public starts remain unavailable.',
|
||||
'Rihani draws become unrevealed Face Dancers. Keep one new card by revealing and returning one unrevealed old card; the other new card returns privately. Only the owner sees the inspection history and new identities.',
|
||||
'A successful own-leader revival preserves the optional skill draw after revival responses. Winner skill and card cleanup precede Face Dance; a killed skilled leader returns its card once. Zoal copies the opposing disc value for unmodified Smuggler collection.',
|
||||
'Other unfinished skill effects retain their stated limits. This development combination does not certify the full expansion or every optional-module interaction.',
|
||||
],
|
||||
related: ['leader-skills', 'tleilaxu-face-dancers', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Full setup, response-driven own revival, Rihani Face Dancer exchanges, later death custody and copied Zoal collection are connected; remaining skill effects stay explicit.', evidence: ['game/leader-skill-profile.ts', 'game/rihani-decipherer.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'Private offers, Face Dancer keep/reveal inspectors and saved own-revival choices reuse the table controls.', evidence: ['components/rihani-decipherer.tsx', 'components/leader-skills.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing profiles follow their private legal offers. Full strategy and strength tuning wait for feature completion.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Entry, source contracts and remaining boundaries are recorded.', evidence: ['docs/TLEILAXU_LEADER_SKILLS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Genuine setup, physical custody, revival response chains, private exchange, Face Dance aftermath, Zoal and authenticated recovery have focused checks; full acceptance remains open.', evidence: ['tests/tleilaxu-skills-integration.test.ts', 'tests/tleilaxu-skills-revival.test.ts', 'tests/tleilaxu-skills-recovery.test.ts', 'tests/rihani-face-dancers.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'moritani-leader-skills',
|
||||
title: 'Moritani with Leader Skills',
|
||||
@@ -4051,7 +4073,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Other special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
related: ['moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
|
||||
related: ['tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
|
||||
|
||||
+32
-10
@@ -1,6 +1,6 @@
|
||||
import type { BattleLeaderSkill } from './leader-skill-combat';
|
||||
import { usesSurvivingSkilledLeader } from './leader-skill-combat';
|
||||
import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
|
||||
import { validateNexusTraitorSnapshot, type NexusExchangePlayer, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
|
||||
|
||||
export type RihaniSkill = { leader: string; normal: boolean; skilled: boolean };
|
||||
export type RihaniReceipt = {
|
||||
@@ -18,6 +18,24 @@ function requireRihani(value: unknown, message: string): asserts value {
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const members = (a: readonly string[], b: readonly string[]) =>
|
||||
a.length === b.length && same([...a].sort(), [...b].sort());
|
||||
function eligibleOldCards(owner: NexusExchangePlayer, used: readonly string[]): string[] {
|
||||
return owner.faction === 'tleilaxu'
|
||||
? owner.faceDancers!.filter((card) => !card.revealed).map((card) => card.leader)
|
||||
: owner.traitors.filter((id) => !used.includes(id));
|
||||
}
|
||||
function unchangedHand(owner: NexusExchangePlayer, old: NexusExchangePlayer): boolean {
|
||||
return same(owner.traitors, old.traitors) && same(owner.faceDancers, old.faceDancers);
|
||||
}
|
||||
function augmentedHand(owner: NexusExchangePlayer, old: NexusExchangePlayer, drawn: readonly string[]): boolean {
|
||||
return owner.faction === 'tleilaxu'
|
||||
? same(owner.faceDancers, [...old.faceDancers!, ...drawn.map((leader) => ({ leader, revealed: false }))])
|
||||
: same(owner.traitors, [...old.traitors, ...drawn]);
|
||||
}
|
||||
function exchangedHand(owner: NexusExchangePlayer, old: NexusExchangePlayer, kept: string, given: string): boolean {
|
||||
return owner.faction === 'tleilaxu'
|
||||
? same(owner.faceDancers, [...old.faceDancers!.filter((card) => card.leader !== given), { leader: kept, revealed: false }])
|
||||
: members(owner.traitors, [...old.traitors.filter((id) => id !== given), kept]);
|
||||
}
|
||||
function shuffled<T>(values: readonly T[], rng: () => number): T[] {
|
||||
const result = [...values];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
@@ -49,8 +67,8 @@ export function beginRihani(snapshot: NexusTraitorSnapshot, universe: readonly s
|
||||
identity: Pick<RihaniReceipt, 'event' | 'turn' | 'owner' | 'skill'>, used: readonly string[], rng: () => number): RihaniReceipt {
|
||||
validateNexusTraitorSnapshot(snapshot, universe);
|
||||
const owner = snapshot.players.find((p) => p.id === identity.owner);
|
||||
requireRihani(owner && owner.faction !== 'tleilaxu', 'Rihani Face Dancer exchanges are still being integrated.');
|
||||
const eligible = owner.traitors.filter((id) => !used.includes(id));
|
||||
requireRihani(owner, 'Rihani requires its original card owner.');
|
||||
const eligible = eligibleOldCards(owner, used);
|
||||
const optional = identity.skill.skilled && eligible.length > 0;
|
||||
requireRihani(!(identity.skill.normal || optional) || snapshot.reserve.length >= 2,
|
||||
'Rihani requires two physical cards in the Traitor Deck.');
|
||||
@@ -72,13 +90,13 @@ export function validateRihani(receipt: RihaniReceipt, universe: readonly string
|
||||
validateNexusTraitorSnapshot(receipt.state, universe);
|
||||
const old = receipt.before.players.find((p) => p.id === receipt.owner);
|
||||
const owner = receipt.state.players.find((p) => p.id === receipt.owner);
|
||||
requireRihani(old && owner && old.faction !== 'tleilaxu' && owner.faction === old.faction &&
|
||||
requireRihani(old && owner && owner.faction === old.faction &&
|
||||
same(receipt.before.players.filter((p) => p.id !== receipt.owner), receipt.state.players.filter((p) => p.id !== receipt.owner)),
|
||||
'Rihani changed another player’s physical Traitors.');
|
||||
requireRihani(Array.isArray(receipt.used) && new Set(receipt.used).size === receipt.used.length &&
|
||||
members(receipt.deckAfterPeek, receipt.before.reserve) &&
|
||||
(receipt.skill.normal || same(receipt.deckAfterPeek, receipt.before.reserve)) &&
|
||||
same(receipt.eligible, old.traitors.filter((id) => !receipt.used.includes(id))) &&
|
||||
same(receipt.eligible, eligibleOldCards(old, receipt.used)) &&
|
||||
receipt.peeked.length === (receipt.skill.normal ? 2 : 0) &&
|
||||
new Set(receipt.peeked).size === receipt.peeked.length &&
|
||||
receipt.peeked.every((id) => receipt.before.reserve.includes(id)), 'Rihani lost its original inspection or unused cards.');
|
||||
@@ -86,18 +104,18 @@ export function validateRihani(receipt: RihaniReceipt, universe: readonly string
|
||||
new Set(receipt.drawn).size === receipt.drawn.length && receipt.drawn.every((id) => receipt.before.reserve.includes(id)),
|
||||
'Rihani must draw two distinct physical cards.');
|
||||
if (!receipt.drawn.length) {
|
||||
requireRihani(receipt.stage !== 'return' && !receipt.kept && !receipt.given && same(owner.traitors, old.traitors) &&
|
||||
requireRihani(receipt.stage !== 'return' && !receipt.kept && !receipt.given && unchangedHand(owner, old) &&
|
||||
same(receipt.state.reserve, receipt.deckAfterPeek), 'The undrawn Rihani offer changed hand custody.');
|
||||
} else {
|
||||
requireRihani(receipt.skill.skilled && receipt.eligible.length > 0, 'This Rihani cannot exchange an unused card.');
|
||||
requireRihani(same(receipt.drawn, receipt.deckAfterPeek.slice(0, 2)), 'Rihani did not draw from the saved deck order.');
|
||||
const remaining = receipt.deckAfterPeek.slice(2);
|
||||
if (receipt.stage === 'return') requireRihani(!receipt.kept && !receipt.given &&
|
||||
same(owner.traitors, [...old.traitors, ...receipt.drawn]) && same(receipt.state.reserve, remaining),
|
||||
augmentedHand(owner, old, receipt.drawn) && same(receipt.state.reserve, remaining),
|
||||
'The pending Rihani return lost its drawn cards.');
|
||||
else requireRihani(receipt.stage === 'complete' && receipt.kept && receipt.drawn.includes(receipt.kept) &&
|
||||
receipt.given && receipt.eligible.includes(receipt.given) &&
|
||||
members(owner.traitors, [...old.traitors.filter((id) => id !== receipt.given), receipt.kept]) &&
|
||||
exchangedHand(owner, old, receipt.kept, receipt.given) &&
|
||||
members(receipt.state.reserve, [...remaining, receipt.given, ...receipt.drawn.filter((id) => id !== receipt.kept)]),
|
||||
'The completed Rihani exchange lost its exact new or returned card.');
|
||||
}
|
||||
@@ -114,7 +132,9 @@ export function chooseRihaniDraw(receipt: RihaniReceipt, universe: readonly stri
|
||||
else {
|
||||
requireRihani(next.state.reserve.length >= 2, 'Rihani requires two physical cards in the Traitor Deck.');
|
||||
next.drawn = next.state.reserve.splice(0, 2);
|
||||
next.state.players.find((p) => p.id === next.owner)!.traitors.push(...next.drawn);
|
||||
const owner = next.state.players.find((p) => p.id === next.owner)!;
|
||||
if (owner.faction === 'tleilaxu') owner.faceDancers!.push(...next.drawn.map((leader) => ({ leader, revealed: false })));
|
||||
else owner.traitors.push(...next.drawn);
|
||||
next.stage = 'return';
|
||||
}
|
||||
signed(next); validateRihani(next, universe); return next;
|
||||
@@ -126,7 +146,9 @@ export function finishRihani(receipt: RihaniReceipt, universe: readonly string[]
|
||||
'Keep one newly drawn Traitor and reveal one unused Traitor held before the draw.');
|
||||
const next = structuredClone(receipt), other = next.drawn.find((id) => id !== kept)!;
|
||||
const owner = next.state.players.find((p) => p.id === next.owner)!;
|
||||
owner.traitors = owner.traitors.filter((id) => id !== given && id !== other);
|
||||
if (owner.faction === 'tleilaxu')
|
||||
owner.faceDancers = owner.faceDancers!.filter((card) => card.leader !== given && card.leader !== other);
|
||||
else owner.traitors = owner.traitors.filter((id) => id !== given && id !== other);
|
||||
next.state.reserve = shuffled([...next.state.reserve, given, other], rng);
|
||||
next.kept = kept; next.given = given; next.stage = 'complete';
|
||||
signed(next); validateRihani(next, universe); return next;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Game, GameView } from './engine';
|
||||
import { splitLocation, validGameLocation } from './board';
|
||||
import { faction } from './catalog';
|
||||
import { basicMoritaniLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { basicExpansionLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { sandmasterLeader, sandmasterModeSupported } from './sandmaster-movement';
|
||||
|
||||
export type SandmasterWormCollection = {
|
||||
@@ -26,7 +26,7 @@ export function sandmasterWormCollection(
|
||||
const blocked = (reason: string): SandmasterWormCollection =>
|
||||
({ leader, key: null, before: 0, blocked: reason });
|
||||
if (!sandmasterModeSupported(game) || game.ecazTreachery ||
|
||||
!(basicMoritaniLeaderSkillsProfile(game) ||
|
||||
!(basicExpansionLeaderSkillsProfile(game) ||
|
||||
game.players.every(p => faction(p.faction).expansion === 'base')))
|
||||
return blocked('Sandmaster worm collection with other optional modules is still being integrated.');
|
||||
if (destination === decision.territory ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { basicMoritaniLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
import { basicExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
import type { Card, Leader } from './cards';
|
||||
import { splitLocation, validGameLocation } from './board';
|
||||
import { leaderSkillBattleBonus, usesSurvivingSkilledLeader, type BattleLeaderSkill } from './leader-skill-combat';
|
||||
@@ -11,7 +11,7 @@ export type SmugglerBattleReceipt = {
|
||||
};
|
||||
export function smugglerBattleModeSupported(g: LeaderSkillProfile & { players: readonly { faction: string }[] }) {
|
||||
return ordinaryLeaderSkillModeSupported(g) &&
|
||||
(basicMoritaniLeaderSkillsProfile(g) || g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')));
|
||||
(basicExpansionLeaderSkillsProfile(g) || g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')));
|
||||
}
|
||||
export type SmugglerBattlePlan = {
|
||||
assignments: readonly BattleLeaderSkill[];
|
||||
|
||||
Reference in new issue
Block a user