Add Charity and Revival resource practice
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
This commit is contained in:
1 parent
c4ff75a56d
commit
67e429e32f
16 files changed
+848
-56
No files matched your search
@@ -0,0 +1,274 @@
|
||||
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 = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
+23
-11
@@ -5,13 +5,16 @@ import { quoteSpiceCollection } from './board-resolution-quote';
|
||||
import { INTRODUCTION_MOVE_DESTINATIONS, introductionMovement, type IntroductionMovementChoice } from './introduction-movement';
|
||||
import { introductionBidding, type IntroductionBiddingChoice } from './introduction-bidding';
|
||||
import { introductionAlliance, INTRODUCTION_ALLIANCE_DEFAULTS, type IntroductionAllianceChoice } from './introduction-alliance';
|
||||
import { introductionCharity, introductionRevival, RESOURCE_DEFAULTS, type IntroductionResourceChoice } from './introduction-resources';
|
||||
|
||||
/** Fixed teaching examples, never a room, seat or multiplayer save. */
|
||||
export const INTRODUCTION_STORAGE_KEY = 'dune-introduction-v1';
|
||||
export const INTRODUCTION_STEPS = [
|
||||
{ title: 'Your place at the table', topic: 'setup' },
|
||||
{ title: 'Choose an ally at the Nexus', topic: 'spice-blow' },
|
||||
{ title: 'Claim CHOAM Charity', topic: 'charity' },
|
||||
{ title: 'Bid for a hidden card', topic: 'bidding' },
|
||||
{ title: 'Revive forces and leaders', topic: 'revival' },
|
||||
{ title: 'Ship within your budget', topic: 'movement' },
|
||||
{ title: 'Move across the board', topic: 'movement' },
|
||||
{ title: 'Seal a battle plan', topic: 'battle' },
|
||||
@@ -19,8 +22,8 @@ export const INTRODUCTION_STEPS = [
|
||||
{ title: 'Collect the spice', topic: 'collection' },
|
||||
{ title: 'Join a table', topic: 'privacy' },
|
||||
] as const;
|
||||
export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & {
|
||||
version: 4;
|
||||
export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & IntroductionResourceChoice & {
|
||||
version: 5;
|
||||
step: number;
|
||||
shipment: number;
|
||||
destination: 'stronghold' | 'sand';
|
||||
@@ -37,7 +40,7 @@ export type IntroductionState = IntroductionMovementChoice & IntroductionBidding
|
||||
opponentTraitor: boolean;
|
||||
};
|
||||
export function newIntroduction(): IntroductionState {
|
||||
return { ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [], version: 4, step: 0, shipment: 4, destination: 'sand', shipped: false,
|
||||
return { ...RESOURCE_DEFAULTS, revivalActions: [], ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [], version: 5, step: 0, shipment: 4, destination: 'sand', shipped: false,
|
||||
dial: 2, defense: 'shield', revealed: false, collectors: 3, city: false, collected: false,
|
||||
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
|
||||
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false,
|
||||
@@ -49,19 +52,22 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || ![1, 2, 3, 4].includes(parsed.version)) return null;
|
||||
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version)) return null;
|
||||
// Keep the existing browser key and translate old positions, not just indices.
|
||||
if (parsed.version === 1 && !integer(parsed.step, 0, 4)) return null;
|
||||
if (parsed.version === 2 && !integer(parsed.step, 0, 6)) return null;
|
||||
if (parsed.version === 3 && !integer(parsed.step, 0, 7)) return null;
|
||||
if (parsed.version === 4 && !integer(parsed.step, 0, 8)) return null;
|
||||
const resourceDefaults = { ...RESOURCE_DEFAULTS, revivalActions: [] };
|
||||
const auctionDefaults = { auctionScenario: 'contest', auctionHandFull: false, auctionBid: 1, auctionActions: [] };
|
||||
const allianceDefaults = { ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [] };
|
||||
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 4,
|
||||
step: [0, 3, 5, 7, 8][parsed.step],
|
||||
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 5,
|
||||
step: [0, 5, 7, 9, 10][parsed.step],
|
||||
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
|
||||
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, ...auctionDefaults, ...allianceDefaults }
|
||||
: parsed.version === 2 ? { ...parsed, version: 4, step: [0, 3, 4, 5, 6, 7, 8][parsed.step], ...auctionDefaults, ...allianceDefaults }
|
||||
: parsed.version === 3 ? { ...parsed, version: 4, step: [0, 2, 3, 4, 5, 6, 7, 8][parsed.step], ...allianceDefaults }
|
||||
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, ...auctionDefaults, ...allianceDefaults, ...resourceDefaults }
|
||||
: parsed.version === 2 ? { ...parsed, version: 5, step: [0, 5, 6, 7, 8, 9, 10][parsed.step], ...auctionDefaults, ...allianceDefaults, ...resourceDefaults }
|
||||
: parsed.version === 3 ? { ...parsed, version: 5, step: [0, 3, 5, 6, 7, 8, 9, 10][parsed.step], ...allianceDefaults, ...resourceDefaults }
|
||||
: parsed.version === 4 ? { ...parsed, version: 5, step: [0, 1, 3, 5, 6, 7, 8, 9, 10][parsed.step], ...resourceDefaults }
|
||||
: parsed) as IntroductionState;
|
||||
if (!integer(s.step, 0, INTRODUCTION_STEPS.length - 1) ||
|
||||
!integer(s.shipment, 1, 6) || !['stronghold', 'sand'].includes(s.destination) ||
|
||||
@@ -76,8 +82,10 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul
|
||||
(s.moved && !introductionMovement(s).allowed)) return null;
|
||||
introductionBidding(s);
|
||||
introductionAlliance(s);
|
||||
introductionCharity(s);
|
||||
introductionRevival(s);
|
||||
// Whitelist fields; never retain credentials or arbitrary imported properties.
|
||||
return { version: 4, step: s.step, shipment: s.shipment, destination: s.destination,
|
||||
return { version: 5, step: s.step, shipment: s.shipment, destination: s.destination,
|
||||
shipped: s.shipped, dial: s.dial, defense: s.defense, revealed: s.revealed,
|
||||
collectors: s.collectors, city: s.city, collected: s.collected,
|
||||
moveDestination: s.moveDestination, moveForces: s.moveForces, moveCity: s.moveCity,
|
||||
@@ -86,7 +94,11 @@ export function restoreIntroduction(raw: string | null): IntroductionState | nul
|
||||
auctionScenario: s.auctionScenario, auctionHandFull: s.auctionHandFull,
|
||||
auctionBid: s.auctionBid, auctionActions: [...s.auctionActions],
|
||||
allianceScenario: s.allianceScenario, allianceHoldings: s.allianceHoldings,
|
||||
allianceActions: [...s.allianceActions], allianceClosed: s.allianceClosed, allianceChecked: s.allianceChecked };
|
||||
allianceActions: [...s.allianceActions], allianceClosed: s.allianceClosed, allianceChecked: s.allianceChecked,
|
||||
charitySpice: s.charitySpice, charityClaimed: s.charityClaimed,
|
||||
revivalFaction: s.revivalFaction, revivalSpice: s.revivalSpice, revivalAmount: s.revivalAmount,
|
||||
revivalLeader: s.revivalLeader, revivalRoster: s.revivalRoster,
|
||||
revivalActions: s.revivalActions.map(a => a.kind === 'forces' ? { kind: a.kind, amount: a.amount } : { kind: a.kind, leader: a.leader }) };
|
||||
} catch { return null; }
|
||||
}
|
||||
export function introductionShipment(s: Pick<IntroductionState, 'shipment' | 'destination'>) {
|
||||
|
||||
+8
-7
@@ -230,23 +230,24 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Getting started',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Practice alliances, bidding, shipping, movement, sealed battle plans, Traitor calls and spice collection before joining a table.',
|
||||
summary: 'Practice alliances, charity, bidding, revival, shipping, movement, sealed battle plans, Traitor calls and spice collection before joining a table.',
|
||||
steps: [
|
||||
'Choose Learn to play in the lobby or at the top of this reference. Nine lessons introduce table information, ordinary Nexus alliances, bidding, shipping budgets, movement, battle choices, Traitor calls, collection and saved-seat recovery.',
|
||||
'The seven practice positions are separate Basic examples. Compare movement range with city access, storm and stronghold limits; reveal a matching Traitor or decline after both plans appear. Bidding, shipping, combat and collection use the same rule calculations as live games.',
|
||||
'Choose Learn to play in the lobby or at the top of this reference. Eleven lessons introduce table information, ordinary Nexus alliances, charity, bidding, revival, shipping budgets, movement, battle choices, Traitor calls, collection and saved-seat recovery.',
|
||||
'The nine practice positions are separate Basic examples. Compare movement range with city access, storm and stronghold limits; reveal a matching Traitor or decline after both plans appear. Bidding, shipping, combat and collection use the same rule calculations as live games.',
|
||||
'Bidding lets you raise, pass and re-enter while the card remains for sale. Only the winner pays; a purchased card stays private. Try a full hand or opponents who all pass, then restart the example.',
|
||||
'Charity compares zero, one and two-or-more spice. Revival compares three factions’ free allowances, cumulative force returns, shared spending and leader eligibility across first-death, surviving and repeat-death rosters. Force and leader returns remain separately limited.',
|
||||
'At the Nexus, offer an alliance or accept the Emperor’s offer. An unanswered offer does not form an alliance. Break it or finish the Nexus, then compare three versus four shared strongholds at a later Mentat Pause. Changing practice options restarts that example.',
|
||||
'The opponent uses a fixed teaching plan, revealed only after you seal yours. Try another plan to compare a Shield with a Snooper, or a low dial with the aggressor’s winning tie. This is an exercise, not an AI match.',
|
||||
'Lesson progress stays in this browser, including a revealed plan. No room, multiplayer action or saved-seat credential is created or read. If browser storage is unavailable, practice still works for the current visit.',
|
||||
'Faction lessons, Advanced combat, alliance powers, broader storm exceptions and expansions remain outside this introduction. The full reference and phase guidance explain more; existing mode gates still apply.',
|
||||
],
|
||||
related: ['setup', 'spice-blow', 'alliance-funding', 'bidding', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'],
|
||||
related: ['setup', 'spice-blow', 'alliance-funding', 'charity', 'revival', 'bidding', 'movement', 'battle', 'collection', 'privacy', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Nine lessons and seven interactive Basic examples reuse production alliance/victory, bidding, shipment, map/occupancy, combat and collection calculations. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts', 'game/introduction-movement.ts', 'game/introduction-bidding.ts', 'game/introduction-alliance.ts', 'game/nexus-alliance.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Alliance offer/accept/withdraw/break and later victory, bid/pass, full-hand and all-pass choices, lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx', 'components/introduction-practice.tsx', 'components/introduction-bidding.tsx', 'components/introduction-alliance.tsx'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Eleven lessons and nine interactive Basic examples reuse production alliance/victory, charity/revival, bidding, shipment, map/occupancy, combat and collection calculations. The complete teaching curriculum remains unfinished.', evidence: ['game/introduction.ts', 'game/introduction-movement.ts', 'game/introduction-bidding.ts', 'game/introduction-alliance.ts', 'game/nexus-alliance.ts', 'game/introduction-resources.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Charity claims, cumulative free/paid force returns, eligible leader revival and inspection, alliance offer/accept/withdraw/break and later victory, bid/pass, full-hand and all-pass choices, lesson navigation, keyboard force sliders and battle wheel, card inspectors, seal/reveal, retry and browser-local continuation are connected.', evidence: ['components/introduction.tsx', 'components/introduction-practice.tsx', 'components/introduction-bidding.tsx', 'components/introduction-alliance.tsx', 'components/introduction-resources.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Fixed alliance responses, bidding scripts and an opponent battle plan provide reproducible teaching examples. The introduction is not an AI game and does not demonstrate opponent strategy or calibration.' },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Internal links connect each lesson to existing rules; the setup and saved-seat guidance distinguish practice from a live multiplayer game.', evidence: ['docs/INTERACTIVE_INTRODUCTION.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover alliance consent/break and later victory across 24 engine comparisons, bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts', 'tests/introduction-alliance.test.ts', 'tests/nexus-alliance.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused checks compare resource payments, allowances and leader custody with engine actions, validate paid-save continuation and prior lesson migration, and cover alliance consent/break and later victory across 24 engine comparisons, bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts', 'tests/introduction-alliance.test.ts', 'tests/nexus-alliance.test.ts', 'tests/introduction-resources.test.ts', 'tests/introduction-resources-migration.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+21
-1
@@ -140,7 +140,27 @@ export function normalRevivalCycle(
|
||||
* 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: Game, p: Player) {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user