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.
77 lines
5.0 KiB
TypeScript
77 lines
5.0 KiB
TypeScript
import { FACTIONS } from './catalog';
|
|
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';
|
|
|
|
export type SmugglerBattleReceipt = {
|
|
event: string; turn: number; territory: string; player: string; leader: string;
|
|
frame: string; strength: number; key: string | null; before: number; amount: number;
|
|
stage: 'pending' | 'collected' | 'void'; signature: string;
|
|
};
|
|
export function smugglerBattleModeSupported(g: LeaderSkillProfile & { players: readonly { faction: string }[] }) {
|
|
return ordinaryLeaderSkillModeSupported(g) &&
|
|
(basicExpansionLeaderSkillsProfile(g) || g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')));
|
|
}
|
|
export type SmugglerBattlePlan = {
|
|
assignments: readonly BattleLeaderSkill[];
|
|
leader?: Pick<Leader, 'id' | 'strength'>;
|
|
weapon?: Card; defense?: Card; kwisatz?: boolean;
|
|
};
|
|
export function smugglerBattlePlanBlock(plan: SmugglerBattlePlan, supported: boolean,
|
|
pile?: { territory: string; spice: Readonly<Record<string, number>> }): string | null {
|
|
if (!usesSurvivingSkilledLeader(plan.assignments, 'smuggler', plan.leader?.id, true)) return null;
|
|
if (!supported) return 'Smuggler battle collection with expansion factions or other optional modules is still being integrated.';
|
|
if (plan.kwisatz || leaderSkillBattleBonus({ assignments: plan.assignments,
|
|
selectedLeader: { id: plan.leader!.id, kind: 'disc' }, weapon: plan.weapon,
|
|
defense: plan.defense, skilledLeaderSurvives: true }).bonus)
|
|
return 'Smuggler collection with modified leader strength awaits its ruling. Choose an unmodified plan or another leader.';
|
|
if (pile) {
|
|
try { smugglerBattlePile(pile.territory, pile.spice); }
|
|
catch (error) { return error instanceof Error ? error.message : 'Smuggler spice is unavailable.'; }
|
|
}
|
|
return null;
|
|
}
|
|
export function smugglerBattleSignature(receipt: SmugglerBattleReceipt): string {
|
|
return JSON.stringify({ ...receipt, signature: undefined });
|
|
}
|
|
export function smugglerBattlePile(territory: string, spice: Readonly<Record<string, number>>) {
|
|
const piles = Object.entries(spice).filter(([key]) => splitLocation(key).territory === territory);
|
|
for (const [key, amount] of piles) {
|
|
const at = splitLocation(key);
|
|
if (!validGameLocation({}, at.territory, at.sector) || key !== `${at.territory}:${at.sector}` ||
|
|
!Number.isSafeInteger(amount) || amount < 0) throw new Error('Smuggler needs a valid spice pile.');
|
|
}
|
|
const positive = piles.filter(([, amount]) => amount > 0);
|
|
if (positive.length > 1) throw new Error('Smuggler collection among multiple spice piles awaits its allocation ruling.');
|
|
return positive.length ? { key: positive[0][0], before: positive[0][1] } : { key: null, before: 0 };
|
|
}
|
|
export function createSmugglerBattle(input: {
|
|
event: string; turn: number; territory: string; player: string; frame: string;
|
|
plan: SmugglerBattlePlan; supported: boolean; spice: Readonly<Record<string, number>>;
|
|
}): SmugglerBattleReceipt | null {
|
|
if (!usesSurvivingSkilledLeader(input.plan.assignments, 'smuggler', input.plan.leader?.id, true)) return null;
|
|
const blocked = smugglerBattlePlanBlock(input.plan, input.supported);
|
|
if (blocked) throw new Error(blocked);
|
|
const strength = input.plan.leader!.strength;
|
|
if (!input.event || !input.player || !Number.isSafeInteger(input.turn) || input.turn < 1 ||
|
|
!Number.isSafeInteger(strength) || strength < 0) throw new Error('Smuggler needs the current battle and an unmodified leader.');
|
|
const pile = smugglerBattlePile(input.territory, input.spice);
|
|
const receipt: SmugglerBattleReceipt = { event: input.event, turn: input.turn,
|
|
territory: input.territory, player: input.player, leader: input.plan.leader!.id,
|
|
frame: input.frame, strength, ...pile, amount: Math.min(strength, pile.before), stage: 'pending', signature: '' };
|
|
receipt.signature = smugglerBattleSignature(receipt);
|
|
return receipt;
|
|
}
|
|
/** Survival controls both winning and losing leaders; this is not bank/player income. */
|
|
export function settleSmugglerBattle(receipt: SmugglerBattleReceipt, survives: boolean, spice: Readonly<Record<string, number>>) {
|
|
if (receipt.stage !== 'pending' || receipt.signature !== smugglerBattleSignature(receipt) ||
|
|
receipt.amount !== Math.min(receipt.strength, receipt.before) ||
|
|
JSON.stringify(smugglerBattlePile(receipt.territory, spice)) !== JSON.stringify({ key: receipt.key, before: receipt.before }))
|
|
throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.');
|
|
const amount = survives ? receipt.amount : 0;
|
|
const settled: SmugglerBattleReceipt = { ...receipt, stage: survives ? 'collected' : 'void', signature: '' };
|
|
settled.signature = smugglerBattleSignature(settled);
|
|
return { receipt: settled, amount, spice: receipt.key ? { ...spice, [receipt.key]: receipt.before - amount } : { ...spice } };
|
|
}
|