Expand the internal introduction to eleven lessons and nine independent Basic exercises. Charity shows the ordinary top-up to two spice and prevents duplicate claims. Revival lets Atreides, Emperor and Fremen return ordinary forces in batches and revive an eligible leader using the same spice balance. Reuse production charity and revival calculations; only narrow the leader-options TypeScript signature, with no live rules or AI strategy changes. Version 5 practice saves retain all earlier lesson identities and accepted choices. Replay a bounded, validated history to reconstruct spice, Tanks, reserves, leader custody and turn allowances. Cover first-cycle, surviving-leader and repeat-death rosters, cumulative free allowances, paid returns, the three-force cap, separate leader allowance and unaffordable choices. Seven new tests compare real engine actions, immutable inputs, migration and impossible-save rejection. Independent review found no material issues. Types, lint, 4941 offline tests and production build passed on stable final source. Browser checks confirm previous alliance migration, keyboard Charity, shared revival spending, refreshed force/leader outcomes, roster restrictions, faction free rates, cap rejection and leader inspection. At390px all observed new controls fit, with 44px or taller targets. Screenshot capture remains unavailable; DOM bounds are narrower evidence. The full curriculum, faction/expansion teaching and rules/publication gates remain incomplete. Reused the running server. A stale development import for the newly-created file cleared through import-graph invalidation and reload without restarting. All 1337 rooms, 2805 seats and existing recovery, entry, handover, delegation and migration rows remain unchanged. SQLite integrity and foreign keys pass. No database migration/reset, automation, usage check or AI calibration. HTTP/auth/persistence source is unchanged; the required offline suite includes recovery checks and no broad HTTP suite was rerun. Private evidence: 20260921-resource-practice. Verified source tree: 40e207a5d46f53501a9ec8f77a8b48a5e4f0b3d67278bf670b1e53050770a40b
281 lines
7.9 KiB
TypeScript
281 lines
7.9 KiB
TypeScript
import type { Game, Player } from './engine';
|
|
import { faction, type FactionId } from './catalog';
|
|
import { isAuditorLeader } from './choam-auditor';
|
|
import { homeworldLowBonus } from './homeworld-benefits';
|
|
import { recruitsRevivalAllowance } from './recruits';
|
|
type RevivalRateContext = Pick<
|
|
Game,
|
|
| 'advanced'
|
|
| 'players'
|
|
| 'homeworlds'
|
|
| 'revivalRules'
|
|
| 'freeRevival'
|
|
| 'turn'
|
|
| 'recruits'
|
|
>;
|
|
export type RevivalRules = {
|
|
expanded: string[];
|
|
choamBlocked?: boolean;
|
|
freeBlocked?: string[];
|
|
limitBlocked: boolean;
|
|
fullPrice: boolean;
|
|
allyDiscount: string | null;
|
|
discountBlocked: boolean;
|
|
earlyBlocked: string[];
|
|
};
|
|
export const newRevivalRules = (): RevivalRules => ({
|
|
expanded: [],
|
|
limitBlocked: false,
|
|
fullPrice: false,
|
|
allyDiscount: null,
|
|
discountBlocked: false,
|
|
earlyBlocked: [],
|
|
});
|
|
export function forceRevivalLimit(
|
|
g: Pick<Game, 'revivalRules'>,
|
|
p: Pick<Player, 'id' | 'faction'>,
|
|
) {
|
|
if (p.faction === 'choam' && !g.revivalRules?.choamBlocked) return 20;
|
|
if (g.revivalRules?.limitBlocked) return 3;
|
|
if (p.faction === 'tleilaxu') return 20;
|
|
return g.revivalRules?.expanded.includes(p.id) ? 5 : 3;
|
|
}
|
|
export function revivalDiscount(
|
|
g: Pick<Game, 'players' | 'revivalRules'>,
|
|
p: Pick<Player, 'id' | 'faction'>,
|
|
) {
|
|
if (p.faction === 'tleilaxu') return !g.revivalRules?.fullPrice;
|
|
return (
|
|
!g.revivalRules?.discountBlocked &&
|
|
g.revivalRules?.allyDiscount === p.id &&
|
|
g.players.some((t) => t.faction === 'tleilaxu' && t.ally === p.id)
|
|
);
|
|
}
|
|
export function freeRevivalRate(
|
|
g: RevivalRateContext,
|
|
p: Pick<Player, 'id' | 'faction'>,
|
|
) {
|
|
return g.revivalRules?.freeBlocked?.includes(p.id)
|
|
? 0
|
|
: (g.freeRevival.includes(p.id) ? 3 : faction(p.faction).revival) +
|
|
homeworldLowBonus(g, p.id);
|
|
}
|
|
/** E3's explicit four-free Fremen rate may exceed the ordinary three-return cap;
|
|
* the extra free rate never creates an additional paid allowance. */
|
|
export function normalForceRevivalLimit(
|
|
g: RevivalRateContext,
|
|
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
|
|
) {
|
|
return normalRevivalAllowance(g, p).limit;
|
|
}
|
|
export function normalRevivalAllowance(
|
|
g: RevivalRateContext,
|
|
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
|
|
) {
|
|
const currentFreeRate = freeRevivalRate(g, p);
|
|
return recruitsRevivalAllowance(g, {
|
|
currentFreeRate,
|
|
currentLimit: Math.max(forceRevivalLimit(g, p), currentFreeRate),
|
|
revived: p.revived,
|
|
// A Recruits activation is rejected when this ledger is absent and use is
|
|
// nonzero. The fallback preserves old-room behavior while the card is idle.
|
|
freeAllowanceUsed:
|
|
g.recruits?.turn === g.turn ? (p.freeForcesRevived ?? p.revived) : p.revived,
|
|
freeRateCap: 20,
|
|
});
|
|
}
|
|
export function freeRevivalRemaining(
|
|
g: RevivalRateContext,
|
|
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
|
|
) {
|
|
return normalRevivalAllowance(g, p).freeRemaining;
|
|
}
|
|
export function forceRevivalQuote(
|
|
g: RevivalRateContext,
|
|
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
|
|
amount: number,
|
|
elite = 0,
|
|
freeElite?: number,
|
|
) {
|
|
const free = Math.min(amount, freeRevivalRemaining(g, p));
|
|
return forceRevivalPrice(
|
|
p.faction,
|
|
amount,
|
|
elite,
|
|
free,
|
|
!!revivalDiscount(g, p),
|
|
freeElite,
|
|
!!g.revivalRules?.choamBlocked,
|
|
);
|
|
}
|
|
|
|
/** Normal force returns only; Emperor-funded extra revivals and card effects are separate. */
|
|
export function forceRevivalRemaining(
|
|
g: RevivalRateContext,
|
|
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived' | 'tanks'>,
|
|
) {
|
|
return Math.max(
|
|
0,
|
|
Math.min(
|
|
p.tanks,
|
|
normalForceRevivalLimit(g, p) - p.revived,
|
|
// GF9 November 2020 FAQ p. 5 permits paid Fremen revivals with Tleilaxu in play.
|
|
// Selecting expansion cards alone does not provide that faction's permission.
|
|
p.faction === 'fremen' &&
|
|
!g.players.some((player) => player.faction === 'tleilaxu')
|
|
? freeRevivalRemaining(g, p)
|
|
: Infinity,
|
|
),
|
|
);
|
|
}
|
|
export function normalRevivalCycle(
|
|
p: Pick<Player, 'leaders' | 'revivalCycle'>,
|
|
) {
|
|
return p.leaders.every((l) => l.dead || l.capturedBy || l.gholaBy)
|
|
? Math.min(...p.leaders.map((l) => (l.dead ? l.deaths : l.deaths + 1)))
|
|
: p.revivalCycle;
|
|
}
|
|
|
|
/** Native leader options for the existing ordinary cycle and Tleilaxu self-service.
|
|
* Shared discs are deliberately separate: Ecaz's six-disc cycle is not implemented here.
|
|
* This is a pure quote; committing an action, not reading a view, advances the cycle.
|
|
*/
|
|
export function leaderRevivalOptions(
|
|
g: Pick<
|
|
Game,
|
|
| 'advanced'
|
|
| 'phase'
|
|
| 'turn'
|
|
| 'revivalPrevention'
|
|
| 'players'
|
|
| 'revivalRules'
|
|
>,
|
|
p: Pick<
|
|
Player,
|
|
| 'id'
|
|
| 'faction'
|
|
| 'leaders'
|
|
| 'revivalCycle'
|
|
| 'leaderRevived'
|
|
| 'spice'
|
|
| 'kwisatz'
|
|
>,
|
|
) {
|
|
const cycle = normalRevivalCycle(p);
|
|
const enabled = g.phase === 4 && !revivalPrevented(g, p.id);
|
|
const discount = revivalDiscount(g, p);
|
|
const leaders = p.leaders.flatMap((leader) => {
|
|
if (!enabled || !leader.dead || leader.capturedBy || leader.gholaBy)
|
|
return [];
|
|
// Auditor's first return expressly waives the all-in-Tanks prerequisite.
|
|
// Repeat-death and sixth-disc cycle interpretations remain separately unverified.
|
|
const auditorFirstReturn =
|
|
g.advanced &&
|
|
p.faction === 'choam' &&
|
|
isAuditorLeader(leader) &&
|
|
leader.deaths === 1;
|
|
const normal =
|
|
!p.leaderRevived &&
|
|
(auditorFirstReturn || (cycle > 0 && leader.deaths === cycle));
|
|
if (
|
|
!normal &&
|
|
(p.faction !== 'tleilaxu' ||
|
|
g.revivalRules?.earlyBlocked.includes(`${p.id}:${leader.id}`))
|
|
)
|
|
return [];
|
|
const cost = discount ? Math.ceil(leader.strength / 2) : leader.strength;
|
|
return [
|
|
{
|
|
id: leader.id,
|
|
name: leader.name,
|
|
normalCost: leader.strength,
|
|
cost,
|
|
early: !normal,
|
|
affordable: p.spice >= cost,
|
|
},
|
|
];
|
|
});
|
|
const kwisatzCost = discount ? 1 : 2;
|
|
const kwisatz =
|
|
enabled &&
|
|
g.advanced &&
|
|
p.faction === 'atreides' &&
|
|
p.kwisatz?.dead &&
|
|
!p.leaderRevived &&
|
|
cycle >= (p.kwisatz.revivalCycle ?? 1)
|
|
? { cost: kwisatzCost, affordable: p.spice >= kwisatzCost }
|
|
: null;
|
|
return { cycle, leaders, kwisatz };
|
|
}
|
|
export type PendingRevival = {
|
|
player: string;
|
|
kind: 'forces' | 'leader' | 'kwisatz' | 'foreignGhola';
|
|
payer?: string;
|
|
emperorExtra?: boolean;
|
|
amount?: number;
|
|
elite?: number;
|
|
leader?: string;
|
|
normalCost: number;
|
|
cost: number;
|
|
free: number;
|
|
checks: (
|
|
| 'choamRevival'
|
|
| 'revivalLimit'
|
|
| 'revivalDiscount'
|
|
| 'earlyRevival'
|
|
| 'foreignGhola'
|
|
)[];
|
|
};
|
|
|
|
export function revivalPrevented(
|
|
g: Pick<Game, 'turn' | 'phase' | 'revivalPrevention'>,
|
|
player: string,
|
|
) {
|
|
return (
|
|
g.phase === 4 &&
|
|
g.revivalPrevention?.turn === g.turn &&
|
|
g.revivalPrevention.player === player
|
|
);
|
|
}
|
|
|
|
export function eliteRevivalRemaining(
|
|
p: Pick<Player, 'faction' | 'elites'>,
|
|
advanced = true,
|
|
) {
|
|
return !advanced || p.faction === 'ixians'
|
|
? (p.elites?.tanks ?? 0)
|
|
: Math.min(p.elites?.tanks ?? 0, Math.max(0, 1 - (p.elites?.revived ?? 0)));
|
|
}
|
|
export function paidForceRevivalCost(
|
|
p: Pick<Player, 'faction'>,
|
|
amount: number,
|
|
elite = 0,
|
|
) {
|
|
return amount * 2 + (p.faction === 'ixians' ? elite : 0);
|
|
}
|
|
|
|
export function forceRevivalPrice(
|
|
faction: FactionId,
|
|
amount: number,
|
|
elite: number,
|
|
free: number,
|
|
discount: boolean,
|
|
freeElite?: number,
|
|
choamBlocked = false,
|
|
) {
|
|
const freeCyborgs =
|
|
faction === 'ixians' ? (freeElite ?? Math.min(free, elite)) : 0;
|
|
const normalCost = paidForceRevivalCost(
|
|
{ faction },
|
|
amount - free,
|
|
faction === 'ixians' ? elite - freeCyborgs : 0,
|
|
);
|
|
const factionCost =
|
|
faction === 'choam' && !choamBlocked ? amount - free : normalCost;
|
|
return {
|
|
free,
|
|
normalCost,
|
|
cost: discount ? Math.ceil(factionCost / 2) : factionCost,
|
|
};
|
|
}
|