Files
Dune/game/introduction-resources.ts

276 lines
8.8 KiB
TypeScript

import { faction } from './catalog';
import { charityQuote } from './charity';
import { leaders, type Leader } from './cards';
import {
forceRevivalQuote,
forceRevivalRemaining,
freeRevivalRate,
freeRevivalRemaining,
leaderRevivalOptions,
newRevivalRules,
} from './revival';
export type IntroductionResourceChoice = {
charitySpice: number;
charityClaimed: boolean;
revivalFaction: 'atreides' | 'emperor' | 'fremen';
revivalSpice: number;
revivalAmount: number;
revivalLeader: number;
revivalRoster: 'all-dead' | 'survivor' | 'repeat';
revivalActions: Array<
{ kind: 'forces'; amount: number } | { kind: 'leader'; leader: number }
>;
};
export const RESOURCE_DEFAULTS: IntroductionResourceChoice = {
charitySpice: 0,
charityClaimed: false,
revivalFaction: 'atreides',
revivalSpice: 6,
revivalAmount: 3,
revivalLeader: 0,
revivalRoster: 'all-dead',
revivalActions: [],
};
const exactKeys = (value: object, keys: readonly string[]) =>
Object.keys(value).sort().join(',') === [...keys].sort().join(',');
const integer = (value: unknown, minimum: number, maximum: number) =>
typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= minimum &&
value <= maximum;
function requireResource(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new Error(message);
}
function validateChoice(value: IntroductionResourceChoice) {
requireResource(
!!value && typeof value === 'object' && !Array.isArray(value),
'The resource-practice state has invalid fields.',
);
requireResource(
integer(value.charitySpice, 0, 3) &&
typeof value.charityClaimed === 'boolean',
'Choose zero through three charity spice and a valid claim state.',
);
requireResource(
['atreides', 'emperor', 'fremen'].includes(value.revivalFaction) &&
integer(value.revivalSpice, 0, 10) &&
integer(value.revivalAmount, 1, 4) &&
integer(value.revivalLeader, 0, 4) &&
['all-dead', 'survivor', 'repeat'].includes(value.revivalRoster) &&
Array.isArray(value.revivalActions) &&
value.revivalActions.length <= 4,
'The revival-practice draft is invalid.',
);
let forceActions = 0;
let leaderActions = 0;
for (const action of value.revivalActions) {
requireResource(
!!action && typeof action === 'object' && !Array.isArray(action),
'A revival-practice action is invalid.',
);
if (action.kind === 'forces') {
forceActions++;
requireResource(
exactKeys(action, ['amount', 'kind']) && integer(action.amount, 1, 4),
'A force revival needs only an integer amount from one through four.',
);
} else if (action.kind === 'leader') {
leaderActions++;
requireResource(
exactKeys(action, ['kind', 'leader']) && integer(action.leader, 0, 4),
'A leader revival needs only a leader index from zero through four.',
);
} else throw new Error('Choose a force or leader revival action.');
}
requireResource(
forceActions <= 3 && leaderActions <= 1,
'Practice allows at most three force requests and one leader request.',
);
}
export function introductionCharity(choice: IntroductionResourceChoice) {
validateChoice(choice);
const player = {
id: 'practice',
faction: 'atreides' as const,
spice: choice.charitySpice,
};
const quote = charityQuote(
{
advanced: false,
turn: 1,
inflation: undefined,
players: [],
homeworlds: undefined,
},
player,
);
requireResource(
!choice.charityClaimed || quote.total > 0,
'An ineligible charity claim cannot be restored.',
);
const canClaim = !choice.charityClaimed && quote.total > 0;
return {
amount: quote.total,
spice: choice.charitySpice + (choice.charityClaimed ? quote.total : 0),
canClaim,
reason: choice.charityClaimed
? 'Charity has already been claimed.'
: canClaim
? null
: 'Charity requires fewer than two spice.',
};
}
function practiceLeaders(choice: IntroductionResourceChoice): Leader[] {
return leaders(choice.revivalFaction).map((leader, index) => ({
...leader,
dead: choice.revivalRoster !== 'survivor' || index < 4,
deaths:
choice.revivalRoster === 'survivor' && index === 4
? 0
: choice.revivalRoster === 'repeat' && index === 0
? 2
: 1,
}));
}
export function introductionRevival(choice: IntroductionResourceChoice) {
validateChoice(choice);
const context = {
status: 'playing' as const,
advanced: false,
phase: 4,
turn: 1,
players: [],
homeworlds: undefined,
revivalRules: newRevivalRules(),
freeRevival: [],
recruits: undefined,
revivalPrevention: undefined,
};
const player = {
id: 'practice',
faction: choice.revivalFaction,
spice: choice.revivalSpice,
tanks: 5,
reserves: 15,
revived: 0,
freeForcesRevived: 0,
leaders: practiceLeaders(choice),
leaderRevived: false,
revivalCycle: 0,
kwisatz: undefined,
};
const transcript: string[] = [];
for (const action of choice.revivalActions) {
if (action.kind === 'forces') {
const remaining = forceRevivalRemaining(context, player);
requireResource(
integer(action.amount, 1, remaining),
'That saved force revival exceeds the remaining normal allowance.',
);
const quote = forceRevivalQuote(context, player, action.amount);
requireResource(
quote.cost <= player.spice,
'That saved force revival is no longer affordable.',
);
player.spice -= quote.cost;
player.tanks -= action.amount;
player.reserves += action.amount;
player.revived += action.amount;
player.freeForcesRevived += quote.free;
transcript.push(
`Revived ${action.amount} ${action.amount === 1 ? 'force' : 'forces'}: ${quote.free} free, ${quote.cost} spice.`,
);
continue;
}
const options = leaderRevivalOptions(context, player);
player.revivalCycle = options.cycle;
const leader = player.leaders[action.leader];
const option = options.leaders.find((entry) => entry.id === leader.id);
requireResource(
!player.leaderRevived && leader.dead && option && !option.early,
'That saved leader revival is unavailable in this revival cycle.',
);
requireResource(
option.cost <= player.spice,
'That saved leader revival is no longer affordable.',
);
player.spice -= option.cost;
leader.dead = false;
player.leaderRevived = true;
transcript.push(`Revived ${leader.name} for ${option.cost} spice.`);
}
const freeRate = freeRevivalRate(context, player);
const remaining = forceRevivalRemaining(context, player);
const freeRemaining = freeRevivalRemaining(context, player);
const forceQuote = forceRevivalQuote(context, player, choice.revivalAmount);
const canReviveForces =
choice.revivalAmount <= remaining && forceQuote.cost <= player.spice;
const forceReason =
choice.revivalAmount > remaining
? remaining
? `Only ${remaining} normal force ${remaining === 1 ? 'revival remains' : 'revivals remain'}.`
: 'The normal force-revival allowance is exhausted.'
: forceQuote.cost > player.spice
? 'There is not enough spice for that force revival.'
: null;
const leaderOptions = leaderRevivalOptions(context, player);
const selectedLeader = player.leaders[choice.revivalLeader];
const selectedOption = leaderOptions.leaders.find(
(entry) => entry.id === selectedLeader.id,
);
const leaderCost = selectedOption?.cost ?? selectedLeader.strength;
const canReviveLeader =
!player.leaderRevived &&
selectedLeader.dead &&
!!selectedOption &&
!selectedOption.early &&
selectedOption.affordable;
const leaderReason = player.leaderRevived
? 'The one normal leader revival has already been used.'
: !selectedLeader.dead
? 'That leader is not in the tanks.'
: leaderOptions.cycle === 0
? 'All five leaders must enter the tanks before the first normal leader revival.'
: selectedLeader.deaths > leaderOptions.cycle
? "A repeat-death leader must wait for the other leaders' revival cycle."
: !selectedOption || selectedOption.early
? 'That leader is unavailable in the current revival cycle.'
: !selectedOption.affordable
? 'There is not enough spice to revive that leader.'
: null;
return {
factionName: faction(choice.revivalFaction).name,
freeRate,
remaining,
freeRemaining,
spice: player.spice,
tanks: player.tanks,
reserves: player.reserves,
revived: player.revived,
forceCost: forceQuote.cost,
forceFree: forceQuote.free,
canReviveForces,
forceReason,
leaders: structuredClone(player.leaders),
leaderRevived: player.leaderRevived,
leaderCost,
canReviveLeader,
leaderReason,
transcript,
};
}