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
@@ -25,7 +25,7 @@ Until that stage, AI work focuses on minimal legal participation and critical
|
|||||||
playability fixes.
|
playability fixes.
|
||||||
|
|
||||||
Choose **Learn to play** for the [interactive introduction](docs/INTERACTIVE_INTRODUCTION.md)
|
Choose **Learn to play** for the [interactive introduction](docs/INTERACTIVE_INTRODUCTION.md)
|
||||||
at `/learn`. Practice alliances, bidding, shipping, movement, battle plans, Traitor calls and collection before entering a
|
at `/learn`. Practice alliances, charity, bidding, revival, shipping, movement, battle plans, Traitor calls and collection before entering a
|
||||||
room; lesson progress stays in your browser and does not change saved games.
|
room; lesson progress stays in your browser and does not change saved games.
|
||||||
|
|
||||||
Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project.
|
Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { IntroductionState } from '@/game/introduction';
|
||||||
|
import { introductionCharity, introductionRevival } from '@/game/introduction-resources';
|
||||||
|
import { LeaderInspector } from './leader-inspector';
|
||||||
|
import { LeaderPortrait } from './leader-portrait';
|
||||||
|
import styles from './introduction.module.css';
|
||||||
|
|
||||||
|
type Props = { state: IntroductionState; update: (patch: Partial<IntroductionState>) => void };
|
||||||
|
|
||||||
|
export function CharityLesson({ state, update }: Props) {
|
||||||
|
const charity = introductionCharity(state);
|
||||||
|
return <>
|
||||||
|
<p>CHOAM Charity comes before Bidding. In this Basic example you are Atreides,
|
||||||
|
with no expansion powers or special charity rules. Choose how much spice you already hold.</p>
|
||||||
|
<div className={styles.columns}>
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<label htmlFor="learn-charity-spice">Starting spice: {state.charitySpice}</label>
|
||||||
|
<input id="learn-charity-spice" type="range" min="0" max="3" step="1" value={state.charitySpice}
|
||||||
|
onChange={e => update({ charitySpice: Number(e.target.value), charityClaimed: false })} />
|
||||||
|
<button disabled={!charity.canClaim} onClick={() => update({ charityClaimed: true })}>Claim practice charity</button>
|
||||||
|
<button onClick={() => update({ charityClaimed: false })}>Restart charity example</button>
|
||||||
|
<p className="fine">Changing the starting spice starts a fresh example. It does not change another lesson or any live game.</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.result} aria-live="polite">
|
||||||
|
<h3>{state.charityClaimed ? 'Charity received' : charity.canClaim ? 'You can claim charity' : 'No charity needed'}</h3>
|
||||||
|
<p>You have <strong>{charity.spice} spice</strong>.</p>
|
||||||
|
<p>{state.charityClaimed ? 'The Spice Bank paid' : 'The Spice Bank would pay'} {charity.amount} spice.</p>
|
||||||
|
{charity.reason && <p>{charity.reason}</p>}
|
||||||
|
<p>With no spice you receive two; with one you receive one. At two or more, ordinary charity gives nothing.
|
||||||
|
It brings an eligible player up to two spice; it does not add two to every player’s balance.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>Charity is a once-per-turn claim during its phase. That budget may matter in the following auction.
|
||||||
|
Advanced Bene Gesserit and the CHOAM expansion change the ordinary case; their powers are separate.</p>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevivalLesson({ state, update }: Props) {
|
||||||
|
const revival = introductionRevival(state);
|
||||||
|
const selected = revival.leaders[state.revivalLeader];
|
||||||
|
const reset = (patch: Partial<IntroductionState> = {}) => update({ revivalActions: [], ...patch });
|
||||||
|
return <>
|
||||||
|
<p>At the Revival phase, this separate Basic position has <strong>five ordinary forces in the Tanks</strong> and
|
||||||
|
fifteen in reserves. Revived forces return to reserves, ready for later shipment.
|
||||||
|
No Tleilaxu, ally funding, elites, cards or other expansion effects apply.</p>
|
||||||
|
<div className={styles.columns}>
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<label htmlFor="learn-revival-faction">Practice faction</label>
|
||||||
|
<select id="learn-revival-faction" value={state.revivalFaction}
|
||||||
|
onChange={e => reset({ revivalFaction: e.target.value as IntroductionState['revivalFaction'] })}>
|
||||||
|
<option value="atreides">Atreides</option><option value="emperor">Emperor</option><option value="fremen">Fremen</option>
|
||||||
|
</select>
|
||||||
|
<label htmlFor="learn-revival-spice">Starting spice: {state.revivalSpice}</label>
|
||||||
|
<input id="learn-revival-spice" type="range" min="0" max="10" step="1" value={state.revivalSpice}
|
||||||
|
onChange={e => reset({ revivalSpice: Number(e.target.value) })} />
|
||||||
|
<label htmlFor="learn-revival-roster">Starting leader position</label>
|
||||||
|
<select id="learn-revival-roster" value={state.revivalRoster}
|
||||||
|
onChange={e => reset({ revivalRoster: e.target.value as IntroductionState['revivalRoster'] })}>
|
||||||
|
<option value="all-dead">All five leaders have died once</option>
|
||||||
|
<option value="survivor">Four leaders dead; one has never died</option>
|
||||||
|
<option value="repeat">First leader died twice; others died once</option>
|
||||||
|
</select>
|
||||||
|
<p className="fine">Faction, starting spice and leader position reset this example. Force and leader choices below share the same remaining spice.</p>
|
||||||
|
<h3>Bring forces back</h3>
|
||||||
|
<label htmlFor="learn-revival-amount">Forces to revive: {state.revivalAmount}</label>
|
||||||
|
<input id="learn-revival-amount" type="range" min="1" max="4" step="1" value={state.revivalAmount}
|
||||||
|
onChange={e => update({ revivalAmount: Number(e.target.value) })} />
|
||||||
|
<button disabled={!revival.canReviveForces} onClick={() => update({ revivalActions: [...state.revivalActions, { kind: 'forces', amount: state.revivalAmount }] })}>Revive practice forces</button>
|
||||||
|
<h3>Bring one leader back</h3>
|
||||||
|
<label htmlFor="learn-revival-leader">Leader to revive</label>
|
||||||
|
<select id="learn-revival-leader" value={state.revivalLeader}
|
||||||
|
onChange={e => update({ revivalLeader: Number(e.target.value) })}>
|
||||||
|
{revival.leaders.map((leader, index) => <option key={leader.id} value={index}>{leader.name} · strength {leader.strength} · {leader.dead ? `dead (${leader.deaths} ${leader.deaths === 1 ? 'death' : 'deaths'})` : 'alive'}</option>)}
|
||||||
|
</select>
|
||||||
|
<LeaderInspector identity={{ id: selected.id, name: selected.name, strength: selected.strength, factionName: revival.factionName }} />
|
||||||
|
<button disabled={!revival.canReviveLeader} onClick={() => update({ revivalActions: [...state.revivalActions, { kind: 'leader', leader: state.revivalLeader }] })}>Revive practice leader</button>
|
||||||
|
<button onClick={() => reset()}>Restart revival example</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.result} aria-live="polite">
|
||||||
|
<h3>Your remaining resources</h3>
|
||||||
|
<p><strong>{revival.spice} spice</strong> · {revival.tanks} forces in the Tanks · {revival.reserves} in reserves.</p>
|
||||||
|
<p>{revival.revived} of three ordinary force revivals used this turn. You may still return up to {revival.remaining} {revival.remaining === 1 ? 'force' : 'forces'}.</p>
|
||||||
|
<p>{revival.factionName} has {revival.freeRate} free force revivals per turn; {revival.freeRemaining} of that allowance remain.</p>
|
||||||
|
<h3>Selected force return</h3>
|
||||||
|
{revival.canReviveForces ? <p>{state.revivalAmount} forces: {revival.forceFree} free, total cost <strong>{revival.forceCost} spice</strong> to the Spice Bank.</p>
|
||||||
|
: <p>{revival.forceReason}</p>}
|
||||||
|
<h3>{selected.name}</h3>
|
||||||
|
<LeaderPortrait identityId={selected.id} name={selected.name} fallback={<span>Strength {selected.strength}</span>} badge={selected.strength} />
|
||||||
|
{revival.canReviveLeader ? <p>This leader can return for <strong>{revival.leaderCost} spice</strong> to the Spice Bank.</p>
|
||||||
|
: <p>{revival.leaderReason}</p>}
|
||||||
|
<p>{revival.leaderRevived ? 'Your ordinary leader revival is used for this turn.' : 'One ordinary leader may be revived this turn, if eligible and affordable.'}</p>
|
||||||
|
<h3>Revival history</h3>
|
||||||
|
{revival.transcript.length ? <ol>{revival.transcript.map((entry, i) => <li key={i}>{entry}</li>)}</ol>
|
||||||
|
: <p>No revivals yet. Try returning forces in separate batches: the free allowance is shared across them.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>The ordinary force limit is three total, including free returns. Extra paid returns cost two spice each.
|
||||||
|
Force and leader allowances are separate, so a leader return does not consume a force revival.</p>
|
||||||
|
<p>Normally all five leaders must die before the first revival cycle opens. You may then revive one eligible leader per turn
|
||||||
|
for its fighting strength in spice. A returned leader that dies again must wait for the other revivable leaders to return and die again.
|
||||||
|
Reviving a leader does not remove a matching Traitor Card from another player’s hand.</p>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
+13
-10
@@ -8,6 +8,7 @@ import { PHASES } from '@/game/catalog';
|
|||||||
import { MovementLesson, TraitorLesson } from './introduction-practice';
|
import { MovementLesson, TraitorLesson } from './introduction-practice';
|
||||||
import { BiddingLesson } from './introduction-bidding';
|
import { BiddingLesson } from './introduction-bidding';
|
||||||
import { AllianceLesson } from './introduction-alliance';
|
import { AllianceLesson } from './introduction-alliance';
|
||||||
|
import { CharityLesson, RevivalLesson } from './introduction-resources';
|
||||||
import {
|
import {
|
||||||
INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY,
|
INTRODUCTION_CARDS, INTRODUCTION_LEADERS, INTRODUCTION_STEPS, INTRODUCTION_STORAGE_KEY,
|
||||||
introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction,
|
introductionBattle, introductionCollection, introductionShipment, newIntroduction, restoreIntroduction,
|
||||||
@@ -25,11 +26,11 @@ export function Introduction() {
|
|||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<Link href="/">← Return to Arrakis</Link>
|
<Link href="/">← Return to Arrakis</Link>
|
||||||
<p className="eyebrow">LEARN TO PLAY · BASIC GAME</p>
|
<p className="eyebrow">LEARN TO PLAY · BASIC GAME</p>
|
||||||
<h1>A first turn on Arrakis</h1>
|
<h1>Your first steps on Arrakis</h1>
|
||||||
<p>Practice alliances, bidding, shipping, movement, battle plans, Traitor calls and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.</p>
|
<p>Practice the Basic phases, from alliances and resources to bidding, movement, battle and collection, then take a seat at a real table. These are separate examples; they do not change any saved game.</p>
|
||||||
</header>
|
</header>
|
||||||
{/* Remount on schema changes so a development hot update also migrates old state. */}
|
{/* Remount on schema changes so a development hot update also migrates old state. */}
|
||||||
{hydrated ? <IntroductionLessons key="v4" /> : <output>Restoring your lesson…</output>}
|
{hydrated ? <IntroductionLessons key="v5" /> : <output>Restoring your lesson…</output>}
|
||||||
</main>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,8 +79,10 @@ function IntroductionLessons() {
|
|||||||
<p>The table tells you who must act. Your decisions can wait while another player seals a plan or answers a power. Automatic effects show in the chronicle.</p>
|
<p>The table tells you who must act. Your decisions can wait while another player seals a plan or answers a power. Automatic effects show in the chronicle.</p>
|
||||||
</>}
|
</>}
|
||||||
{state.step === 1 && <AllianceLesson state={state} update={update} />}
|
{state.step === 1 && <AllianceLesson state={state} update={update} />}
|
||||||
{state.step === 2 && <BiddingLesson state={state} update={update} />}
|
{state.step === 2 && <CharityLesson state={state} update={update} />}
|
||||||
{state.step === 3 && <>
|
{state.step === 3 && <BiddingLesson state={state} update={update} />}
|
||||||
|
{state.step === 4 && <RevivalLesson state={state} update={update} />}
|
||||||
|
{state.step === 5 && <>
|
||||||
<p>You have <strong>8 spice</strong> and six available reserve forces. Ship from reserves before moving on the board. This example has no discount, storm, occupancy restriction or reacting faction power.</p>
|
<p>You have <strong>8 spice</strong> and six available reserve forces. Ship from reserves before moving on the board. This example has no discount, storm, occupancy restriction or reacting faction power.</p>
|
||||||
<div className={styles.columns}>
|
<div className={styles.columns}>
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
@@ -103,8 +106,8 @@ function IntroductionLessons() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="fine">Ordinary movement reaches an adjacent territory. Ornithopters and faction powers can extend its range. Inspect the live destination guide before committing.</p>
|
<p className="fine">Ordinary movement reaches an adjacent territory. Ornithopters and faction powers can extend its range. Inspect the live destination guide before committing.</p>
|
||||||
</>}
|
</>}
|
||||||
{state.step === 4 && <MovementLesson state={state} update={update} />}
|
{state.step === 6 && <MovementLesson state={state} update={update} />}
|
||||||
{state.step === 5 && <>
|
{state.step === 7 && <>
|
||||||
<p>A separate battle: you are the aggressor in Wind Pass with six ordinary forces and {INTRODUCTION_LEADERS.you.name}, strength {INTRODUCTION_LEADERS.you.strength}. Your opponent also has six forces. Neither side calls a Traitor or uses a faction power in this example.</p>
|
<p>A separate battle: you are the aggressor in Wind Pass with six ordinary forces and {INTRODUCTION_LEADERS.you.name}, strength {INTRODUCTION_LEADERS.you.strength}. Your opponent also has six forces. Neither side calls a Traitor or uses a faction power in this example.</p>
|
||||||
<div className={styles.columns}>
|
<div className={styles.columns}>
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
@@ -132,8 +135,8 @@ function IntroductionLessons() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
{state.step === 6 && <TraitorLesson state={state} update={update} />}
|
{state.step === 8 && <TraitorLesson state={state} update={update} />}
|
||||||
{state.step === 7 && <>
|
{state.step === 9 && <>
|
||||||
<p>A fresh position has 8 spice in Wind Pass, sector 14. You have surviving forces in that same sector, outside the storm. Collection uses the forces still on the board after battles.</p>
|
<p>A fresh position has 8 spice in Wind Pass, sector 14. You have surviving forces in that same sector, outside the storm. Collection uses the forces still on the board after battles.</p>
|
||||||
<div className={styles.columns}>
|
<div className={styles.columns}>
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
@@ -149,7 +152,7 @@ function IntroductionLessons() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
{state.step === 8 && <>
|
{state.step === 10 && <>
|
||||||
<p>You can revisit any example. This introduction covers ordinary alliances, bidding, core resources, movement and battle choices; it does not teach every faction, card, alliance power, storm exception or expansion.</p>
|
<p>You can revisit any example. This introduction covers ordinary alliances, bidding, core resources, movement and battle choices; it does not teach every faction, card, alliance power, storm exception or expansion.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Create a private room, choose a faction, and add friends or AI players. Share the room invitation.</li>
|
<li>Create a private room, choose a faction, and add friends or AI players. Share the room invitation.</li>
|
||||||
|
|||||||
+18
-13
@@ -6,22 +6,27 @@ gated.** Prototypes do not open completion or publication gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
Latest delivered checkpoint: `b7ec940`, [interactive bidding](INTERACTIVE_INTRODUCTION.md).
|
Latest delivered checkpoint: `c4ff75a`, [interactive alliance practice](INTERACTIVE_INTRODUCTION.md).
|
||||||
Types, lint, **4,925 offline tests** and production build passed. All 1,337 rooms,
|
Types, lint, **4,934 offline tests** and production build passed. All 1,337 rooms,
|
||||||
2,805 seats and existing recovery/entry/handover/delegation rows stayed unchanged.
|
2,805 seats and existing recovery/entry/handover/delegation rows stayed unchanged.
|
||||||
The preceding `b9718ac` connects [lobby AI configuration](LOBBY_AI_CONFIGURATION.md)
|
The stopped server was started against existing state; an existing QA seat restored.
|
||||||
and `b21b2bc` connects [one-use AI permission](SEAT_AI_PERMISSION.md).
|
The preceding `b7ec940` connects bidding practice, `b9718ac` connects
|
||||||
The `8f7a62c` policy checkpoint removed the usage cutoff and deferred AI refinement
|
[lobby AI configuration](LOBBY_AI_CONFIGURATION.md), and `b21b2bc` connects
|
||||||
until feature completion, with three adjacent 75% win-rate targets. Earlier
|
[one-use AI permission](SEAT_AI_PERMISSION.md). The `8f7a62c` policy checkpoint
|
||||||
evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md).
|
removed the usage cutoff and deferred AI refinement until feature completion,
|
||||||
|
with three adjacent 75% targets. Earlier evidence remains in the
|
||||||
|
[implementation history](IMPLEMENTATION_STATUS.md).
|
||||||
|
|
||||||
Current work: [interactive alliance practice](INTERACTIVE_INTRODUCTION.md) adds
|
Current work: [Charity and Revival practice](INTERACTIVE_INTRODUCTION.md) adds two
|
||||||
the ninth lesson, ordinary Nexus consent/break choices and later shared stronghold
|
Basic phase lessons, cumulative free/paid returns, leader eligibility and shared
|
||||||
victory through production quotes. Versioned practice migrates all earlier lesson
|
spending through existing production calculations. Saved choices migrate every
|
||||||
positions and choices. No AI strategy is changed. Final independent review,
|
earlier lesson and commitment. No AI strategy changes. Final independent review,
|
||||||
browser/check/build/preservation and Git evidence belong to its private report.
|
browser/check/build/preservation and Git evidence belong to its private report.
|
||||||
Alliance powers, faction/Advanced/expansion teaching and broader disconnected-seat
|
A bounded current-state Duke review confirmed revival/loan paths still cross
|
||||||
acceptance remain unfinished.
|
recorded custody, return and cycle questions; existing identity/price helpers and
|
||||||
|
non-Ecaz guards are already present. No duplicate ruling or invented outcome.
|
||||||
|
Storm/spice-blow, full faction/Advanced/expansion teaching and broader
|
||||||
|
multiplayer acceptance remain unfinished.
|
||||||
|
|
||||||
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
|
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
|
||||||
still cross recorded pending rulings. Continue independent features rather than
|
still cross recorded pending rulings. Continue independent features rather than
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 21 September 2026 — Charity and Revival practice
|
||||||
|
|
||||||
|
The [introduction](INTERACTIVE_INTRODUCTION.md) expands to eleven lessons and nine
|
||||||
|
Basic examples. Charity teaches the top-up to two spice; Revival teaches shared
|
||||||
|
free allowances, paid force returns, the ordinary cap, leader eligibility and
|
||||||
|
separate leader/force limits. Existing production calculations supply the costs
|
||||||
|
and choices; a legal saved action history preserves cumulative spending and custody.
|
||||||
|
All previous lesson identities and outcomes migrate to version 5. No live AI
|
||||||
|
strategy or rules mode changes. Focused engine comparisons, independent review,
|
||||||
|
browser controls and source-bound checks/preservation/Git evidence belong to the
|
||||||
|
private checkpoint. Duke revival/loans retain their existing unresolved custody
|
||||||
|
boundaries; the full curriculum and game remain unfinished.
|
||||||
|
|
||||||
## 21 September 2026 — Interactive Nexus alliance practice
|
## 21 September 2026 — Interactive Nexus alliance practice
|
||||||
|
|
||||||
The [introduction](INTERACTIVE_INTRODUCTION.md) now connects nine lessons and seven
|
The [introduction](INTERACTIVE_INTRODUCTION.md) now connects nine lessons and seven
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
from the lobby and rules reference. It adds the previously missing guided
|
from the lobby and rules reference. It adds the previously missing guided
|
||||||
introduction alongside the reference's existing standalone practice wheel.
|
introduction alongside the reference's existing standalone practice wheel.
|
||||||
|
|
||||||
Nine navigable lessons cover public/private table information and the phase
|
Eleven navigable lessons cover public/private table information and the phase
|
||||||
sequence, ordinary Nexus alliances, bidding, shipment budgets, movement, sealed battle plans, Traitor calls, spice
|
sequence, ordinary Nexus alliances, charity, bidding, revival, shipment budgets, movement, sealed battle plans, Traitor calls, spice
|
||||||
collection and joining or recovering a saved table. Seven independent Basic practice positions let a player
|
collection and joining or recovering a saved table. Nine independent Basic practice positions let a player
|
||||||
change inputs, commit, inspect results and retry. These positions are deliberately
|
change inputs, commit, inspect results and retry. These positions are deliberately
|
||||||
separate examples rather than a complete game or an authoritative live room.
|
separate examples rather than a complete game or an authoritative live room.
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ The base rulebook's Alliances (Nexus) and Mentat Pause sections remain the rule
|
|||||||
authority; fresh publisher-indexed rulebook/quick-start results confirm the
|
authority; fresh publisher-indexed rulebook/quick-start results confirm the
|
||||||
ordinary two-member/four-stronghold contract. No new ambiguous ruling is inferred.
|
ordinary two-member/four-stronghold contract. No new ambiguous ruling is inferred.
|
||||||
|
|
||||||
The version 4 save whitelists alliance choices and validates the bounded
|
The saved state whitelists alliance choices and validates the bounded
|
||||||
opening/offer/withdraw/break sequence and phase order. Prior lesson identities,
|
opening/offer/withdraw/break sequence and phase order. Prior lesson identities,
|
||||||
auction history and every other accepted practice choice remain intact.
|
auction history and every other accepted practice choice remain intact.
|
||||||
`tests/introduction-alliance.test.ts` compares all 24 offered scenario/holding/
|
`tests/introduction-alliance.test.ts` compares all 24 offered scenario/holding/
|
||||||
@@ -70,6 +70,35 @@ existing Nexus Card forfeiture tests cover the retained expansion side effects.
|
|||||||
Final independent review, browser, source-bound checks and saved-game preservation
|
Final independent review, browser, source-bound checks and saved-game preservation
|
||||||
are recorded in the private checkpoint and Git message.
|
are recorded in the private checkpoint and Git message.
|
||||||
|
|
||||||
|
The Charity and Revival lessons connect two further missing Basic phase exercises.
|
||||||
|
Charity compares starting balances zero through three, pays the ordinary bank
|
||||||
|
amount once and distinguishes a top-up to two from a flat two-spice payment.
|
||||||
|
Revival uses Atreides, Emperor or Fremen, with five ordinary forces in the Tanks
|
||||||
|
and fifteen in reserves. Separate batches share the per-turn free allowance and
|
||||||
|
three-force cap. Eligible leader returns use the same spice balance but a separate
|
||||||
|
one-leader allowance. Full first-death, one-survivor and repeat-death rosters show
|
||||||
|
why a selected leader may be unavailable; the existing portrait and inspector
|
||||||
|
remain readable without exposing any live private information.
|
||||||
|
|
||||||
|
The exercises reuse `charityQuote`, the force revival rate/remaining/price helpers
|
||||||
|
and `leaderRevivalOptions`. Only the latter's TypeScript input types are narrowed;
|
||||||
|
its live rule behavior is unchanged. A bounded legal action history recomputes
|
||||||
|
balances, Tanks, reserves, leader custody and usage counters after refresh rather
|
||||||
|
than trusting saved totals. Changing starting conditions resets this independent
|
||||||
|
position. No Tleilaxu, elites, cards, allies, capture or expansion interactions are
|
||||||
|
simulated, and no AI strategy changes. Source authority is the same classic
|
||||||
|
GF9 base rulebook, Charity and Revival (printed pp.7 and 9). The official indexed
|
||||||
|
Revival text and [GF9 Quick Start Guide, Charity, p.6](https://www.gf9games.com/dunegame/wp-content/uploads/Quick-start-Guide.pdf#page=6)
|
||||||
|
were freshly retrieved on 21 September. The similarly named 2021
|
||||||
|
movie game returned by search is a different ruleset and was not used.
|
||||||
|
|
||||||
|
Resource regressions compare ordinary claims/returns against actual engine
|
||||||
|
actions, preserve inputs, check cumulative spending and allowances, reject
|
||||||
|
impossible saves and retain all nine v4 lesson identities and prior commitments.
|
||||||
|
Final source-bound browser/check/build/preservation and review evidence belongs
|
||||||
|
to this checkpoint's private report and Git delivery. Storm/spice-blow hazards,
|
||||||
|
full faction/Advanced/expansion teaching and the complete curriculum remain open.
|
||||||
|
|
||||||
The movement position starts with five ordinary forces in Red Chasm. It compares
|
The movement position starts with five ordinary forces in Red Chasm. It compares
|
||||||
range one with range three from a separate Arrakeen occupant, exact sectors,
|
range one with range three from a separate Arrakeen occupant, exact sectors,
|
||||||
storm exclusion and a stronghold already occupied by two other factions. Accepted
|
storm exclusion and a stronghold already occupied by two other factions. Accepted
|
||||||
@@ -81,9 +110,10 @@ actual authoritative `applyAction` transitions; no live movement rule changed.
|
|||||||
The existing wheel, keyboard controls and card inspectors remain available.
|
The existing wheel, keyboard controls and card inspectors remain available.
|
||||||
Lesson changes focus the heading; responsive columns stack on small screens.
|
Lesson changes focus the heading; responsive columns stack on small screens.
|
||||||
Browser storage contains only a versioned, validated set of lesson choices under
|
Browser storage contains only a versioned, validated set of lesson choices under
|
||||||
`dune-introduction-v1`. Version 4 payloads explicitly migrate all five version 1,
|
`dune-introduction-v1`. Version 5 payloads explicitly migrate all five version 1,
|
||||||
seven version 2 and eight version 3 lesson positions to the same lesson after insertion,
|
seven version 2, eight version 3 and nine version 4 lesson positions to the same lesson after insertion,
|
||||||
preserving prior choices and committed outcomes. New alliance fields start at their defaults for every older version.
|
preserving prior choices and committed outcomes. New resource fields start at defaults for every older version. Versions 1–3
|
||||||
|
receive alliance defaults.
|
||||||
Versions 1 and 2 receive bidding defaults; version 1 also receives movement/Traitor defaults. Hot updates
|
Versions 1 and 2 receive bidding defaults; version 1 also receives movement/Traitor defaults. Hot updates
|
||||||
remount the lesson state to apply this migration. Only the player's bid/pass
|
remount the lesson state to apply this migration. Only the player's bid/pass
|
||||||
history is saved; deterministic replay validates the complete auction and rejects
|
history is saved; deterministic replay validates the complete auction and rejects
|
||||||
|
|||||||
@@ -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 { INTRODUCTION_MOVE_DESTINATIONS, introductionMovement, type IntroductionMovementChoice } from './introduction-movement';
|
||||||
import { introductionBidding, type IntroductionBiddingChoice } from './introduction-bidding';
|
import { introductionBidding, type IntroductionBiddingChoice } from './introduction-bidding';
|
||||||
import { introductionAlliance, INTRODUCTION_ALLIANCE_DEFAULTS, type IntroductionAllianceChoice } from './introduction-alliance';
|
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. */
|
/** Fixed teaching examples, never a room, seat or multiplayer save. */
|
||||||
export const INTRODUCTION_STORAGE_KEY = 'dune-introduction-v1';
|
export const INTRODUCTION_STORAGE_KEY = 'dune-introduction-v1';
|
||||||
export const INTRODUCTION_STEPS = [
|
export const INTRODUCTION_STEPS = [
|
||||||
{ title: 'Your place at the table', topic: 'setup' },
|
{ title: 'Your place at the table', topic: 'setup' },
|
||||||
{ title: 'Choose an ally at the Nexus', topic: 'spice-blow' },
|
{ 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: 'Bid for a hidden card', topic: 'bidding' },
|
||||||
|
{ title: 'Revive forces and leaders', topic: 'revival' },
|
||||||
{ title: 'Ship within your budget', topic: 'movement' },
|
{ title: 'Ship within your budget', topic: 'movement' },
|
||||||
{ title: 'Move across the board', topic: 'movement' },
|
{ title: 'Move across the board', topic: 'movement' },
|
||||||
{ title: 'Seal a battle plan', topic: 'battle' },
|
{ title: 'Seal a battle plan', topic: 'battle' },
|
||||||
@@ -19,8 +22,8 @@ export const INTRODUCTION_STEPS = [
|
|||||||
{ title: 'Collect the spice', topic: 'collection' },
|
{ title: 'Collect the spice', topic: 'collection' },
|
||||||
{ title: 'Join a table', topic: 'privacy' },
|
{ title: 'Join a table', topic: 'privacy' },
|
||||||
] as const;
|
] as const;
|
||||||
export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & {
|
export type IntroductionState = IntroductionMovementChoice & IntroductionBiddingChoice & IntroductionAllianceChoice & IntroductionResourceChoice & {
|
||||||
version: 4;
|
version: 5;
|
||||||
step: number;
|
step: number;
|
||||||
shipment: number;
|
shipment: number;
|
||||||
destination: 'stronghold' | 'sand';
|
destination: 'stronghold' | 'sand';
|
||||||
@@ -37,7 +40,7 @@ export type IntroductionState = IntroductionMovementChoice & IntroductionBidding
|
|||||||
opponentTraitor: boolean;
|
opponentTraitor: boolean;
|
||||||
};
|
};
|
||||||
export function newIntroduction(): IntroductionState {
|
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,
|
dial: 2, defense: 'shield', revealed: false, collectors: 3, city: false, collected: false,
|
||||||
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
|
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
|
||||||
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: 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;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
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.
|
// 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 === 1 && !integer(parsed.step, 0, 4)) return null;
|
||||||
if (parsed.version === 2 && !integer(parsed.step, 0, 6)) 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 === 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 auctionDefaults = { auctionScenario: 'contest', auctionHandFull: false, auctionBid: 1, auctionActions: [] };
|
||||||
const allianceDefaults = { ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [] };
|
const allianceDefaults = { ...INTRODUCTION_ALLIANCE_DEFAULTS, allianceActions: [] };
|
||||||
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 4,
|
const s = (parsed.version === 1 ? { ...newIntroduction(), ...parsed, version: 5,
|
||||||
step: [0, 3, 5, 7, 8][parsed.step],
|
step: [0, 5, 7, 9, 10][parsed.step],
|
||||||
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
|
moveDestination: 'pasty_mesa:5', moveForces: 3, moveCity: false, moveStorm: false,
|
||||||
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, ...auctionDefaults, ...allianceDefaults }
|
moved: false, traitorStage: 'plans', traitorCall: false, opponentTraitor: false, ...auctionDefaults, ...allianceDefaults, ...resourceDefaults }
|
||||||
: parsed.version === 2 ? { ...parsed, version: 4, step: [0, 3, 4, 5, 6, 7, 8][parsed.step], ...auctionDefaults, ...allianceDefaults }
|
: parsed.version === 2 ? { ...parsed, version: 5, step: [0, 5, 6, 7, 8, 9, 10][parsed.step], ...auctionDefaults, ...allianceDefaults, ...resourceDefaults }
|
||||||
: parsed.version === 3 ? { ...parsed, version: 4, step: [0, 2, 3, 4, 5, 6, 7, 8][parsed.step], ...allianceDefaults }
|
: 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;
|
: parsed) as IntroductionState;
|
||||||
if (!integer(s.step, 0, INTRODUCTION_STEPS.length - 1) ||
|
if (!integer(s.step, 0, INTRODUCTION_STEPS.length - 1) ||
|
||||||
!integer(s.shipment, 1, 6) || !['stronghold', 'sand'].includes(s.destination) ||
|
!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;
|
(s.moved && !introductionMovement(s).allowed)) return null;
|
||||||
introductionBidding(s);
|
introductionBidding(s);
|
||||||
introductionAlliance(s);
|
introductionAlliance(s);
|
||||||
|
introductionCharity(s);
|
||||||
|
introductionRevival(s);
|
||||||
// Whitelist fields; never retain credentials or arbitrary imported properties.
|
// 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,
|
shipped: s.shipped, dial: s.dial, defense: s.defense, revealed: s.revealed,
|
||||||
collectors: s.collectors, city: s.city, collected: s.collected,
|
collectors: s.collectors, city: s.city, collected: s.collected,
|
||||||
moveDestination: s.moveDestination, moveForces: s.moveForces, moveCity: s.moveCity,
|
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,
|
auctionScenario: s.auctionScenario, auctionHandFull: s.auctionHandFull,
|
||||||
auctionBid: s.auctionBid, auctionActions: [...s.auctionActions],
|
auctionBid: s.auctionBid, auctionActions: [...s.auctionActions],
|
||||||
allianceScenario: s.allianceScenario, allianceHoldings: s.allianceHoldings,
|
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; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
export function introductionShipment(s: Pick<IntroductionState, 'shipment' | 'destination'>) {
|
export function introductionShipment(s: Pick<IntroductionState, 'shipment' | 'destination'>) {
|
||||||
|
|||||||
+8
-7
@@ -230,23 +230,24 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
category: 'Getting started',
|
category: 'Getting started',
|
||||||
coverage: 'Partial',
|
coverage: 'Partial',
|
||||||
developmentStage: 'Prototyped',
|
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: [
|
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.',
|
'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 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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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: [
|
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: '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: '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: '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: '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: '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.
|
* 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.
|
* 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 cycle = normalRevivalCycle(p);
|
||||||
const enabled = g.phase === 4 && !revivalPrevented(g, p.id);
|
const enabled = g.phase === 4 && !revivalPrevented(g, p.id);
|
||||||
const discount = revivalDiscount(g, p);
|
const discount = revivalDiscount(g, p);
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ void test('v3 migration preserves all eight lesson identities, completed auction
|
|||||||
shipped: true, moved: true, moveCity: true, revealed: true, collected: true,
|
shipped: true, moved: true, moveCity: true, revealed: true, collected: true,
|
||||||
traitorStage: 'resolved', traitorCall: true, allianceActions: ['invalid'], allianceChecked: true, token: 'discarded' };
|
traitorStage: 'resolved', traitorCall: true, allianceActions: ['invalid'], allianceChecked: true, token: 'discarded' };
|
||||||
const restored = restoreIntroduction(JSON.stringify(old))!;
|
const restored = restoreIntroduction(JSON.stringify(old))!;
|
||||||
assert.equal(restored.version, 4);
|
assert.equal(restored.version, 5);
|
||||||
assert.equal(INTRODUCTION_STEPS[restored.step].title, title);
|
assert.equal(INTRODUCTION_STEPS[restored.step].title, title);
|
||||||
assert.deepEqual(restored.auctionActions, [null, 4]);
|
assert.deepEqual(restored.auctionActions, [null, 4]);
|
||||||
assert.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall);
|
assert.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall);
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ void test('all seven v2 lessons keep their identity and prior decisions when bid
|
|||||||
unrelated: 'removed',
|
unrelated: 'removed',
|
||||||
};
|
};
|
||||||
const result = restoreIntroduction(JSON.stringify(old))!;
|
const result = restoreIntroduction(JSON.stringify(old))!;
|
||||||
assert.equal(result.version, 4);
|
assert.equal(result.version, 5);
|
||||||
assert.equal(INTRODUCTION_STEPS[result.step].title, title);
|
assert.equal(INTRODUCTION_STEPS[result.step].title, title);
|
||||||
assert.equal(result.shipped, true);
|
assert.equal(result.shipped, true);
|
||||||
assert.equal(result.revealed, true);
|
assert.equal(result.revealed, true);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ void test('all five v1 lessons migrate by identity and retain prior committed ch
|
|||||||
for (const [step, title] of titles.entries()) {
|
for (const [step, title] of titles.entries()) {
|
||||||
const result = restoreIntroduction(JSON.stringify({ ...old, step, token: 'excluded', moved: true, traitorCall: true }))!;
|
const result = restoreIntroduction(JSON.stringify({ ...old, step, token: 'excluded', moved: true, traitorCall: true }))!;
|
||||||
assert.equal(INTRODUCTION_STEPS[result.step].title, title);
|
assert.equal(INTRODUCTION_STEPS[result.step].title, title);
|
||||||
assert.equal(result.version, 4); assert.equal(result.shipped, true); assert.equal(result.revealed, true);
|
assert.equal(result.version, 5); assert.equal(result.shipped, true); assert.equal(result.revealed, true);
|
||||||
assert.equal(result.dial, 3); assert.equal(result.collected, true); assert.equal(result.collectors, 2);
|
assert.equal(result.dial, 3); assert.equal(result.collected, true); assert.equal(result.collectors, 2);
|
||||||
assert.equal(result.moved, false); assert.equal(result.traitorCall, false); assert.equal('token' in result, false);
|
assert.equal(result.moved, false); assert.equal(result.traitorCall, false); assert.equal('token' in result, false);
|
||||||
assert.deepEqual(restoreIntroduction(JSON.stringify(result)), result);
|
assert.deepEqual(restoreIntroduction(JSON.stringify(result)), result);
|
||||||
@@ -86,7 +86,7 @@ void test('new practice choices survive all stages and reject impossible saved c
|
|||||||
}
|
}
|
||||||
for (const patch of [{ moveDestination: 'invented:7' }, { moveForces: 0 }, { moveCity: 'yes' },
|
for (const patch of [{ moveDestination: 'invented:7' }, { moveForces: 0 }, { moveCity: 'yes' },
|
||||||
{ moved: true, moveStorm: true }, { moved: true, moveDestination: 'imperial_basin:9' },
|
{ moved: true, moveStorm: true }, { moved: true, moveDestination: 'imperial_basin:9' },
|
||||||
{ traitorStage: 'later' }, { traitorCall: true }, { opponentTraitor: 1 }, { version: 5 }]) {
|
{ traitorStage: 'later' }, { traitorCall: true }, { opponentTraitor: 1 }, { version: 6 }]) {
|
||||||
assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), ...patch })), null);
|
assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), ...patch })), null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { INTRODUCTION_STEPS, newIntroduction, restoreIntroduction } from '../game/introduction';
|
||||||
|
import { introductionCharity, introductionRevival } from '../game/introduction-resources';
|
||||||
|
|
||||||
|
void test('all nine v4 lessons retain their identity and prior commitments when resource practice is inserted', () => {
|
||||||
|
const titles = ['Your place at the table', 'Choose an ally at the Nexus', 'Bid for a hidden card',
|
||||||
|
'Ship within your budget', 'Move across the board', 'Seal a battle plan', 'Reveal a Traitor', 'Collect the spice', 'Join a table'];
|
||||||
|
for (const [step, title] of titles.entries()) {
|
||||||
|
const old = { ...newIntroduction(), version: 4, step, shipped: true, moved: true,
|
||||||
|
moveCity: true, revealed: true, collected: true, traitorStage: 'resolved', traitorCall: true,
|
||||||
|
auctionActions: [null, 4], auctionBid: 4, allianceActions: ['offer'], allianceClosed: true,
|
||||||
|
allianceChecked: true, charitySpice: 'ignore', charityClaimed: true, revivalActions: ['ignore'],
|
||||||
|
token: 'discarded' };
|
||||||
|
const restored = restoreIntroduction(JSON.stringify(old))!;
|
||||||
|
assert.equal(restored.version, 5);
|
||||||
|
assert.equal(INTRODUCTION_STEPS[restored.step].title, title);
|
||||||
|
assert.ok(restored.shipped && restored.moved && restored.revealed && restored.collected && restored.traitorCall);
|
||||||
|
assert.deepEqual(restored.auctionActions, [null, 4]);
|
||||||
|
assert.deepEqual(restored.allianceActions, ['offer']);
|
||||||
|
assert.equal(restored.allianceChecked, true);
|
||||||
|
assert.equal(restored.charityClaimed, false);
|
||||||
|
assert.equal(restored.charitySpice, 0);
|
||||||
|
assert.deepEqual(restored.revivalActions, []);
|
||||||
|
assert.equal(Object.hasOwn(restored, 'token'), false);
|
||||||
|
assert.deepEqual(restoreIntroduction(JSON.stringify(restored)), restored);
|
||||||
|
}
|
||||||
|
assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), version: 4, step: 9 })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('resource choices and paid outcomes restore exactly without retaining unrelated fields', () => {
|
||||||
|
const state = { ...newIntroduction(), step: 4, charityClaimed: true,
|
||||||
|
revivalActions: [{ kind: 'forces' as const, amount: 1 }, { kind: 'forces' as const, amount: 1 },
|
||||||
|
{ kind: 'forces' as const, amount: 1 }, { kind: 'leader' as const, leader: 4 }] };
|
||||||
|
const restored = restoreIntroduction(JSON.stringify({ ...state, serverGame: { private: 'excluded' } }));
|
||||||
|
assert.deepEqual(restored, state);
|
||||||
|
assert.deepEqual(introductionRevival(restored!), introductionRevival(state));
|
||||||
|
assert.deepEqual(introductionCharity(restored!), introductionCharity(state));
|
||||||
|
assert.equal(introductionRevival(restored!).spice, 3);
|
||||||
|
for (const patch of [
|
||||||
|
{ charitySpice: 2, charityClaimed: true }, { charitySpice: -1 }, { charityClaimed: 'yes' },
|
||||||
|
{ revivalFaction: 'tleilaxu' }, { revivalRoster: 'invented' }, { revivalSpice: 99 },
|
||||||
|
{ revivalAmount: 0 }, { revivalLeader: 5 }, { revivalActions: [{ kind: 'forces', amount: 4 }] },
|
||||||
|
{ revivalActions: [{ kind: 'leader', leader: 0, token: 'extra' }] },
|
||||||
|
{ revivalActions: [{ kind: 'forces', amount: 1 }, { kind: 'forces', amount: 3 }] },
|
||||||
|
]) assert.equal(restoreIntroduction(JSON.stringify({ ...newIntroduction(), ...patch })), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, createGame, newPlayer, type Game } from '../game/engine';
|
||||||
|
import {
|
||||||
|
introductionCharity,
|
||||||
|
introductionRevival,
|
||||||
|
RESOURCE_DEFAULTS,
|
||||||
|
type IntroductionResourceChoice,
|
||||||
|
} from '../game/introduction-resources';
|
||||||
|
import { newRevivalRules } from '../game/revival';
|
||||||
|
|
||||||
|
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||||
|
const choice = (
|
||||||
|
overrides: Partial<IntroductionResourceChoice> = {},
|
||||||
|
): IntroductionResourceChoice => ({
|
||||||
|
...copy(RESOURCE_DEFAULTS),
|
||||||
|
...overrides,
|
||||||
|
revivalActions: copy(overrides.revivalActions ?? []),
|
||||||
|
});
|
||||||
|
|
||||||
|
function actualState(input: IntroductionResourceChoice, phase: 2 | 4): Game {
|
||||||
|
const game = createGame(
|
||||||
|
'RESOURCEPRACTICE',
|
||||||
|
newPlayer('practice', 'Practice', input.revivalFaction),
|
||||||
|
);
|
||||||
|
Object.assign(game, {
|
||||||
|
status: 'playing',
|
||||||
|
phase,
|
||||||
|
turn: 1,
|
||||||
|
response: null,
|
||||||
|
decision: null,
|
||||||
|
revivalRules: newRevivalRules(),
|
||||||
|
freeRevival: [],
|
||||||
|
revivalPrevention: undefined,
|
||||||
|
homeworlds: undefined,
|
||||||
|
recruits: undefined,
|
||||||
|
});
|
||||||
|
const player = game.players[0];
|
||||||
|
Object.assign(player, {
|
||||||
|
spice: phase === 2 ? input.charitySpice : input.revivalSpice,
|
||||||
|
tanks: 5,
|
||||||
|
reserves: 15,
|
||||||
|
forces: {},
|
||||||
|
revived: 0,
|
||||||
|
freeForcesRevived: 0,
|
||||||
|
leaderRevived: false,
|
||||||
|
revivalCycle: 0,
|
||||||
|
});
|
||||||
|
player.leaders.forEach((leader, index) => {
|
||||||
|
leader.dead = input.revivalRoster !== 'survivor' || index < 4;
|
||||||
|
leader.deaths =
|
||||||
|
input.revivalRoster === 'survivor' && index === 4
|
||||||
|
? 0
|
||||||
|
: input.revivalRoster === 'repeat' && index === 0
|
||||||
|
? 2
|
||||||
|
: 1;
|
||||||
|
});
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actualRevival(input: IntroductionResourceChoice) {
|
||||||
|
let game = actualState(input, 4);
|
||||||
|
for (const action of input.revivalActions)
|
||||||
|
game =
|
||||||
|
action.kind === 'forces'
|
||||||
|
? applyAction(game, 'practice', {
|
||||||
|
type: 'revive',
|
||||||
|
amount: action.amount,
|
||||||
|
})
|
||||||
|
: applyAction(game, 'practice', {
|
||||||
|
type: 'reviveLeader',
|
||||||
|
leader: game.players[0].leaders[action.leader].id,
|
||||||
|
});
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('defaults and charity claims match actual eligibility and one-time payment', () => {
|
||||||
|
assert.deepEqual(RESOURCE_DEFAULTS, {
|
||||||
|
charitySpice: 0,
|
||||||
|
charityClaimed: false,
|
||||||
|
revivalFaction: 'atreides',
|
||||||
|
revivalSpice: 6,
|
||||||
|
revivalAmount: 3,
|
||||||
|
revivalLeader: 0,
|
||||||
|
revivalRoster: 'all-dead',
|
||||||
|
revivalActions: [],
|
||||||
|
});
|
||||||
|
for (const charitySpice of [0, 1, 2, 3]) {
|
||||||
|
const input = choice({ charitySpice });
|
||||||
|
const offered = introductionCharity(input);
|
||||||
|
assert.equal(offered.canClaim, charitySpice < 2);
|
||||||
|
assert.equal(offered.amount, Math.max(0, 2 - charitySpice));
|
||||||
|
if (charitySpice < 2) {
|
||||||
|
const actual = applyAction(actualState(input, 2), 'practice', {
|
||||||
|
type: 'charity',
|
||||||
|
});
|
||||||
|
const restored = introductionCharity({ ...input, charityClaimed: true });
|
||||||
|
assert.equal(restored.spice, actual.players[0].spice);
|
||||||
|
assert.equal(restored.canClaim, false);
|
||||||
|
} else {
|
||||||
|
const before = actualState(input, 2);
|
||||||
|
assert.throws(
|
||||||
|
() => applyAction(before, 'practice', { type: 'charity' }),
|
||||||
|
/Charity/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => introductionCharity({ ...input, charityClaimed: true }),
|
||||||
|
/ineligible charity claim/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all three faction force rates and costs replay to actual engine resources and counters', () => {
|
||||||
|
for (const [revivalFaction, freeRate] of [
|
||||||
|
['atreides', 2],
|
||||||
|
['emperor', 1],
|
||||||
|
['fremen', 3],
|
||||||
|
] as const) {
|
||||||
|
const draft = choice({ revivalFaction, revivalAmount: 3 });
|
||||||
|
const offered = introductionRevival(draft);
|
||||||
|
assert.equal(offered.freeRate, freeRate);
|
||||||
|
const input = choice({
|
||||||
|
revivalFaction,
|
||||||
|
revivalAmount: 3,
|
||||||
|
revivalActions: [{ kind: 'forces', amount: 3 }],
|
||||||
|
});
|
||||||
|
const model = introductionRevival(input);
|
||||||
|
const actual = actualRevival(input).players[0];
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
spice: model.spice,
|
||||||
|
tanks: model.tanks,
|
||||||
|
reserves: model.reserves,
|
||||||
|
revived: model.revived,
|
||||||
|
free: model.freeRate - model.freeRemaining,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
spice: actual.spice,
|
||||||
|
tanks: actual.tanks,
|
||||||
|
reserves: actual.reserves,
|
||||||
|
revived: actual.revived,
|
||||||
|
free: actual.freeForcesRevived,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(model.tanks + model.reserves, 20);
|
||||||
|
assert.equal(model.remaining, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('mixed force and leader history pays each quote once and preserves physical leaders', () => {
|
||||||
|
const input = choice({
|
||||||
|
revivalActions: [
|
||||||
|
{ kind: 'forces', amount: 1 },
|
||||||
|
{ kind: 'leader', leader: 2 },
|
||||||
|
{ kind: 'forces', amount: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const before = copy(input);
|
||||||
|
const model = introductionRevival(input);
|
||||||
|
const actual = actualRevival(input).players[0];
|
||||||
|
assert.deepEqual(input, before);
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
spice: model.spice,
|
||||||
|
tanks: model.tanks,
|
||||||
|
reserves: model.reserves,
|
||||||
|
revived: model.revived,
|
||||||
|
leaderRevived: model.leaderRevived,
|
||||||
|
leaders: model.leaders.map(({ id, dead, deaths }) => ({
|
||||||
|
id,
|
||||||
|
dead,
|
||||||
|
deaths,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
spice: actual.spice,
|
||||||
|
tanks: actual.tanks,
|
||||||
|
reserves: actual.reserves,
|
||||||
|
revived: actual.revived,
|
||||||
|
leaderRevived: actual.leaderRevived,
|
||||||
|
leaders: actual.leaders.map(({ id, dead, deaths }) => ({
|
||||||
|
id,
|
||||||
|
dead,
|
||||||
|
deaths,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(model.spice, 0);
|
||||||
|
assert.equal(model.transcript.length, 3);
|
||||||
|
assert.equal(model.canReviveForces, false);
|
||||||
|
assert.equal(model.canReviveLeader, false);
|
||||||
|
assert.equal(model.tanks + model.reserves, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('survivor and repeat rosters expose the same unavailable leaders as real actions', () => {
|
||||||
|
for (const [revivalRoster, revivalLeader] of [
|
||||||
|
['survivor', 0],
|
||||||
|
['repeat', 0],
|
||||||
|
] as const) {
|
||||||
|
const input = choice({ revivalRoster, revivalLeader });
|
||||||
|
const model = introductionRevival(input);
|
||||||
|
assert.equal(model.canReviveLeader, false);
|
||||||
|
assert.match(
|
||||||
|
model.leaderReason!,
|
||||||
|
revivalRoster === 'survivor' ? /All five leaders/ : /repeat-death leader/,
|
||||||
|
);
|
||||||
|
const actual = actualState(input, 4);
|
||||||
|
const before = structuredClone(actual);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
applyAction(actual, 'practice', {
|
||||||
|
type: 'reviveLeader',
|
||||||
|
leader: actual.players[0].leaders[revivalLeader].id,
|
||||||
|
}),
|
||||||
|
/All leaders must die|unavailable/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(actual, before);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
introductionRevival({
|
||||||
|
...input,
|
||||||
|
revivalActions: [{ kind: 'leader', leader: revivalLeader }],
|
||||||
|
}),
|
||||||
|
/unavailable in this revival cycle/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const available = introductionRevival(
|
||||||
|
choice({ revivalRoster: 'repeat', revivalLeader: 1 }),
|
||||||
|
);
|
||||||
|
assert.equal(available.canReviveLeader, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('JSON-restored history is deterministic and malformed or impossible histories reject', () => {
|
||||||
|
const input = choice({
|
||||||
|
revivalFaction: 'emperor',
|
||||||
|
revivalSpice: 8,
|
||||||
|
revivalAmount: 2,
|
||||||
|
revivalLeader: 3,
|
||||||
|
revivalActions: [
|
||||||
|
{ kind: 'forces', amount: 1 },
|
||||||
|
{ kind: 'leader', leader: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const restored = JSON.parse(
|
||||||
|
JSON.stringify(input),
|
||||||
|
) as IntroductionResourceChoice;
|
||||||
|
assert.deepEqual(introductionCharity(restored), introductionCharity(input));
|
||||||
|
assert.deepEqual(introductionRevival(restored), introductionRevival(input));
|
||||||
|
const output = introductionRevival(restored);
|
||||||
|
output.leaders[0].dead = false;
|
||||||
|
assert.deepEqual(restored, input);
|
||||||
|
|
||||||
|
const invalid = [
|
||||||
|
{
|
||||||
|
...input,
|
||||||
|
revivalActions: [{ kind: 'forces', amount: 1, extra: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...input,
|
||||||
|
revivalActions: [
|
||||||
|
{ kind: 'forces', amount: 1 },
|
||||||
|
{ kind: 'forces', amount: 1 },
|
||||||
|
{ kind: 'forces', amount: 1 },
|
||||||
|
{ kind: 'forces', amount: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...input,
|
||||||
|
revivalSpice: 0,
|
||||||
|
revivalActions: [{ kind: 'leader', leader: 0 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...input,
|
||||||
|
revivalActions: [
|
||||||
|
{ kind: 'forces', amount: 2 },
|
||||||
|
{ kind: 'forces', amount: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const state of invalid)
|
||||||
|
assert.throws(() =>
|
||||||
|
introductionRevival(state as IntroductionResourceChoice),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -44,8 +44,8 @@ void test('saved practice restores sealed choices, rejects corrupt versions and
|
|||||||
const saved = { ...newIntroduction(), step: 3, dial: 3, revealed: true, shipped: true };
|
const saved = { ...newIntroduction(), step: 3, dial: 3, revealed: true, shipped: true };
|
||||||
assert.deepEqual(restoreIntroduction(JSON.stringify(saved)), saved);
|
assert.deepEqual(restoreIntroduction(JSON.stringify(saved)), saved);
|
||||||
assert.deepEqual(restoreIntroduction(JSON.stringify({ ...saved, token: 'not-a-real-token', hand: ['private'] })), saved);
|
assert.deepEqual(restoreIntroduction(JSON.stringify({ ...saved, token: 'not-a-real-token', hand: ['private'] })), saved);
|
||||||
for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 5 }),
|
for (const raw of [null, '', '{', 'null', '[]', JSON.stringify({ ...saved, version: 6 }),
|
||||||
JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 9 }),
|
JSON.stringify({ ...saved, step: -1 }), JSON.stringify({ ...saved, step: 11 }),
|
||||||
JSON.stringify({ ...saved, defense: 'lasgun' }), JSON.stringify({ ...saved, dial: 2.5 }),
|
JSON.stringify({ ...saved, defense: 'lasgun' }), JSON.stringify({ ...saved, dial: 2.5 }),
|
||||||
JSON.stringify({ ...saved, collectors: 99 }), JSON.stringify({ ...saved, revealed: 'true' }),
|
JSON.stringify({ ...saved, collectors: 99 }), JSON.stringify({ ...saved, revealed: 'true' }),
|
||||||
JSON.stringify({ ...saved, shipment: 6, destination: 'sand', shipped: true })]) {
|
JSON.stringify({ ...saved, shipment: 6, destination: 'sand', shipped: true })]) {
|
||||||
|
|||||||
Reference in new issue
Block a user