Prototype native Diplomat base-defense copying
Allow one committed Worthless to copy an opposing base Shield or Snooper when no actual defense is played, including protection of the selected native trained leader. Expose an optional named public choice after both plans reveal, before traitor calls, and show the activated protection in revealed plans. Discard the chosen physical Worthless once after battle, including winner and traitor-preempted outcomes. Original physical card identities and plans stay intact. Timing and explicit-discard precedence are documented implementation inferences; lower retreat, modified defenses, combined modes and public starts remain guarded. Reuse legal Worthless planning candidates and add a public-information decision path for all four AI profiles. Bind new battle choices to revealed plans, public skill stance, canonical card custody and an independent event, validate saved continuations before reads/actions, omit internal proof fields from views, and retain unmarked legacy-battle behavior. Independent rules/privacy/persistence review found no blocker. Validation: focused Diplomat rules/UI/bot/JSON/SQLite checks plus 80 related legacy checks passed; typecheck, lint, 4537 offline tests, production build and 43 HTTP tests passed. Concurrent copy versus decline, stale/corrupt decisions, self-protection and mandatory traitor-winner discard are covered. Browser genuine assignment and a conserved staged battle exercised human plan submission, refresh of the pending choice, actual Snooper copying with Baliset, Hasimir survival, exactly one Baliset discard, ordinary Crysknife retention and final refresh with private cards and four board forces, fifteen reserves, one Tank and eight spice. Basic/Advanced four-profile samples completed in 210/223 actions with six/seven JSON restores and no rejected candidates; neither encountered an eligible copy, so targeted checks establish the effect. The server was healthy after the power outage. All 563 opening rooms and original seat/recovery records were preserved (597 after QA/HTTP); SQLite integrity and foreign keys passed. No restart, reset or automation was needed. Verified source tree: 868bb7484cef244e6ab20f8d08d62e118629e257870cf91a436bf42651b968f5
This commit is contained in:
1 parent
788453e07e
commit
f067950f5e
21 files changed
+1463
-25
No files matched your search
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { battleCardLabel } from '@/game/battle-cards';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function DiplomatDefense({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const decision =
|
||||
game.decision?.kind === 'diplomatDefense' ? game.decision : null;
|
||||
if (!decision || decision.player !== game.me) return null;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
const visibleCards = [...(game.battle?.cards ?? []), ...(me?.hand ?? [])];
|
||||
const card = (id: string) => visibleCards.find((entry) => entry.id === id);
|
||||
const source = card(decision.source);
|
||||
const copiedDefense = source ? battleCardLabel(source.kind) : 'defense';
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-label="Diplomat defense choice">
|
||||
<p>
|
||||
The opposing committed defense,{' '}
|
||||
<strong>{source?.name ?? decision.source}</strong>, provides{' '}
|
||||
<strong>{copiedDefense}</strong> protection. Diplomat can copy that
|
||||
protection onto one of your committed Worthless cards.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Choose one committed Worthless card as the copied defense. That card
|
||||
must be discarded after this battle. Decline to proceed without a copied
|
||||
defense.
|
||||
</p>
|
||||
{decision.cards.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
!busy && act({ type: 'decision', event: decision.event, card: id })
|
||||
}
|
||||
>
|
||||
Copy {source?.name ?? copiedDefense} with {card(id)?.name ?? id} ·
|
||||
mandatory discard
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
!busy && act({ type: 'decision', event: decision.event, card: null })
|
||||
}
|
||||
>
|
||||
Decline Diplomat defense
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -152,6 +152,7 @@ import { SpiceBankerControl, spiceBankerControlState } from './spice-banker';
|
||||
import { canUsePlanetologistBattleSpecial, validLeaderSkillBattleCardPair } from '../game/leader-skill-combat';
|
||||
import type { Card } from '../game/cards';
|
||||
import { RevealedBattle } from './revealed-battle';
|
||||
import { DiplomatDefense } from './diplomat-defense';
|
||||
import { EliteCount } from './elite-count';
|
||||
import { HelpTip } from './help-tip';
|
||||
import { PHASE_HELP, phaseRuleId } from '@/game/reference';
|
||||
@@ -2135,6 +2136,8 @@ export function GameTable({
|
||||
? g.decision.stage === 'offer' ? 'Rihani Decipherer — optional draw' : 'Choose your Traitor exchange'
|
||||
: g.decision.kind === 'sukRescue'
|
||||
? 'Save your battle casualties'
|
||||
: g.decision.kind === 'diplomatDefense'
|
||||
? 'Copy an opposing defense'
|
||||
: g.decision.kind === 'homeworldRevivalDeployment'
|
||||
? 'Revival deployment'
|
||||
: g.decision.kind === 'ecazSpice'
|
||||
@@ -2546,6 +2549,11 @@ export function GameTable({
|
||||
mode: 'ignore',
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'diplomatDefense' ? (
|
||||
<>
|
||||
<RevealedBattle game={g} />
|
||||
<DiplomatDefense game={g} act={act} busy={busy} />
|
||||
</>
|
||||
) : g.decision.kind === 'poisonTooth' ? (
|
||||
<>
|
||||
<RevealedBattle game={g} />
|
||||
|
||||
@@ -112,9 +112,12 @@ export function LeaderSkillsPanel({
|
||||
This development-only module currently connects the card-role strength
|
||||
modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz,
|
||||
Killer Medic and Prana-Bindu Adept, plus Planetologist movement and
|
||||
Special-card battle use, Suk Graduate force rescue, Mentat and Bureaucrat
|
||||
battle scores, Sandmaster ground-route collection and victory spice, Rihani inspections/exchanges, optional Smuggler reserve shipping and Spice Banker battle spending. Other Leader Skill effects are
|
||||
unfinished.
|
||||
Special-card battle use, Suk Graduate force rescue, Mentat and
|
||||
Bureaucrat battle scores, Sandmaster ground-route collection and
|
||||
victory spice, Rihani inspections and exchanges, optional Smuggler
|
||||
reserve shipping, Spice Banker battle spending, and Diplomat copying
|
||||
an opposing base defense with a committed Worthless card after plans
|
||||
are revealed. Other Leader Skill effects are unfinished.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
<div className="revealed-plans" aria-label="Revealed battle plans">
|
||||
{Object.entries(battle.plans).map(([id, plan]) => {
|
||||
const leader = game.allLeaders.find((l) => l.id === plan.leader);
|
||||
const diplomat =
|
||||
battle.diplomatDefense?.player === id ? battle.diplomatDefense : null;
|
||||
const undialed = [
|
||||
...new Set(
|
||||
comparisons.flatMap(
|
||||
@@ -91,6 +93,15 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
</span>
|
||||
)}
|
||||
<span>Defense in original plan: {name(plan.defense)}</span>
|
||||
{diplomat?.stage === 'copied' && diplomat.card && (
|
||||
<span>
|
||||
Diplomat: {name(diplomat.card)} copies {name(diplomat.source)} ·
|
||||
discard after battle
|
||||
</span>
|
||||
)}
|
||||
{diplomat?.stage === 'declined' && (
|
||||
<span>Diplomat: defense copy declined</span>
|
||||
)}
|
||||
{battle.lateDefense[id] && (
|
||||
<span>Added after reveal: {name(battle.lateDefense[id])}</span>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,14 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
|
||||
|
||||
## Evidence and source of truth
|
||||
|
||||
- [Diplomat defense copying](DIPLOMAT_DEFENSE.md) connects one committed Worthless
|
||||
card to an opposing base Shield or Snooper, with optional controls, all four AI
|
||||
profiles, native trained-leader self-protection and mandatory discard. Saved
|
||||
choices and concurrent resolution have focused recovery evidence. Lower retreat
|
||||
and combined modules remain unfinished; no public gate opens. After the latest
|
||||
outage, the existing server was healthy and all 563 opening games and original
|
||||
seat records matched the backup. Final checks are recorded in the checkpoint.
|
||||
|
||||
- [Smuggler with Richese No-Field](SMUGGLER_NO_FIELD.md) connects the officially
|
||||
permitted free physical companion, unchanged marker price, private controls,
|
||||
all four AI profiles and saved cancellation/interception. Genuine CHOAM &
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Diplomat copied-defense prototype
|
||||
|
||||
13 September 2026. **Prototyped**, with **Partial** rules coverage. This connects
|
||||
the Diplomat's normal band for canonical base Shield and Snooper defenses only.
|
||||
The lower retreat band, combined modules and other defense cards remain
|
||||
unfinished. Public Leader Skills starts and publication remain gated.
|
||||
|
||||
## Source and interpretation
|
||||
|
||||
The archived physical card says that, when its owner plays no defense, one
|
||||
Worthless card in that Battle Plan may count as the defense the opponent used,
|
||||
and directs that Worthless card to be discarded afterward. Jack Reda repeats
|
||||
the effect in the [designer walkthrough at 14:11](https://www.youtube.com/watch?v=XT_azRVLq_0&t=851s).
|
||||
The [GF9 CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf),
|
||||
page 9, keeps a normal skill active while its native trainer and card are face
|
||||
up. It also expressly keeps the first band available when that trained disc is
|
||||
selected for battle, if applicable. Page 12 limits a captured skill to its
|
||||
lower band. The archived component photograph and transcript are retained in
|
||||
the local source archive described by [the common source contract](LEADER_SKILLS_RULES.md).
|
||||
|
||||
The connected base interpretation therefore accepts one actual Worthless card
|
||||
from either plan slot, or offers a choice when both slots contain Worthless
|
||||
cards. An actual defense makes the skill unavailable. The opponent must have
|
||||
played a canonical base Shield or Snooper; an empty defense supplies nothing to
|
||||
copy. The copied role protects exactly like that base defense without changing
|
||||
the Worthless card's identity or either revealed plan. This lets the selected
|
||||
native Diplomat protect itself: copied protection is resolved first, then the
|
||||
common surviving-trainer rule is evaluated. If the copied type does not stop
|
||||
the weapon, removing the dead trainer's skill does not change that outcome.
|
||||
|
||||
Two timing and cleanup rules are explicit implementation inferences. The
|
||||
optional public choice occurs after both plans reveal and before traitor calls,
|
||||
because the copied defense cannot be known when the Worthless card is sealed.
|
||||
The chosen Worthless is always discarded after resolution, including when a
|
||||
successful traitor call preempts ordinary weapon effects. This combines the
|
||||
card's explicit discard instruction with the chosen scheduling point; the
|
||||
publisher sources do not separately address Diplomat versus traitor timing.
|
||||
The [November 2020 GF9 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf),
|
||||
page 8, supplies the general winner-retention rule, which the chosen card's
|
||||
specific discard instruction overrides in this prototype.
|
||||
|
||||
## Connected behavior
|
||||
|
||||
New supported battles carry a version marker and an independent event-bound
|
||||
receipt. After reveal, the server derives the eligible physical Worthless IDs,
|
||||
opposing defense and live native assignment from current custody. Choosing one
|
||||
records its copied kind; declining records that choice. Either result preserves
|
||||
the original plans and leaves every card in its hand until battle resolution.
|
||||
Legacy battles without the new marker keep their existing continuation and do
|
||||
not gain a retroactive Diplomat opportunity.
|
||||
|
||||
The copied base role feeds ordinary weapon survival and existing Poison or
|
||||
Projectile Defense skill checks. The physical Worthless identity remains
|
||||
available to checks such as Warmaster. Only the chosen Worthless receives the
|
||||
mandatory skill discard; a second played Worthless follows ordinary winner or
|
||||
loser cleanup. The receipt is revalidated before views, normalization and
|
||||
actions against battle identity, turn, territory, plans, public skill posture,
|
||||
card faces and unique physical custody.
|
||||
|
||||
The decision and its already-revealed card IDs are public. Internal frame,
|
||||
signature and binding fields are not projected. Before joint reveal, ordinary
|
||||
sealed-plan and hand privacy remain unchanged. Human controls name the copied
|
||||
defense and each eligible Worthless card, and allow an explicit decline. All
|
||||
four AI profiles use the public revealed cards and shared legality; no opposing
|
||||
hand or pre-reveal plan is inspected.
|
||||
|
||||
## Evidence and remaining scope
|
||||
|
||||
Sixteen focused checks were observed before the broad checkpoint run. They
|
||||
cover the pure base quote, Basic and Advanced battles, either Worthless slot,
|
||||
two-card choice, same-disc survival, Shield explosion composition, captured
|
||||
discipline strength, decline, controls, all four AI profiles, private/public
|
||||
views, JSON/SQLite restart, concurrent choice, traitor cleanup, corruption and
|
||||
legacy continuation. Independent rules, privacy and persistence review found no
|
||||
blocker in this bounded path. A new isolated browser room used genuine skill
|
||||
assignment before a conserved staged battle. The human sealed Hasimir Fenring,
|
||||
Crysknife and Baliset, restored the pending copy choice, copied an opposing
|
||||
Snooper and resolved. Hasimir survived poison; Baliset was discarded exactly
|
||||
once. Keeping Crysknife and refreshing preserved that private card and traitor,
|
||||
four board forces, fifteen reserves, one Tank and eight spice. This is a targeted
|
||||
browser journey, not full-game acceptance.
|
||||
|
||||
Two genuine four-profile Basic/Advanced samples completed in 210/223 actions with
|
||||
six/seven JSON restores and no rejected candidates. Neither encountered an
|
||||
eligible copy; targeted tests and the browser establish the effect. Required
|
||||
broad check results and final saved-game preservation are recorded in the
|
||||
checkpoint commit and private source-bound report.
|
||||
|
||||
The lower Diplomat retreat remains pending with its recorded strength and
|
||||
resolution-order questions. Shield Snooper, Chemistry, defensive Weirding Way,
|
||||
Portable Snooper, Carthag-added protection and any future copied or modified
|
||||
defense need a separate contract for whether generic roles or named exceptions
|
||||
are inherited. Homeworlds, Nexus, Discoveries, Stronghold Cards, Tech Tokens and
|
||||
other combined modules remain guarded. These limits preserve the existing
|
||||
public gates.
|
||||
@@ -1,5 +1,32 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 September 2026 — Diplomat base-defense copying prototype
|
||||
|
||||
[Diplomat](DIPLOMAT_DEFENSE.md) now optionally counts one committed Worthless as
|
||||
an opposing base Shield or Snooper, including to save its native trained leader.
|
||||
The owner chooses after public plan reveal and before traitor decisions. Exact
|
||||
physical plans and cards stay in place; the selected Worthless is discarded after
|
||||
battle even on a win. This timing and explicit-discard precedence over ordinary
|
||||
traitor retention are recorded implementation inferences, not additional printed
|
||||
rulings. Lower retreat and modified/combined defenses remain unfinished.
|
||||
|
||||
Player controls, all four legal AI paths, public projection and saved decisions
|
||||
are connected. Independent review and focused SQLite restart, concurrent copy or
|
||||
decline, corruption rejection, self-protection and traitor-preempted discard
|
||||
checks cover this boundary. Two genuine four-profile samples completed without
|
||||
rejected candidates: Basic used 210 actions and six JSON restores; Advanced used
|
||||
223 actions and seven restores. Neither encountered an eligible Diplomat offer;
|
||||
targeted battle checks establish the effect. A fresh browser scenario restored
|
||||
the pending copy, saved native Hasimir from poison, discarded Baliset exactly
|
||||
once, kept Crysknife and restored the private hand/traitor plus four board forces,
|
||||
fifteen reserves, one Tank and eight spice. Required broad results are recorded
|
||||
in the checkpoint commit and private report.
|
||||
|
||||
After the new outage report the server responded normally, database integrity and
|
||||
foreign keys passed, and all 563 opening rooms plus their original seat/recovery
|
||||
records matched the backup. No restart, reset or automation was required. Public
|
||||
mode and publication gates stay closed.
|
||||
|
||||
## 13 September 2026 — Smuggler with owned Richese No-Field
|
||||
|
||||
The [officially permitted combination](SMUGGLER_NO_FIELD.md) now ships one
|
||||
|
||||
@@ -53,10 +53,13 @@ publisher instruction to announce a name.
|
||||
|
||||
[Battle-effects follow-up](LEADER_BATTLE_EFFECTS.md) connects both Rihani bands
|
||||
and the lower Mentat, Bureaucrat and Sandmaster effects. Their normal questioning,
|
||||
payments and movement collection remain missing, along with Spice Banker normal
|
||||
income, Diplomat effects and Smuggler battle collection. [Banker battle spending](SPICE_BANKER_RUNTIME.md)
|
||||
payment redirection and Spice Banker income remain missing. [Banker battle spending](SPICE_BANKER_RUNTIME.md)
|
||||
now connects its lower band with separate sealed funds and survivor strength. [Smuggler shipping](SMUGGLER_SHIPMENT.md)
|
||||
connects its optional normal reserve bonus and the [owned No-Field companion](SMUGGLER_NO_FIELD.md).
|
||||
[Diplomat defense](DIPLOMAT_DEFENSE.md) connects its normal band for a physical
|
||||
Worthless card copying a canonical base Shield or Snooper; the lower retreat
|
||||
and modified-defense inheritance remain guarded. Smuggler battle collection
|
||||
also remains missing.
|
||||
Planetologist and Suk Graduate have the connected
|
||||
prototypes described below.
|
||||
|
||||
@@ -69,8 +72,9 @@ Before enabling them, add response-driven automatic revival continuation
|
||||
coverage; the current before/after revival observer does not certify deferred
|
||||
Tleilaxu response chains. The other source-contract timing questions also remain
|
||||
open. Planetologist now has bounded movement and battle controls as described below.
|
||||
Remaining normal-band payments, questions, collection and the remaining
|
||||
skill bands still need connected effects.
|
||||
Remaining normal-band payments and questions, Smuggler battle collection,
|
||||
Diplomat retreat and the other guarded skill combinations still need connected
|
||||
effects.
|
||||
|
||||
## Planetologist follow-up
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
|
||||
| Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) |
|
||||
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) |
|
||||
| Sandmaster movement | One optional board spice per distinct entered territory on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing; multiple-pile collection remains guarded. | [Source and connected prototype](SANDMASTER_MOVEMENT.md) |
|
||||
| Diplomat defense | With no actual defense, one physical Worthless card in either Battle Plan slot may copy the opponent's canonical base Shield or Snooper. The same native trained disc may protect itself. The public after-reveal/before-traitor choice and mandatory discard after traitor preemption are documented implementation inferences. Lower retreat and modified or multirole defenses remain guarded. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) |
|
||||
| Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Multiple piles and Face Dancer composition stay guarded. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md) |
|
||||
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
|
||||
| Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) |
|
||||
@@ -20,7 +21,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
|
||||
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
|
||||
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
|
||||
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
|
||||
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster and normal Smuggler now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
|
||||
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
|
||||
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine have bounded prototypes; Orgiz and contested/mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
|
||||
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
|
||||
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
|
||||
@@ -35,7 +36,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
||||
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and remaining battle timing stay guarded. Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
|
||||
| Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered; arithmetic helpers do not admit the full battle card. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) |
|
||||
| Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
|
||||
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody (user question sent), Smuggler survival settlement, Mentat empty-hand/death disclosure, Diplomat retreat ordering and simultaneous optional timing need resolution before those cases are enabled. These are newly recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources) |
|
||||
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody (user question sent), Smuggler survival settlement, Mentat empty-hand/death disclosure, Diplomat lower retreat ordering, modified-defense inheritance and remaining simultaneous optional timing need resolution before those cases are enabled. These are recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources), [Diplomat boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
|
||||
| Suk Graduate / Atreides | Whether rescued dialed casualties count toward the seven-loss Kwisatz Haderach threshold remains unresolved. The user question has been sent; Advanced Atreides rescue stays guarded. | [Source and runtime boundary](SUK_GRADUATE_RULES.md) |
|
||||
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
|
||||
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
|
||||
|
||||
@@ -31,6 +31,7 @@ import { auditCount } from './choam-auditor';
|
||||
import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate';
|
||||
import { validateSpiceBankerSpend } from './spice-banker';
|
||||
import { rihaniVictorySkill, type RihaniSkill } from './rihani-decipherer';
|
||||
import { copiedDiplomatDefense, quoteDiplomatDefense, type DiplomatDefenseQuote } from './diplomat-defense';
|
||||
import {
|
||||
quoteHomeworldBattleRules,
|
||||
type HomeworldBattleRules,
|
||||
@@ -71,6 +72,7 @@ export type ResolutionParticipant = {
|
||||
noFieldAtTerritory?: boolean;
|
||||
};
|
||||
export type ResolutionCombatant = ResolutionParticipant & {
|
||||
diplomatDefense?: Pick<DiplomatDefenseQuote, 'leader' | 'source' | 'kind'> & { card: string };
|
||||
spice: number;
|
||||
hand: readonly Card[];
|
||||
plan: ResolutionPlan;
|
||||
@@ -346,6 +348,19 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
dw = card(d, d.plan.weapon),
|
||||
ad = card(a, a.lateDefense ?? a.plan.defense),
|
||||
dd = card(d, d.lateDefense ?? d.plan.defense);
|
||||
const effectiveDefense = (side: ResolutionCombatant, other: ResolutionCombatant, defense: Card | undefined) => {
|
||||
const copy = side.diplomatDefense;
|
||||
if (copy === undefined) return defense;
|
||||
const quote = quoteDiplomatDefense({ assignments: side.leaderSkills ?? [], selectedLeader: side.plan.leader,
|
||||
weapon: card(side, side.plan.weapon), defense: card(side, side.plan.defense),
|
||||
opposingDefense: card(other, other.plan.defense) });
|
||||
requireQuote(copy && quote && !side.lateDefense && !other.lateDefense &&
|
||||
!side.stronghold && !other.stronghold && !input.homeworld &&
|
||||
copy.leader === quote.leader && copy.source === quote.source && copy.kind === quote.kind && quote.cards.includes(copy.card),
|
||||
'The copied Diplomat defense lost its native trainer, committed Worthless or opposing base defense.');
|
||||
return copiedDiplomatDefense(quote, copy.card);
|
||||
};
|
||||
const effectiveAd = effectiveDefense(a, d, ad), effectiveDd = effectiveDefense(d, a, dd);
|
||||
const tie =
|
||||
d.stronghold === 'habbanya_ridge_sietch' ? 'defender' : 'attacker';
|
||||
const stone =
|
||||
@@ -511,9 +526,9 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
} else {
|
||||
effects = strongholdBattleEffects(
|
||||
aw,
|
||||
ad,
|
||||
effectiveAd,
|
||||
dw,
|
||||
dd,
|
||||
effectiveDd,
|
||||
a.poisonTooth,
|
||||
d.poisonTooth,
|
||||
a.stronghold,
|
||||
@@ -536,6 +551,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
selectedLeader: selectedLeader(a),
|
||||
weapon: aw,
|
||||
defense: ad,
|
||||
effectiveDefense: effectiveAd,
|
||||
skilledLeaderSurvives: !deaths.attacker,
|
||||
bankerSpice: a.plan.bankerSpice,
|
||||
}),
|
||||
@@ -544,6 +560,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
selectedLeader: selectedLeader(d),
|
||||
weapon: dw,
|
||||
defense: dd,
|
||||
effectiveDefense: effectiveDd,
|
||||
skilledLeaderSurvives: !deaths.defender,
|
||||
bankerSpice: d.plan.bankerSpice,
|
||||
}),
|
||||
@@ -697,12 +714,14 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
selectedLeader: side.plan.leader,
|
||||
card: card(side, selected),
|
||||
});
|
||||
const mandatorySkillDiscard = (side: ResolutionCombatant, selected: string) =>
|
||||
mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected;
|
||||
const moritani = input.participants.find((p) => p.faction === 'moritani');
|
||||
if (winner && moritani) {
|
||||
const loser = winner === a ? d : a,
|
||||
loserPlayed = loser === a ? played.attacker : played.defender;
|
||||
const retainablePlayed = loserPlayed.filter(
|
||||
(selected) => !mandatoryPlanetologistDiscard(loser, selected),
|
||||
(selected) => !mandatorySkillDiscard(loser, selected),
|
||||
);
|
||||
const eligible = retainablePlayed.filter(
|
||||
(selected) =>
|
||||
@@ -740,9 +759,9 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
!(
|
||||
retention?.player === s.id &&
|
||||
retention.played.includes(selected) &&
|
||||
!mandatoryPlanetologistDiscard(s, selected)
|
||||
!mandatorySkillDiscard(s, selected)
|
||||
) &&
|
||||
(mandatoryPlanetologistDiscard(s, selected) ||
|
||||
(mandatorySkillDiscard(s, selected) ||
|
||||
winner !== s ||
|
||||
!canRetainBattleCard(
|
||||
card(s, selected)!,
|
||||
|
||||
@@ -1481,6 +1481,32 @@ function policyActions(g: GameView): Action[] {
|
||||
stronghold,
|
||||
}));
|
||||
}
|
||||
if (d.kind === 'diplomatDefense') {
|
||||
const battle = g.battle;
|
||||
const opponent = battle
|
||||
? battle.attacker === me.id
|
||||
? battle.defender
|
||||
: battle.attacker
|
||||
: null;
|
||||
const enemyWeapon = battle?.cards.find(
|
||||
(card) => card.id === (opponent ? battle.plans[opponent]?.weapon : null),
|
||||
);
|
||||
const copiedDefense = battle?.cards.find(
|
||||
(card) => card.id === d.source,
|
||||
);
|
||||
const useful =
|
||||
!!enemyWeapon &&
|
||||
!!copiedDefense &&
|
||||
weaponKills(enemyWeapon) &&
|
||||
!weaponKills(enemyWeapon, copiedDefense);
|
||||
return [
|
||||
{
|
||||
type: 'decision',
|
||||
event: d.event,
|
||||
card: useful ? (d.cards[0] ?? null) : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (d.kind === 'richeseAllyOpportunity') {
|
||||
const offer = alliedNoFieldOffer(g);
|
||||
return offer
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { baseDeck, type Card } from './cards';
|
||||
import type { BattleLeaderSkill } from './leader-skill-combat';
|
||||
|
||||
export type DiplomatDefenseQuote = {
|
||||
leader: string;
|
||||
cards: string[];
|
||||
source: string;
|
||||
kind: 'shield' | 'snooper';
|
||||
};
|
||||
|
||||
/** Planning eligibility does not inspect the opponent's unsubmitted cards. */
|
||||
export function diplomatTrainer(
|
||||
assignments: readonly BattleLeaderSkill[],
|
||||
selectedLeader: string | null | undefined,
|
||||
): BattleLeaderSkill | undefined {
|
||||
return assignments.find(
|
||||
(a) =>
|
||||
a.skill === 'diplomat' &&
|
||||
!a.captured &&
|
||||
(a.faceUp || a.leader === selectedLeader),
|
||||
);
|
||||
}
|
||||
|
||||
const baseDefenses = new Map(
|
||||
baseDeck()
|
||||
.filter((card) => card.kind === 'shield' || card.kind === 'snooper')
|
||||
.map((card) => [card.id, card]),
|
||||
);
|
||||
|
||||
/** A copied role does not change either physical card or either sealed plan. */
|
||||
export function quoteDiplomatDefense(input: {
|
||||
assignments: readonly BattleLeaderSkill[];
|
||||
selectedLeader: string | null | undefined;
|
||||
weapon?: Card;
|
||||
defense?: Card;
|
||||
opposingDefense?: Card;
|
||||
}): DiplomatDefenseQuote | null {
|
||||
const assignment = diplomatTrainer(input.assignments, input.selectedLeader);
|
||||
if (!assignment || (input.defense && input.defense.kind !== 'worthless'))
|
||||
return null;
|
||||
const source = input.opposingDefense;
|
||||
const printed = source && baseDefenses.get(source.id);
|
||||
if (
|
||||
!source ||
|
||||
!printed ||
|
||||
printed.kind !== source.kind ||
|
||||
printed.name !== source.name
|
||||
)
|
||||
return null;
|
||||
const cards = [input.weapon, input.defense]
|
||||
.filter((card): card is Card => card?.kind === 'worthless')
|
||||
.map((card) => card.id);
|
||||
if (!cards.length || new Set(cards).size !== cards.length) return null;
|
||||
return {
|
||||
leader: assignment.leader,
|
||||
cards,
|
||||
source: source.id,
|
||||
kind: source.kind as 'shield' | 'snooper',
|
||||
};
|
||||
}
|
||||
|
||||
/** Native skill inputs must already exclude dead or foreign-ghola trainers. */
|
||||
export function copiedDiplomatDefense(
|
||||
quote: DiplomatDefenseQuote,
|
||||
card: string,
|
||||
): Card {
|
||||
if (!quote.cards.includes(card))
|
||||
throw new Error('Choose one committed Worthless card for Diplomat.');
|
||||
return { id: card, name: 'Diplomat defense', kind: quote.kind };
|
||||
}
|
||||
|
||||
export function diplomatDefenseModeSupported(game: {
|
||||
expansions: readonly string[];
|
||||
homeworlds?: unknown;
|
||||
nexusCards?: unknown;
|
||||
discoveries?: unknown;
|
||||
discoveryEnabled?: unknown;
|
||||
strongholdCards?: unknown;
|
||||
techTokens?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
!game.expansions.length &&
|
||||
!game.homeworlds &&
|
||||
!game.nexusCards &&
|
||||
!game.discoveries &&
|
||||
!game.discoveryEnabled &&
|
||||
!game.strongholdCards &&
|
||||
!game.techTokens
|
||||
);
|
||||
}
|
||||
+130
@@ -14,6 +14,7 @@ import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFiel
|
||||
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
|
||||
import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
|
||||
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
|
||||
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
|
||||
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
|
||||
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
|
||||
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
|
||||
@@ -578,7 +579,23 @@ export type PlanField = Exclude<
|
||||
keyof Plan,
|
||||
'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice'
|
||||
>;
|
||||
export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
|
||||
event: string;
|
||||
player: string;
|
||||
battle: string;
|
||||
turn: number;
|
||||
territory: string;
|
||||
/** Only the revealed plans, played cards and public skill stance are bound. */
|
||||
frame: string;
|
||||
stage: 'offered' | 'copied' | 'declined';
|
||||
card: string | null;
|
||||
signature: string;
|
||||
};
|
||||
export type Battle = {
|
||||
/** New battles opt into the revealed Diplomat step; legacy battles are not reopened. */
|
||||
diplomatDefenseVersion?: 1;
|
||||
diplomatDefenseEvent?: string;
|
||||
diplomatDefense?: DiplomatDefenseReceipt;
|
||||
/** Public face-up/behind-shield choice precedes faction battle powers. */
|
||||
leaderSkillHidden?: Record<string, boolean>;
|
||||
nexusInspection?: NexusInspection;
|
||||
@@ -639,6 +656,7 @@ export type Auction = {
|
||||
allyPayment?: number;
|
||||
};
|
||||
export type Decision =
|
||||
| { kind: 'diplomatDefense'; player: string; event: string; cards: string[]; source: string }
|
||||
| { kind: 'leaderSkillVisibility'; player: string; event: string; resumePowers?: boolean }
|
||||
| { kind: 'leaderSkillRevival'; player: string; event: string }
|
||||
| { kind: 'homeworldRevivalDeployment'; player: string; event: string }
|
||||
@@ -4927,6 +4945,7 @@ function leaderSkillAssignmentUnavailable(
|
||||
: null;
|
||||
}
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
diplomatDefenseIntegrity(g);
|
||||
const noFieldShipment = leaderSkillNoFieldIntegrity(g);
|
||||
sandmasterIntegrity(g);
|
||||
sukRescueIntegrity(g);
|
||||
@@ -14072,8 +14091,95 @@ function stoneBurnerIntegrity(g: Game) {
|
||||
);
|
||||
}
|
||||
}
|
||||
function diplomatDefenseOffer(g: Game): (DiplomatDefenseQuote & { player: string }) | null {
|
||||
const b = g.battle;
|
||||
if (!b?.revealed || !g.leaderSkills || !diplomatDefenseModeSupported(g) ||
|
||||
b.lateDefense && Object.keys(b.lateDefense).length) return null;
|
||||
for (const player of [b.attacker, b.defender]) {
|
||||
const p = getPlayer(g, player), plan = b.plans[player];
|
||||
const other = getPlayer(g, b.attacker === player ? b.defender : b.attacker);
|
||||
const opposingPlan = b.plans[other.id];
|
||||
if (!plan || !opposingPlan) continue;
|
||||
const quote = quoteDiplomatDefense({
|
||||
assignments: g.leaderSkills.assignments.filter(a => a.owner === player && a.skill === 'diplomat' &&
|
||||
p.leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy))
|
||||
.map(a => ({ skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[player], captured: false })),
|
||||
selectedLeader: plan.leader,
|
||||
weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense),
|
||||
opposingDefense: cardOf(other, opposingPlan.defense),
|
||||
});
|
||||
if (quote) return { ...quote, player };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function diplomatDefenseFrame(g: Game): string {
|
||||
const b = g.battle!;
|
||||
return JSON.stringify({ event: b.event, turn: g.turn, territory: b.territory,
|
||||
attacker: b.attacker, defender: b.defender, plans: b.plans, hidden: b.leaderSkillHidden,
|
||||
cards: [b.attacker, b.defender].map(id => {
|
||||
const p = getPlayer(g, id), plan = b.plans[id];
|
||||
return [plan.weapon, plan.defense, plan.leader].map(selected => cardOf(p, selected) ?? null);
|
||||
}) });
|
||||
}
|
||||
function diplomatDefenseSignature(receipt: DiplomatDefenseReceipt): string {
|
||||
return JSON.stringify({ ...receipt, signature: undefined });
|
||||
}
|
||||
function diplomatDefenseIntegrity(g: Game) {
|
||||
const b = g.battle;
|
||||
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'diplomatDefense');
|
||||
if (b?.diplomatDefenseVersion === undefined && b?.diplomatDefense === undefined &&
|
||||
b?.diplomatDefenseEvent === undefined && !decisions.length) return;
|
||||
requireRule(b && b.diplomatDefenseVersion === 1,
|
||||
'The saved Diplomat choice lost its original battle version.');
|
||||
const receipt = b.diplomatDefense;
|
||||
const offer = diplomatDefenseOffer(g);
|
||||
if (!receipt) {
|
||||
requireRule(receipt === undefined && b.diplomatDefenseEvent === undefined && !decisions.length && !offer,
|
||||
'The revealed Diplomat defense lost its original choice.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.status === 'playing' && g.phase === 6 && b.revealed && offer &&
|
||||
receipt.battle === b.event && receipt.turn === g.turn && receipt.territory === b.territory &&
|
||||
typeof receipt.event === 'string' && receipt.event.length > 0 && receipt.event === b.diplomatDefenseEvent &&
|
||||
receipt.frame === diplomatDefenseFrame(g) && receipt.signature === diplomatDefenseSignature(receipt) &&
|
||||
JSON.stringify({leader: receipt.leader, cards: receipt.cards, source: receipt.source, kind: receipt.kind, player: receipt.player}) === JSON.stringify(offer),
|
||||
'The saved Diplomat defense changed its revealed plans, trainer, source or physical card choices.');
|
||||
requireRule(['offered', 'copied', 'declined'].includes(receipt.stage) &&
|
||||
(receipt.stage === 'copied' ? typeof receipt.card === 'string' && receipt.cards.includes(receipt.card) : receipt.card === null),
|
||||
'The saved Diplomat defense changed its optional choice or selected Worthless card.');
|
||||
const physical = physicalTreacheryCards(g);
|
||||
for (const card of [...receipt.cards, receipt.source]) requireRule(physical.filter(c => c.id === card).length === 1,
|
||||
'The committed Diplomat cards must retain unique physical custody.');
|
||||
requireRule(decisions.length === (receipt.stage === 'offered' ? 1 : 0),
|
||||
'The saved Diplomat defense lost or reopened its unique public decision.');
|
||||
if (receipt.stage === 'offered') requireRule(Object.keys(b.traitorCalls).length === 0,
|
||||
'Diplomat defense must be chosen before any traitor decisions.');
|
||||
for (const decision of decisions) requireRule(decision.player === receipt.player &&
|
||||
decision.event === receipt.event && decision.source === receipt.source &&
|
||||
JSON.stringify(decision.cards) === JSON.stringify(receipt.cards),
|
||||
'The Diplomat decision no longer matches its original public choices.');
|
||||
}
|
||||
function offerDiplomatDefense(g: Game): boolean {
|
||||
const b = g.battle!;
|
||||
if (b.diplomatDefenseVersion !== 1 || b.diplomatDefense) return false;
|
||||
const offer = diplomatDefenseOffer(g);
|
||||
if (!offer) return false;
|
||||
requireRule(b.event && !Object.keys(b.traitorCalls).length,
|
||||
'Diplomat defense needs the current revealed battle before traitor decisions.');
|
||||
const event = crypto.randomUUID();
|
||||
const receipt: DiplomatDefenseReceipt = { ...offer, event, battle: b.event, turn: g.turn,
|
||||
territory: b.territory, frame: diplomatDefenseFrame(g), stage: 'offered', card: null, signature: '' };
|
||||
receipt.signature = diplomatDefenseSignature(receipt);
|
||||
b.diplomatDefense = receipt;
|
||||
b.diplomatDefenseEvent = event;
|
||||
g.decision = { kind: 'diplomatDefense', player: offer.player, event, cards: [...offer.cards], source: offer.source };
|
||||
log(g, `${getPlayer(g, offer.player).name} may use Diplomat to make one committed Worthless card copy the opposing ${offer.kind === 'shield' ? 'Shield' : 'Snooper'} before traitor decisions.`,
|
||||
{ faction: getPlayer(g, offer.player).faction, name: 'Diplomat defense' });
|
||||
return true;
|
||||
}
|
||||
function nextRevealedDecision(g: Game) {
|
||||
const b = g.battle!;
|
||||
if (offerDiplomatDefense(g)) return;
|
||||
const stoneOwner = [b.attacker, b.defender].find(
|
||||
(id) =>
|
||||
isStoneBurner(cardOf(getPlayer(g, id), b.plans[id].weapon)) &&
|
||||
@@ -14150,6 +14256,9 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
forces: combatForces(g, p, b.territory, opponent),
|
||||
stronghold: strongholdEffect(g, p.id),
|
||||
lateDefense: b.lateDefense?.[p.id],
|
||||
...(b.diplomatDefense?.stage === 'copied' && b.diplomatDefense.player === p.id
|
||||
? { diplomatDefense: { leader: b.diplomatDefense.leader, card: b.diplomatDefense.card!,
|
||||
source: b.diplomatDefense.source, kind: b.diplomatDefense.kind } } : {}),
|
||||
poisonTooth: b.poisonTooth?.[p.id],
|
||||
stoneMode: b.stoneBurner?.[p.id],
|
||||
...(aid ? { aid: { donor: p.ally!, amount: aid.amount } } : {}),
|
||||
@@ -20350,6 +20459,20 @@ function applyActionInner(
|
||||
if (harkonnen) returnCaptives(g, harkonnen);
|
||||
settleAdvisors(g);
|
||||
finishBattle(g);
|
||||
} else if (decision.kind === 'diplomatDefense') {
|
||||
const receipt = g.battle?.diplomatDefense;
|
||||
requireRule(Object.keys(action).every(key => ['type', 'event', 'card'].includes(key)) &&
|
||||
action.event === decision.event && receipt?.event === decision.event && receipt.player === id &&
|
||||
receipt.stage === 'offered' && (action.card === null || typeof action.card === 'string' && receipt.cards.includes(action.card)),
|
||||
'Choose one offered Worthless card or decline this exact Diplomat defense.');
|
||||
receipt.card = action.card as string | null;
|
||||
receipt.stage = receipt.card ? 'copied' : 'declined';
|
||||
receipt.signature = diplomatDefenseSignature(receipt);
|
||||
log(g, receipt.card
|
||||
? `${p.name} used Diplomat: ${cardOf(p, receipt.card)!.name} copies the opposing ${receipt.kind === 'shield' ? 'Shield' : 'Snooper'} for this battle and must be discarded afterward. The original plans and physical card identities remain unchanged.`
|
||||
: `${p.name} declined to copy the opposing defense with Diplomat.`,
|
||||
{ faction: p.faction, name: 'Diplomat defense' });
|
||||
nextRevealedDecision(g);
|
||||
} else if (decision.kind === 'stoneBurner') {
|
||||
requireRule(
|
||||
Object.keys(action).every((key) =>
|
||||
@@ -22330,6 +22453,7 @@ function applyActionInner(
|
||||
g.battle = {
|
||||
...choice,
|
||||
event: battleEvent,
|
||||
...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 1 as const } : {}),
|
||||
...(byFaction(g, 'richese')
|
||||
? { preLeader: { event: battleEvent, ready: [], closed: false } }
|
||||
: {}),
|
||||
@@ -23609,6 +23733,12 @@ export function viewGame(state: Game, id: string) {
|
||||
}
|
||||
: null,
|
||||
lateDefense: b.revealed ? (b.lateDefense ?? {}) : {},
|
||||
diplomatDefense: b.revealed && b.diplomatDefense
|
||||
? { player: b.diplomatDefense.player, event: b.diplomatDefense.event,
|
||||
leader: b.diplomatDefense.leader, cards: [...b.diplomatDefense.cards],
|
||||
source: b.diplomatDefense.source, kind: b.diplomatDefense.kind,
|
||||
stage: b.diplomatDefense.stage, card: b.diplomatDefense.card }
|
||||
: null,
|
||||
fullPlan: b.fullPlan ?? null,
|
||||
fullPlanInsight:
|
||||
b.fullPlan?.owner === id &&
|
||||
|
||||
@@ -142,6 +142,7 @@ function qualifies(
|
||||
skill: DirectLeaderSkillId,
|
||||
weapon: Card | undefined,
|
||||
defense: Card | undefined,
|
||||
effectiveDefense: Card | undefined,
|
||||
) {
|
||||
if (skill === 'warmaster')
|
||||
return [weapon, defense].some((card) => card?.kind === 'worthless');
|
||||
@@ -150,8 +151,8 @@ function qualifies(
|
||||
if (skill === 'swordmaster-of-ginaz')
|
||||
return weaponTypes(weapon).includes('projectile');
|
||||
if (skill === 'killer-medic')
|
||||
return defenseTypes(defense).includes('snooper');
|
||||
return defenseTypes(defense).includes('shield');
|
||||
return defenseTypes(effectiveDefense ?? defense).includes('snooper');
|
||||
return defenseTypes(effectiveDefense ?? defense).includes('shield');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +165,8 @@ export function leaderSkillBattleBonus(input: {
|
||||
selectedLeader: { id: string; kind: 'disc' | 'hero' } | undefined;
|
||||
weapon: Card | undefined;
|
||||
defense: Card | undefined;
|
||||
/** A copied defense role leaves the played Worthless card available to Warmaster. */
|
||||
effectiveDefense?: Card;
|
||||
skilledLeaderSurvives: boolean;
|
||||
bankerSpice?: number;
|
||||
}): LeaderSkillBattleBonus {
|
||||
@@ -191,7 +194,7 @@ export function leaderSkillBattleBonus(input: {
|
||||
});
|
||||
for (const assignment of input.assignments) {
|
||||
if (!isDirectSkill(assignment.skill)) continue;
|
||||
if (!qualifies(assignment.skill, input.weapon, input.defense)) continue;
|
||||
if (!qualifies(assignment.skill, input.weapon, input.defense, input.effectiveDefense)) continue;
|
||||
if (
|
||||
input.selectedLeader.kind === 'disc' &&
|
||||
assignment.leader === input.selectedLeader.id &&
|
||||
|
||||
+8
-7
@@ -3563,7 +3563,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping, Sandmaster route collection and sealed Spice Banker battle spending in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping, Sandmaster route collection sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
|
||||
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
||||
@@ -3578,16 +3578,17 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
|
||||
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
|
||||
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income remains unfinished.',
|
||||
'Normal Mentat questioning, Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and all Diplomat effects remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. Lower retreat and modified defenses remain unfinished.',
|
||||
'Normal Mentat questioning, Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
related: ['setup','choam-modules','implementation-checklist'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. Spice Banker lower spending is connected; normal income, other remaining bands, Diplomat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments, Sandmaster route collection and funded Banker commitments. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx'] },
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; normal income, other remaining bands, Diplomat retreat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments, Sandmaster route collection funded Banker commitments and optional Diplomat copying from public revealed defenses. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -8,6 +8,7 @@ const DECISIONS = {
|
||||
leaderSkillRevival: true,
|
||||
sukRescue: true,
|
||||
rihani: true,
|
||||
diplomatDefense: true,
|
||||
choamAudit: true,
|
||||
choamAuditPayment: true,
|
||||
strongholdCopy: true,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { DiplomatDefense } from '../components/diplomat-defense';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
diplomatDefenseGame,
|
||||
revealDiplomatPlans,
|
||||
takeBattleCard,
|
||||
} from './diplomat-defense-fixture';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
});
|
||||
const { RevealedBattle } = await import('../components/revealed-battle');
|
||||
aliases.deregister();
|
||||
|
||||
function markup(game: Game, viewer = 'a', busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(DiplomatDefense, {
|
||||
game: viewGame(game, viewer),
|
||||
act() {},
|
||||
busy,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function revealAgainstProjectile(game: Game) {
|
||||
const own = game.players.find((player) => player.id === 'a')!;
|
||||
const other = game.players.find((player) => player.id === 'd')!;
|
||||
const weapon = takeBattleCard(game, 'd', 'projectile');
|
||||
let next = applyAction(game, 'a', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'emperor-1',
|
||||
defense: own.hand.find((card) => card.kind === 'worthless')!.id,
|
||||
});
|
||||
next = applyAction(next, 'd', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'guild-1',
|
||||
weapon: weapon.id,
|
||||
defense: other.hand.find((card) => card.kind === 'snooper')!.id,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
void test('the deciding owner sees the copied protection, named Worthless choice, mandatory discard and decline', () => {
|
||||
const game = diplomatDefenseGame();
|
||||
const uncommitted = takeBattleCard(game, 'd', 'worthless');
|
||||
const revealed = revealDiplomatPlans(game);
|
||||
const html = markup(revealed);
|
||||
assert.match(html, /aria-label="Diplomat defense choice"/);
|
||||
assert.match(html, /opposing committed defense, <strong>Snooper/);
|
||||
assert.match(html, /<strong>Poison defense<\/strong> protection/);
|
||||
assert.match(html, /Copy Snooper with Baliset · mandatory discard/);
|
||||
assert.match(html, /must be discarded after this battle/);
|
||||
assert.match(html, /Decline Diplomat defense/);
|
||||
assert.equal(html.includes(uncommitted.name), false);
|
||||
assert.equal(markup(revealed, 'd'), '');
|
||||
assert.ok(
|
||||
(markup(revealed, 'a', true).match(/disabled=""/g) ?? []).length >= 2,
|
||||
);
|
||||
assert.match(
|
||||
markup(revealDiplomatPlans(diplomatDefenseGame(false, true))),
|
||||
/Diplomat defense choice/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('all profiles copy useful revealed protection and decline a mismatched defense', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let useful = revealDiplomatPlans(diplomatDefenseGame());
|
||||
let view = viewGame(useful, 'a');
|
||||
view.players.find((player) => player.id === 'a')!.bot = difficulty;
|
||||
const copy = botActions(view)[0];
|
||||
assert.deepEqual(copy, {
|
||||
type: 'decision',
|
||||
event:
|
||||
useful.decision?.kind === 'diplomatDefense'
|
||||
? useful.decision.event
|
||||
: '',
|
||||
card:
|
||||
useful.decision?.kind === 'diplomatDefense'
|
||||
? useful.decision.cards[0]
|
||||
: null,
|
||||
});
|
||||
useful = applyAction(useful, 'a', copy);
|
||||
assert.equal(useful.battle!.diplomatDefense!.stage, 'copied');
|
||||
|
||||
let mismatch = revealAgainstProjectile(diplomatDefenseGame());
|
||||
view = viewGame(mismatch, 'a');
|
||||
view.players.find((player) => player.id === 'a')!.bot = difficulty;
|
||||
const decline = botActions(view)[0];
|
||||
assert.deepEqual(decline, {
|
||||
type: 'decision',
|
||||
event:
|
||||
mismatch.decision?.kind === 'diplomatDefense'
|
||||
? mismatch.decision.event
|
||||
: '',
|
||||
card: null,
|
||||
});
|
||||
mismatch = applyAction(mismatch, 'a', decline);
|
||||
assert.equal(mismatch.battle!.diplomatDefense!.stage, 'declined');
|
||||
}
|
||||
});
|
||||
|
||||
void test('the revealed plan preserves the public copied or declined Diplomat status across views', () => {
|
||||
const unsealed = diplomatDefenseGame();
|
||||
assert.equal(
|
||||
renderToStaticMarkup(
|
||||
createElement(RevealedBattle, { game: viewGame(unsealed, 'a') }),
|
||||
),
|
||||
'',
|
||||
);
|
||||
|
||||
let copied = revealDiplomatPlans(unsealed);
|
||||
assert.equal(copied.decision?.kind, 'diplomatDefense');
|
||||
copied = applyAction(copied, 'a', {
|
||||
type: 'decision',
|
||||
event: copied.decision.event,
|
||||
card: copied.decision.cards[0],
|
||||
});
|
||||
for (const viewer of ['a', 'd']) {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(RevealedBattle, { game: viewGame(copied, viewer) }),
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/Diplomat: Baliset copies Snooper · discard after battle/,
|
||||
);
|
||||
}
|
||||
|
||||
let declined = revealDiplomatPlans(diplomatDefenseGame());
|
||||
assert.equal(declined.decision?.kind, 'diplomatDefense');
|
||||
declined = applyAction(declined, 'a', {
|
||||
type: 'decision',
|
||||
event: declined.decision.event,
|
||||
card: null,
|
||||
});
|
||||
const declinedHtml = renderToStaticMarkup(
|
||||
createElement(RevealedBattle, { game: viewGame(declined, 'd') }),
|
||||
);
|
||||
assert.match(declinedHtml, /Diplomat: defense copy declined/);
|
||||
assert.doesNotMatch(declinedHtml, /discard after battle/);
|
||||
});
|
||||
|
||||
void test('all profiles retain legal Worthless plans for the native trainer and the selected hidden trained disc', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
for (const hidden of [false, true]) {
|
||||
const game = diplomatDefenseGame(hidden);
|
||||
const view = viewGame(game, 'a');
|
||||
view.players.find((player) => player.id === 'a')!.bot = difficulty;
|
||||
const worthless = new Set(
|
||||
view.players
|
||||
.find((player) => player.id === 'a')!
|
||||
.hand?.filter((card) => card.kind === 'worthless')
|
||||
.map((card) => card.id),
|
||||
);
|
||||
const leader = hidden ? 'emperor-0' : 'emperor-1';
|
||||
const candidates = botActions(view).filter(
|
||||
(action) =>
|
||||
action.type === 'battlePlan' &&
|
||||
action.leader === leader &&
|
||||
(worthless.has(String(action.weapon)) ||
|
||||
worthless.has(String(action.defense))),
|
||||
);
|
||||
assert.ok(
|
||||
candidates.length,
|
||||
`${difficulty} omitted ${hidden ? 'trained-disc' : 'native'} Diplomat planning`,
|
||||
);
|
||||
assert.doesNotThrow(() => applyAction(game, 'a', candidates[0]));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Game } from '../game/engine';
|
||||
import type { Card } from '../game/cards';
|
||||
import { leaderSkillBattle } from './leader-skill-battle-fixture';
|
||||
|
||||
/** Genuine skill assignment and conserved battle staging; returns before sealing plans. */
|
||||
export function diplomatDefenseGame(hide = false, advanced = false): Game {
|
||||
const game = leaderSkillBattle({
|
||||
skill: 'diplomat',
|
||||
unsealed: true,
|
||||
hide,
|
||||
advanced,
|
||||
});
|
||||
takeBattleCard(game, 'd', 'poison');
|
||||
takeBattleCard(game, 'd', 'snooper');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function takeBattleCard(
|
||||
game: Game,
|
||||
player: string,
|
||||
kind: Card['kind'],
|
||||
): Card {
|
||||
const index = game.deck.findIndex((card) => card.kind === kind);
|
||||
assert.ok(index >= 0, `Missing fixture ${kind}`);
|
||||
const [card] = game.deck.splice(index, 1);
|
||||
game.players.find((p) => p.id === player)!.hand.push(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
export function revealDiplomatPlans(
|
||||
game: Game,
|
||||
slot: 'weapon' | 'defense' = 'defense',
|
||||
): Game {
|
||||
const own = game.players[0],
|
||||
other = game.players[1];
|
||||
let next = applyAction(game, 'a', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: game.battle!.leaderSkillHidden?.a ? 'emperor-0' : 'emperor-1',
|
||||
[slot]: own.hand.find((card) => card.kind === 'worthless')!.id,
|
||||
});
|
||||
next = applyAction(next, 'd', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'guild-1',
|
||||
weapon: other.hand.find((card) => card.kind === 'poison')!.id,
|
||||
defense: other.hand.find((card) => card.kind === 'snooper')!.id,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import * as engine from '../game/engine';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
diplomatDefenseGame,
|
||||
revealDiplomatPlans,
|
||||
} from './diplomat-defense-fixture';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function fixture(hide = true) {
|
||||
const store = unitStore();
|
||||
const sqlite: DatabaseSync = store.sqlite;
|
||||
const created = await store.rooms.createRoom(
|
||||
'Diplomat owner',
|
||||
'emperor',
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const joined = await store.rooms.joinRoom(
|
||||
created.view.code,
|
||||
'Diplomat opponent',
|
||||
'guild',
|
||||
);
|
||||
const code = created.view.code;
|
||||
const tokens = [created.token!, joined.token!];
|
||||
const original = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
for (const [index, seat] of original.entries())
|
||||
sqlite
|
||||
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
|
||||
.run(index ? 'd' : 'a', code, seat.playerId);
|
||||
const seats = await Promise.all(
|
||||
tokens.map((token) => store.restart().authenticate(code, token)),
|
||||
);
|
||||
const state = diplomatDefenseGame(hide);
|
||||
state.code = code;
|
||||
state.version = (await store.rooms.readRoom(code)).version;
|
||||
const save = (game: engine.Game) =>
|
||||
sqlite
|
||||
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(game), game.version, code);
|
||||
save(state);
|
||||
store.writes.length = 0;
|
||||
const worthless = state.players[0].hand.find(
|
||||
(card) => card.kind === 'worthless',
|
||||
)!;
|
||||
const poison = state.players[1].hand.find((card) => card.kind === 'poison')!;
|
||||
const snooper = state.players[1].hand.find(
|
||||
(card) => card.kind === 'snooper',
|
||||
)!;
|
||||
assert.ok(worthless && poison && snooper);
|
||||
return {
|
||||
...store,
|
||||
sqlite,
|
||||
code,
|
||||
seats,
|
||||
state,
|
||||
save,
|
||||
worthless,
|
||||
poison,
|
||||
snooper,
|
||||
};
|
||||
}
|
||||
|
||||
function ownerPlan(f: Awaited<ReturnType<typeof fixture>>): engine.Action {
|
||||
return {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: f.state.battle!.leaderSkillHidden?.a ? 'emperor-0' : 'emperor-1',
|
||||
defense: f.worthless.id,
|
||||
};
|
||||
}
|
||||
|
||||
function opponentPlan(f: Awaited<ReturnType<typeof fixture>>): engine.Action {
|
||||
return {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'guild-1',
|
||||
weapon: f.poison.id,
|
||||
defense: f.snooper.id,
|
||||
};
|
||||
}
|
||||
|
||||
function barrier() {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let arrivals = 0;
|
||||
return async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await ready;
|
||||
};
|
||||
}
|
||||
|
||||
function committedOnce(
|
||||
results: PromiseSettledResult<unknown>[],
|
||||
writes: { changes: number }[],
|
||||
) {
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === 'rejected').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
writes.map((write) => write.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
}
|
||||
|
||||
void test('restart conceals the sealed Worthless, publishes the revealed Diplomat offer, and concurrent copies save the selected trainer once', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, ownerPlan(f), clock);
|
||||
const sealed = await f.restart().readRoom(f.code);
|
||||
const owner = await f.restart().readSeatView(f.code, f.seats[0]);
|
||||
const observer = await f.restart().readSeatView(f.code, f.seats[1]);
|
||||
assert.equal(owner.battle!.plans.a.defense, f.worthless.id);
|
||||
assert.equal(observer.battle!.plans.a, undefined);
|
||||
assert.equal(
|
||||
JSON.stringify(observer.battle).includes(f.worthless.id),
|
||||
false,
|
||||
);
|
||||
|
||||
await f
|
||||
.restart()
|
||||
.act(f.code, f.seats[1], sealed.version, opponentPlan(f), clock);
|
||||
const offered = await f.restart().readRoom(f.code);
|
||||
assert.equal(offered.decision?.kind, 'diplomatDefense');
|
||||
assert.equal(offered.battle!.diplomatDefense?.stage, 'offered');
|
||||
assert.deepEqual(offered.battle!.diplomatDefense?.cards, [f.worthless.id]);
|
||||
for (const seat of f.seats) {
|
||||
const view = await f.restart().readSeatView(f.code, seat);
|
||||
assert.deepEqual(view.decision, offered.decision);
|
||||
assert.equal(view.battle!.diplomatDefense?.stage, 'offered');
|
||||
const publicReceipt = JSON.stringify(view.battle!.diplomatDefense);
|
||||
assert.equal(publicReceipt.includes('signature'), false);
|
||||
assert.equal(publicReceipt.includes('frame'), false);
|
||||
}
|
||||
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const choice: engine.Action = {
|
||||
type: 'decision',
|
||||
event: offered.decision!.event,
|
||||
card: f.worthless.id,
|
||||
};
|
||||
const choices = await Promise.allSettled([
|
||||
f.rooms.act(f.code, f.seats[0], offered.version, choice, clock),
|
||||
f.restart().act(f.code, f.seats[0], offered.version, choice, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
committedOnce(choices, f.writes);
|
||||
let selected = await f.restart().readRoom(f.code);
|
||||
assert.equal(selected.battle!.diplomatDefense?.stage, 'copied');
|
||||
assert.equal(selected.battle!.diplomatDefense?.card, f.worthless.id);
|
||||
assert.equal(
|
||||
selected.players[0].hand.filter((card) => card.id === f.worthless.id)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
selected.discard.some((card) => card.id === f.worthless.id),
|
||||
false,
|
||||
);
|
||||
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
selected.version,
|
||||
{ type: 'traitorCall', call: false },
|
||||
clock,
|
||||
);
|
||||
selected = await f.restart().readRoom(f.code);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
selected.version,
|
||||
{ type: 'traitorCall', call: false },
|
||||
clock,
|
||||
);
|
||||
const resolved = await f.restart().readRoom(f.code);
|
||||
assert.equal(
|
||||
resolved.players[0].leaders.find((leader) => leader.id === 'emperor-0')!
|
||||
.dead,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolved.discard.filter((card) => card.id === f.worthless.id).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
resolved.players.some((player) =>
|
||||
player.hand.some((card) => card.id === f.worthless.id),
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent restored copy and decline commit one public choice without moving the Worthless early', async () => {
|
||||
const f = await fixture(false);
|
||||
try {
|
||||
const offered = revealDiplomatPlans(f.state, 'weapon');
|
||||
f.save(offered);
|
||||
assert.equal(offered.decision?.kind, 'diplomatDefense');
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const event = offered.decision.event;
|
||||
const results = await Promise.allSettled([
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
offered.version,
|
||||
{ type: 'decision', event, card: f.worthless.id },
|
||||
clock,
|
||||
),
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
offered.version,
|
||||
{ type: 'decision', event, card: null },
|
||||
clock,
|
||||
),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
committedOnce(results, f.writes);
|
||||
const current = await f.restart().readRoom(f.code);
|
||||
const copied = results[0].status === 'fulfilled';
|
||||
assert.equal(
|
||||
current.battle!.diplomatDefense?.stage,
|
||||
copied ? 'copied' : 'declined',
|
||||
);
|
||||
assert.equal(
|
||||
current.battle!.diplomatDefense?.card,
|
||||
copied ? f.worthless.id : null,
|
||||
);
|
||||
assert.equal(
|
||||
current.players[0].hand.filter((card) => card.id === f.worthless.id)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
current.discard.some((card) => card.id === f.worthless.id),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a restored activated copy still discards its exact Worthless after a successful Traitor call', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
const owner = f.state.players[0];
|
||||
const old = owner.traitors[0];
|
||||
const identity = 'guild-1';
|
||||
if (old !== identity) {
|
||||
const holder = f.state.players.find((player) =>
|
||||
player.traitors.includes(identity),
|
||||
);
|
||||
if (holder) holder.traitors[holder.traitors.indexOf(identity)] = old;
|
||||
else {
|
||||
const reserve = f.state.traitorReserve!.indexOf(identity);
|
||||
assert.ok(reserve >= 0);
|
||||
f.state.traitorReserve![reserve] = old;
|
||||
}
|
||||
owner.traitors[0] = identity;
|
||||
}
|
||||
const offered = revealDiplomatPlans(f.state);
|
||||
assert.equal(offered.decision?.kind, 'diplomatDefense');
|
||||
const copied = engine.applyAction(offered, 'a', {
|
||||
type: 'decision',
|
||||
event: offered.decision.event,
|
||||
card: f.worthless.id,
|
||||
});
|
||||
f.save(copied);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
copied.version,
|
||||
{ type: 'traitorCall', call: true },
|
||||
clock,
|
||||
);
|
||||
const called = await f.restart().readRoom(f.code);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
called.version,
|
||||
{ type: 'traitorCall', call: false },
|
||||
clock,
|
||||
);
|
||||
const resolved = await f.restart().readRoom(f.code);
|
||||
assert.equal(resolved.lastBattleContext?.result, 'traitor');
|
||||
assert.equal(resolved.lastBattleContext?.winner, 'a');
|
||||
assert.equal(
|
||||
resolved.discard.filter((card) => card.id === f.worthless.id).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
resolved.players.some((player) =>
|
||||
player.hand.some((card) => card.id === f.worthless.id),
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('corrupted restored receipt, decision, or independent battle marker rejects views and actions without mutation', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const original = revealDiplomatPlans(f.state);
|
||||
assert.equal(original.decision?.kind, 'diplomatDefense');
|
||||
const action: engine.Action = {
|
||||
type: 'decision',
|
||||
event: original.decision.event,
|
||||
card: f.worthless.id,
|
||||
};
|
||||
const corruptions: ((game: engine.Game) => void)[] = [
|
||||
(game) => {
|
||||
game.battle!.diplomatDefense!.frame += '-changed';
|
||||
},
|
||||
(game) => {
|
||||
if (game.decision?.kind === 'diplomatDefense')
|
||||
game.decision.source = 'wrong-defense';
|
||||
},
|
||||
(game) => {
|
||||
delete game.battle!.diplomatDefenseVersion;
|
||||
},
|
||||
(game) => {
|
||||
delete game.battle!.diplomatDefenseEvent;
|
||||
},
|
||||
];
|
||||
for (const corrupt of corruptions) {
|
||||
const bad = structuredClone(original);
|
||||
corrupt(bad);
|
||||
f.save(bad);
|
||||
f.writes.length = 0;
|
||||
for (const seat of f.seats)
|
||||
await assert.rejects(f.restart().readSeatView(f.code, seat));
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.seats[0], bad.version, action, clock),
|
||||
);
|
||||
const snapshot = structuredClone(bad);
|
||||
assert.throws(() => engine.normalizeAutomaticGame(bad));
|
||||
assert.deepEqual(bad, snapshot);
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.deepEqual(await f.rooms.readRoom(f.code), snapshot);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a restored legacy revealed battle without a Diplomat marker keeps its existing traitor continuation', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const legacy = revealDiplomatPlans(f.state);
|
||||
delete legacy.battle!.diplomatDefenseVersion;
|
||||
delete legacy.battle!.diplomatDefenseEvent;
|
||||
delete legacy.battle!.diplomatDefense;
|
||||
legacy.decision = null;
|
||||
f.save(legacy);
|
||||
const view = await f.restart().readSeatView(f.code, f.seats[0]);
|
||||
assert.equal(view.decision, null);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
legacy.version,
|
||||
{ type: 'traitorCall', call: false },
|
||||
clock,
|
||||
);
|
||||
const continued = await f.restart().readRoom(f.code);
|
||||
assert.equal(continued.battle!.traitorCalls.a, false);
|
||||
assert.equal(continued.battle!.diplomatDefense, undefined);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import {
|
||||
quoteDiplomatDefense,
|
||||
copiedDiplomatDefense,
|
||||
} from '../game/diplomat-defense';
|
||||
import { leaderSkillBattleBonus } from '../game/leader-skill-combat';
|
||||
import {
|
||||
diplomatDefenseGame,
|
||||
revealDiplomatPlans,
|
||||
takeBattleCard,
|
||||
} from './diplomat-defense-fixture';
|
||||
|
||||
function choose(game: Game, card: string | null): Game {
|
||||
assert.equal(game.decision?.kind, 'diplomatDefense');
|
||||
return applyAction(JSON.parse(JSON.stringify(game)), 'a', {
|
||||
type: 'decision',
|
||||
event: game.decision.event,
|
||||
card,
|
||||
});
|
||||
}
|
||||
function finish(game: Game, call = false): Game {
|
||||
let next = applyAction(game, 'a', { type: 'traitorCall', call });
|
||||
next = applyAction(next, 'd', { type: 'traitorCall', call: false });
|
||||
for (const p of next.players)
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
);
|
||||
const ids = [
|
||||
...next.deck,
|
||||
...next.discard,
|
||||
...next.players.flatMap((p) => p.hand),
|
||||
].map((c) => c.id);
|
||||
assert.equal(new Set(ids).size, 33);
|
||||
assert.equal(ids.length, 33);
|
||||
return next;
|
||||
}
|
||||
function reject(game: Game, owner: string, action: Action) {
|
||||
const before = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, owner, action));
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
}
|
||||
|
||||
void test('Diplomat quotes one physical Worthless from either slot and never copies absent or unsupported defenses', () => {
|
||||
const cards = baseDeck(),
|
||||
worthless = cards.filter((c) => c.kind === 'worthless'),
|
||||
snooper = cards.find((c) => c.kind === 'snooper')!;
|
||||
const assignments = [
|
||||
{
|
||||
skill: 'diplomat' as const,
|
||||
leader: 'trainer',
|
||||
faceUp: true,
|
||||
captured: false,
|
||||
},
|
||||
];
|
||||
const quote = quoteDiplomatDefense({
|
||||
assignments,
|
||||
selectedLeader: 'other',
|
||||
weapon: worthless[0],
|
||||
defense: worthless[1],
|
||||
opposingDefense: snooper,
|
||||
})!;
|
||||
assert.deepEqual(
|
||||
quote.cards,
|
||||
worthless.slice(0, 2).map((c) => c.id),
|
||||
);
|
||||
assert.equal(copiedDiplomatDefense(quote, worthless[0].id).kind, 'snooper');
|
||||
assert.equal(worthless[0].kind, 'worthless');
|
||||
assert.throws(() => copiedDiplomatDefense(quote, 'unplayed'));
|
||||
for (const opposingDefense of [
|
||||
undefined,
|
||||
worthless[0],
|
||||
{ ...snooper, id: 'unrecognized' },
|
||||
{ ...snooper, kind: 'shieldSnooper' as const },
|
||||
])
|
||||
assert.equal(
|
||||
quoteDiplomatDefense({
|
||||
assignments,
|
||||
selectedLeader: 'other',
|
||||
weapon: worthless[0],
|
||||
opposingDefense,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
quoteDiplomatDefense({
|
||||
assignments,
|
||||
selectedLeader: 'other',
|
||||
weapon: worthless[0],
|
||||
defense: snooper,
|
||||
opposingDefense: snooper,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
quoteDiplomatDefense({
|
||||
assignments: [{ ...assignments[0], faceUp: false }],
|
||||
selectedLeader: 'other',
|
||||
weapon: worthless[0],
|
||||
opposingDefense: snooper,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
quoteDiplomatDefense({
|
||||
assignments: [{ ...assignments[0], faceUp: false }],
|
||||
selectedLeader: 'trainer',
|
||||
weapon: worthless[0],
|
||||
opposingDefense: snooper,
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
quoteDiplomatDefense({
|
||||
assignments: [{ ...assignments[0], captured: true }],
|
||||
selectedLeader: 'trainer',
|
||||
weapon: worthless[0],
|
||||
opposingDefense: snooper,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
void test('native face-up and selected Diplomats copy a Snooper after reveal in Basic and Advanced without rewriting sealed plans', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const hide of [false, true])
|
||||
for (const slot of ['weapon', 'defense'] as const) {
|
||||
const game = diplomatDefenseGame(hide, advanced),
|
||||
card = game.players[0].hand[0].id;
|
||||
const pending = revealDiplomatPlans(game, slot),
|
||||
plans = structuredClone(pending.battle!.plans);
|
||||
assert.equal(pending.decision?.kind, 'diplomatDefense');
|
||||
reject(pending, 'd', {
|
||||
type: 'decision',
|
||||
event: pending.decision.event,
|
||||
card,
|
||||
});
|
||||
reject(pending, 'a', { type: 'traitorCall', call: false });
|
||||
reject(pending, 'a', { type: 'decision', event: 'stale', card });
|
||||
const copied = choose(pending, card);
|
||||
assert.deepEqual(copied.battle!.plans, plans);
|
||||
assert.equal(
|
||||
copied.players[0].hand.find((c) => c.id === card)!.kind,
|
||||
'worthless',
|
||||
);
|
||||
const done = finish(copied),
|
||||
leader = done.players[0].leaders.find(
|
||||
(l) => l.id === (hide ? 'emperor-0' : 'emperor-1'),
|
||||
)!;
|
||||
assert.equal(leader.dead, false);
|
||||
assert.ok(!done.players[0].hand.some((c) => c.id === card));
|
||||
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
|
||||
reject(done, 'a', {
|
||||
type: 'decision',
|
||||
event: pending.decision.event,
|
||||
card,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
void test('declining the visible copy leaves ordinary weapon deaths and card custody unchanged', () => {
|
||||
const game = diplomatDefenseGame(),
|
||||
card = game.players[0].hand[0].id;
|
||||
const pending = revealDiplomatPlans(game);
|
||||
const done = finish(choose(pending, null));
|
||||
assert.equal(
|
||||
done.players[0].leaders.find((l) => l.id === 'emperor-1')!.dead,
|
||||
true,
|
||||
);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'] ?? 0, 0);
|
||||
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
|
||||
});
|
||||
|
||||
void test('two committed Worthless cards offer one choice and only the chosen winner card is mandatory discard', () => {
|
||||
let game = diplomatDefenseGame();
|
||||
const first = game.players[0].hand[0],
|
||||
second = takeBattleCard(game, 'a', 'worthless');
|
||||
game = applyAction(game, 'a', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'emperor-1',
|
||||
weapon: first.id,
|
||||
defense: second.id,
|
||||
});
|
||||
game = applyAction(game, 'd', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'guild-1',
|
||||
weapon: game.players[1].hand.find((c) => c.kind === 'poison')!.id,
|
||||
defense: game.players[1].hand.find((c) => c.kind === 'snooper')!.id,
|
||||
});
|
||||
assert.equal(game.decision?.kind, 'diplomatDefense');
|
||||
assert.deepEqual(
|
||||
new Set(game.decision.cards),
|
||||
new Set([first.id, second.id]),
|
||||
);
|
||||
const done = finish(choose(game, second.id));
|
||||
assert.equal(done.discard.filter((c) => c.id === second.id).length, 1);
|
||||
assert.ok(done.players[0].hand.some((c) => c.id === first.id));
|
||||
assert.equal(done.decision?.kind, 'battleCards');
|
||||
assert.ok(done.decision.cards.includes(first.id));
|
||||
assert.ok(!done.decision.cards.includes(second.id));
|
||||
});
|
||||
|
||||
void test('an actual defense or absent opposing defense creates no Diplomat choice', () => {
|
||||
for (const ownDefense of [false, true]) {
|
||||
let game = diplomatDefenseGame();
|
||||
const defense = ownDefense
|
||||
? takeBattleCard(game, 'a', 'snooper').id
|
||||
: undefined;
|
||||
game = applyAction(game, 'a', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'emperor-1',
|
||||
weapon: game.players[0].hand[0].id,
|
||||
defense,
|
||||
});
|
||||
game = applyAction(game, 'd', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'guild-1',
|
||||
defense: ownDefense
|
||||
? game.players[1].hand.find((c) => c.kind === 'snooper')!.id
|
||||
: undefined,
|
||||
});
|
||||
assert.notEqual(game.decision?.kind, 'diplomatDefense');
|
||||
finish(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('copying a Shield preserves the existing Lasgun explosion instead of making a survival loop', () => {
|
||||
let game = diplomatDefenseGame(true);
|
||||
const card = game.players[0].hand[0].id,
|
||||
laser = takeBattleCard(game, 'd', 'lasgun'),
|
||||
shield = takeBattleCard(game, 'd', 'shield');
|
||||
// The staged opponent only needs its played pair; return other physical cards to the deck.
|
||||
game.deck.push(
|
||||
...game.players[1].hand.filter(
|
||||
(c) => ![laser.id, shield.id].includes(c.id),
|
||||
),
|
||||
);
|
||||
game.players[1].hand = game.players[1].hand.filter((c) =>
|
||||
[laser.id, shield.id].includes(c.id),
|
||||
);
|
||||
game = applyAction(game, 'a', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'emperor-0',
|
||||
defense: card,
|
||||
});
|
||||
game = applyAction(game, 'd', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: 'guild-1',
|
||||
weapon: laser.id,
|
||||
defense: shield.id,
|
||||
});
|
||||
const done = finish(choose(game, card));
|
||||
for (const p of done.players) assert.equal(p.tanks, 5);
|
||||
assert.equal(
|
||||
done.players[0].leaders.find((l) => l.id === 'emperor-0')!.dead,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
done.players[1].leaders.find((l) => l.id === 'guild-1')!.dead,
|
||||
true,
|
||||
);
|
||||
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
|
||||
});
|
||||
|
||||
void test('copied defense role supports captured Medic or Prana while the physical Worthless still supports Warmaster', () => {
|
||||
const card = baseDeck().find((c) => c.kind === 'worthless')!;
|
||||
for (const [skill, kind] of [
|
||||
['killer-medic', 'snooper'],
|
||||
['prana-bindu-adept', 'shield'],
|
||||
['warmaster', 'snooper'],
|
||||
] as const) {
|
||||
const bonus = leaderSkillBattleBonus({
|
||||
assignments: [
|
||||
{ skill, leader: 'captive', faceUp: false, captured: true },
|
||||
],
|
||||
selectedLeader: { id: 'captive', kind: 'disc' },
|
||||
weapon: undefined,
|
||||
defense: card,
|
||||
effectiveDefense: { id: card.id, name: 'Copied defense', kind },
|
||||
skilledLeaderSurvives: true,
|
||||
});
|
||||
assert.equal(bonus.bonus, 3, skill);
|
||||
}
|
||||
});
|
||||
|
||||
void test('old unmarked revealed battles retain their original continuation without inventing a copy', () => {
|
||||
const unsealed = diplomatDefenseGame();
|
||||
delete unsealed.battle!.diplomatDefenseVersion;
|
||||
const game = revealDiplomatPlans(unsealed);
|
||||
assert.notEqual(game.decision?.kind, 'diplomatDefense');
|
||||
assert.equal(
|
||||
viewGame(JSON.parse(JSON.stringify(game)), 'a').battle!.revealed,
|
||||
true,
|
||||
);
|
||||
const done = finish(game);
|
||||
assert.equal(
|
||||
done.players[0].leaders.find((l) => l.id === 'emperor-1')!.dead,
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -222,7 +222,7 @@ void test('battle visibility choice names the skilled leader and disables both a
|
||||
assert.equal(disabledControls(markup(skills, true)), 2);
|
||||
});
|
||||
|
||||
void test('the panel states the exact five connected role modifiers and unfinished boundary', () => {
|
||||
void test('the panel states the connected role modifiers, Diplomat copy and unfinished boundary', () => {
|
||||
const html = markup(view());
|
||||
for (const name of [
|
||||
'Warmaster',
|
||||
@@ -233,5 +233,9 @@ void test('the panel states the exact five connected role modifiers and unfinish
|
||||
])
|
||||
assert.match(html, new RegExp(name));
|
||||
assert.match(html, /development-only module/);
|
||||
assert.match(
|
||||
html,
|
||||
/Diplomat copying an opposing base defense with a committed Worthless card after plans are revealed/,
|
||||
);
|
||||
assert.match(html, /Other Leader Skill effects are unfinished/);
|
||||
});
|
||||
Reference in new issue
Block a user