Connect bounded Diplomat retreat and record live deployment
This commit is contained in:
1 parent
4801ce003b
commit
c9a3625ff2
18 files changed
+601
-28
No files matched your search
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import { splitLocation, territory } from '@/game/board';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function DiplomatRetreat({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [destination, setDestination] = useState<string | null>(null);
|
||||
const [choiceIndex, setChoiceIndex] = useState(0);
|
||||
const decision = game.decision?.kind === 'diplomatRetreat' ? game.decision : null;
|
||||
if (!decision || decision.player !== game.me) return null;
|
||||
|
||||
const selectedDestination = decision.destinations.find((offer) => offer.location === destination)
|
||||
?? decision.destinations[0];
|
||||
const choice = selectedDestination?.choices[choiceIndex] ?? selectedDestination?.choices[0];
|
||||
const selectedLocation = selectedDestination ? splitLocation(selectedDestination.location) : null;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3" aria-label="Diplomat retreat choice">
|
||||
<p>
|
||||
Your skilled Diplomat lost this battle. Move up to their strength in
|
||||
undialed physical forces to one empty adjacent non-stronghold territory,
|
||||
or leave the forces where they are.
|
||||
</p>
|
||||
{selectedDestination && choice && selectedLocation ? (
|
||||
<>
|
||||
<label htmlFor={`${id}-destination`}>Retreat destination</label>
|
||||
<select
|
||||
id={`${id}-destination`}
|
||||
value={selectedDestination.location}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setDestination(event.target.value);
|
||||
setChoiceIndex(0);
|
||||
}}
|
||||
>
|
||||
{decision.destinations.map((offer) => {
|
||||
const location = splitLocation(offer.location);
|
||||
return (
|
||||
<option key={offer.location} value={offer.location}>
|
||||
{territory(location.territory).name} · sector {location.sector}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<label htmlFor={`${id}-forces`}>Physical forces to retreat</label>
|
||||
<select
|
||||
id={`${id}-forces`}
|
||||
value={selectedDestination.choices.indexOf(choice)}
|
||||
disabled={busy}
|
||||
onChange={(event) => setChoiceIndex(Number(event.target.value))}
|
||||
>
|
||||
{selectedDestination.choices.map((amount, index) => (
|
||||
<option key={`${amount.normal}:${amount.elite}`} value={index}>
|
||||
{amount.normal} ordinary · {amount.elite} elite
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={busy}
|
||||
onClick={() => !busy && act({
|
||||
type: 'decision', event: decision.event,
|
||||
destination: selectedDestination.location, normal: choice.normal, elite: choice.elite,
|
||||
})}
|
||||
>
|
||||
Retreat to {territory(selectedLocation.territory).name} · sector {selectedLocation.sector}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">No legal retreat destination and force combination is available.</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={busy}
|
||||
onClick={() => !busy && act({
|
||||
type: 'decision', event: decision.event,
|
||||
destination: null, normal: 0, elite: 0,
|
||||
})}
|
||||
>
|
||||
Decline Diplomat retreat
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -180,6 +180,7 @@ import { canUsePlanetologistBattleSpecial } from '../game/leader-skill-combat';
|
||||
import type { Card } from '../game/cards';
|
||||
import { RevealedBattle } from './revealed-battle';
|
||||
import { DiplomatDefense } from './diplomat-defense';
|
||||
import { DiplomatRetreat } from './diplomat-retreat';
|
||||
import { EliteCount } from './elite-count';
|
||||
import { HelpTip } from './help-tip';
|
||||
import { PHASE_HELP, phaseRuleId } from '@/game/reference';
|
||||
@@ -2272,6 +2273,8 @@ export function GameTable({
|
||||
? 'Save your battle casualties'
|
||||
: g.decision.kind === 'harassWithdraw'
|
||||
? 'Choose undialed forces to return'
|
||||
: g.decision.kind === 'diplomatRetreat'
|
||||
? 'Retreat undialed forces'
|
||||
: g.decision.kind === 'diplomatDefense'
|
||||
? 'Copy an opposing defense'
|
||||
: g.decision.kind === 'homeworldRevivalDeployment'
|
||||
@@ -2702,6 +2705,8 @@ export function GameTable({
|
||||
<>
|
||||
<HarassWithdrawChoice key={g.decision.event} game={g} act={act} busy={busy} />
|
||||
</>
|
||||
) : g.decision.kind === 'diplomatRetreat' ? (
|
||||
<DiplomatRetreat key={g.decision.event} game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'diplomatDefense' ? (
|
||||
<>
|
||||
<DiplomatDefense game={g} act={act} busy={busy} />
|
||||
|
||||
+16
-5
@@ -1,11 +1,22 @@
|
||||
# Current development status
|
||||
|
||||
Updated 26 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
Updated 27 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
is playable and the six classic factions have a user-authorized Advanced preview.
|
||||
Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
The live NAS Apps `dune` image is now the immutable
|
||||
`ghcr.io/quintine/dune:sha-4801ce003bd28d5f0aaf26c3bcded3dcfea4df07`
|
||||
from successful container workflow `36355611201`. The public homepage displays
|
||||
that full revision. After the update, owner sign-in succeeded and the nine
|
||||
existing rooms remained in the directory, including a playing room at version
|
||||
54. A 0600 online SQLite backup in the persistent app volume passed
|
||||
`PRAGMA quick_check` before the update; the temporary remote plaintext owner
|
||||
key and provisioning SQL were removed afterward. Private saved-seat
|
||||
reconnection and complete historical-data comparison were not exercised.
|
||||
The pending Diplomat retreat work is not in this deployed image.
|
||||
|
||||
The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
|
||||
prototype connects an initially empty worm's public offer, one spent private
|
||||
card, a Karama response, a typed remote ride after the Nexus, human controls
|
||||
@@ -40,9 +51,9 @@ bulk actions and backup/operations tools remain unfinished.
|
||||
|
||||
[Participant AI](ADMIN_PARTICIPANT_AI.md) checkpoint `2cddf03` is pushed. Types/lint,
|
||||
5,408 offline tests, 52 HTTP tests, build, twenty-two administrator HTTP groups
|
||||
and desktop/mobile browser acceptance passed. Deployment request delivery remains
|
||||
unconfirmed; the latest confirmed production revision is `e918e2e`. No strategic
|
||||
AI refinement was added.
|
||||
and desktop/mobile browser acceptance passed. Deployment delivery was
|
||||
unconfirmed at that checkpoint; the current production revision is recorded
|
||||
above. No strategic AI refinement was added.
|
||||
|
||||
Readable-notice checkpoint `137f922` is pushed. Types/lint, 5,381 offline tests,
|
||||
52 HTTP tests, build and delayed-response desktop/mobile browser QA passed.
|
||||
@@ -230,7 +241,7 @@ These links define bounded working behavior, not complete module certification.
|
||||
| --- | --- |
|
||||
| Base/Advanced setup | [Normal Advanced preview](ADVANCED_PREVIEW.md), [genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. |
|
||||
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement and [Advanced Loyalty](ECAZ_LOYALTY.md), separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
|
||||
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
|
||||
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md) and [bounded retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation). Remaining bands and combinations are explicit in those contracts. |
|
||||
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
|
||||
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
|
||||
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
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
|
||||
The lower [retreat prototype](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation)
|
||||
is now connected separately; combined modules and other defense cards remain
|
||||
unfinished. Public Leader Skills starts and publication remain gated.
|
||||
|
||||
## Source and interpretation
|
||||
@@ -86,10 +87,11 @@ 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.
|
||||
The bounded [lower retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation)
|
||||
now follows a documented conservative strength and outcome order; this does not
|
||||
settle a publisher priority ruling. Shield Snooper, Chemistry, defensive
|
||||
Weirding Way, Portable Snooper, Carthag-added protection and any future copied
|
||||
or modified defense still 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,11 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 September 2026 — Bounded skilled Diplomat retreat
|
||||
|
||||
The standalone [Leader Skills retreat interpretation](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation) now offers a surviving losing trained leader an event-bound, saved decision before ordinary loser casualties. The server quotes destination-specific undialed normal/elite counts using dial commitments, occupied strongholds, sector adjacency and the storm; accepted choices transfer those exact physical counters, while a decline or dead leader follows ordinary losses. Human controls and all four AI profiles use the same projected choices. Nine focused engine, controls and concurrent in-memory SQLite cases passed, including JSON restoration, split-sector custody, legacy-battle continuation and rejected-action immutability. An isolated browser view from a genuine revealed battle rendered the destination/count selectors and decline; POST was blocked, so no room was changed. The scoped type check and production build passed. This is a provisional strength/outcome-order interpretation, not a complete skill or combined-module ruling, and it is not yet deployed.
|
||||
|
||||
The complete offline regression suite passed 5,545 tests across 696 files after the retreat changes. The unrelated in-progress Nexus/Fremen draft still blocks the repository-wide type/lint check; the scoped type check covers this retreat slice. The live `4801ce0` image predates this slice.
|
||||
|
||||
## 27 September 2026 — Ixian/Tleilaxu with Nexus Cards
|
||||
|
||||
The paired-faction Nexus sample path now includes genuine Ixian/Tleilaxu games without opening the public expansion/module starts. At seed 20260927 all ten two-through-six-seat Basic/Advanced [samples](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games) completed with 3,853 accepted actions, 100 JSON restores, five unallied card choices and no rejected actions. The three-seat Basic and Advanced traces reach Ixian auction decisions and Nexus draws; Basic reaches a Face Dancer decision, Advanced an Ixian Technology decision. A five-seat Basic trace also plays one Atreides Nexus effect, while an interrupted four-seat Advanced game resumes its own roster. The source-bound report is `/tmp/dune-ix-nexus-checkpoint-20260927/report.json`. This does not certify all card effects, combined module configurations or complete expansion rules.
|
||||
|
||||
@@ -184,6 +184,24 @@ designer expressly presents the latter as useful to Fremen, but does not
|
||||
publish a general stacking order for every expansion movement modifier
|
||||
([designer example, 19:13](https://www.youtube.com/watch?v=XT_azRVLq_0&t=1153s)).
|
||||
|
||||
### Bounded Diplomat retreat interpretation
|
||||
|
||||
A 27 September 2026 online check of the [publisher's CHOAM & Richese
|
||||
rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf),
|
||||
the [designer's walkthrough](https://www.youtube.com/watch?v=XT_azRVLq_0&t=851s)
|
||||
and the independent [Landsraad tournament reference](https://www.landsraad-vegas.com/rules)
|
||||
did not establish a publisher/designer priority ruling for modified strength
|
||||
or the precise casualty order. The publisher's old PDF URL currently redirects
|
||||
to its store; the sourced printed-card paraphrase above remains the authority.
|
||||
For the playable standalone Leader Skills profile, use the selected disc's
|
||||
printed value (Zoal copies the opposing disc), not battle bonuses. Resolve
|
||||
weapons and leader survival first; a surviving losing trained leader may then
|
||||
move up to that cap in undialed physical counters before the remaining loser
|
||||
forces go to the Tanks. A dead leader, traitor loss or explosion cannot retreat.
|
||||
This is an explicit conservative implementation interpretation, not a quoted
|
||||
FAQ answer. Legacy battles, other optional modules and modified-skill
|
||||
interactions retain their existing gates.
|
||||
|
||||
## 20 September Smuggler follow-up
|
||||
|
||||
A bounded renewed check of the GF9 CHOAM & Richese rules/Q&A, the
|
||||
|
||||
@@ -72,8 +72,11 @@ income remains 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](SMUGGLER_BATTLE.md)
|
||||
Worthless card copying a canonical base Shield or Snooper. Its bounded lower
|
||||
[retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation) now moves
|
||||
selected undialed normal/elite counters to an empty adjacent non-stronghold
|
||||
after a surviving trained leader loses. Modified-defense inheritance remains
|
||||
guarded. [Smuggler battle collection](SMUGGLER_BATTLE.md)
|
||||
now connects reveal-bound, unmodified collection after survival, including
|
||||
losers and captives. Modified strength and combined modules remain guarded.
|
||||
Planetologist and Suk Graduate have the connected
|
||||
@@ -88,8 +91,8 @@ The Basic Tleilaxu follow-up now tests response-driven automatic own revival
|
||||
continuation; this does not certify Advanced foreign-ghola custody. The other source-contract timing questions also remain
|
||||
open. Planetologist now has bounded movement and battle controls as described below.
|
||||
Remaining normal-band payments, ordinary Mentat activation, modified Smuggler collection,
|
||||
Diplomat retreat and the other guarded skill combinations still need connected
|
||||
effects.
|
||||
and the other guarded skill combinations still need connected effects. Diplomat
|
||||
retreat is a bounded prototype, not a complete simultaneous-effect ruling.
|
||||
|
||||
## Planetologist follow-up
|
||||
|
||||
|
||||
@@ -71,7 +71,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 later battle timing stay guarded. The existing pre-plan opportunity supports [early aggressor](SAPHO_AGGRESSOR.md). 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. Every genuine Richese setup includes those cards and Stone Burner; an ordinary-copy-only admission gate cannot depend on concealed custody or alter the printed deck. | [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 modifier/allocation boundaries, Mentat empty-hand fallback (private historical observations persist after death), 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) |
|
||||
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifier/allocation boundaries, Mentat empty-hand fallback, modified Diplomat defenses and simultaneous optional timing remain source gaps. After an online publisher/designer/tournament check found no explicit priority ruling, the bounded standalone Diplomat retreat prototype explicitly chooses printed/copied disc strength and a surviving-loser retreat before ordinary casualties; other combinations remain guarded. This is an implementation interpretation, not an official clarification. | [Material boundaries and selected retreat interpretation](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Diplomat defense boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
|
||||
| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; unmodified lower collection is now [prototyped](SMUGGLER_BATTLE.md); modified strength, multiple positive piles and combined modules remain gated. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
|
||||
| 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) |
|
||||
|
||||
@@ -33,6 +33,13 @@ Each pushed application checkpoint also needs a separate live deployment; a
|
||||
successful GitHub container publication alone is not a deployment. Follow
|
||||
[the manual TrueNAS update](TRUENAS.md#applying-an-update) only after the image
|
||||
passes CI, the persistent volume is backed up and play reaches a safe point.
|
||||
|
||||
For subsequent checkpoints, delegate the NAS Apps update and live verification
|
||||
to a dedicated deployment agent while the integration owner continues other
|
||||
work. Supply the exact successful immutable image tag; the agent changes only
|
||||
the `dune` app in Apps, retains its `/data` volume, and reports the displayed
|
||||
revision and saved-room continuity before the checkpoint is called deployed.
|
||||
|
||||
If access, the backup or a safe point is missing, report the push as **not
|
||||
deployed** and keep its production verification open rather than claiming the
|
||||
Git revision label matches the pushed commit.
|
||||
|
||||
@@ -25,6 +25,7 @@ const nullableCount = (value: unknown) => value === null || count(value);
|
||||
const decisions = {
|
||||
harassWithdraw: true,
|
||||
diplomatDefense: true,
|
||||
diplomatRetreat: true,
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
homeworldRevivalDeployment: true,
|
||||
|
||||
@@ -1547,6 +1547,20 @@ function policyActions(g: GameView): Action[] {
|
||||
stronghold,
|
||||
}));
|
||||
}
|
||||
if (d.kind === 'diplomatRetreat') {
|
||||
const destination = d.destinations[0];
|
||||
let choice = destination?.choices[0];
|
||||
for (const candidate of destination?.choices ?? [])
|
||||
if (!choice || candidate.normal + candidate.elite > choice.normal + choice.elite)
|
||||
choice = candidate;
|
||||
return [{
|
||||
type: 'decision',
|
||||
event: d.event,
|
||||
destination: destination && choice ? destination.location : null,
|
||||
normal: choice?.normal ?? 0,
|
||||
elite: choice?.elite ?? 0,
|
||||
}];
|
||||
}
|
||||
if (d.kind === 'diplomatDefense') {
|
||||
const battle = g.battle;
|
||||
const opponent = battle
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { casualtyOptions, type CombatForces } from './combat';
|
||||
|
||||
export type RetreatForces = { normal: number; elite: number };
|
||||
export type DiplomatRetreatSelection = RetreatForces & { destination: string | null };
|
||||
export type DiplomatRetreatDestination = { location: string; choices: RetreatForces[] };
|
||||
|
||||
/** A retreat may take only counters not needed for any legal physical dial commitment. */
|
||||
export function diplomatRetreatChoices(
|
||||
forces: CombatForces,
|
||||
dial: number,
|
||||
support: number,
|
||||
strength: number,
|
||||
available: RetreatForces = forces,
|
||||
): RetreatForces[] {
|
||||
if (!Number.isSafeInteger(strength) || strength < 0) return [];
|
||||
const choices: RetreatForces[] = [];
|
||||
for (let normal = 0; normal <= Math.min(strength, forces.normal, available.normal); normal++)
|
||||
for (let elite = 0; elite <= Math.min(strength - normal, forces.elite, available.elite); elite++) {
|
||||
if (!normal && !elite) continue;
|
||||
if (casualtyOptions({ ...forces, normal: forces.normal - normal, elite: forces.elite - elite }, dial, support).length)
|
||||
choices.push({ normal, elite });
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
+135
-4
@@ -21,6 +21,7 @@ import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterRouteDis
|
||||
import { sandmasterWormCollection } from './sandmaster-worm';
|
||||
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
|
||||
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
|
||||
import { diplomatRetreatChoices, type DiplomatRetreatSelection, type DiplomatRetreatDestination } from './diplomat-retreat';
|
||||
import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
|
||||
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair } from './battle-card-slots';
|
||||
import { HARASS_WITHDRAW_CARD, HarassWithdrawError, isHarassWithdraw, quoteHarassWithdraw, defaultHarassWithdrawAllocation, harassWithdrawNeedsAllocation, type HarassWithdrawSelection, type HarassWithdrawContext, type HarassWithdrawPreview } from './harass-withdraw';
|
||||
@@ -295,6 +296,7 @@ import {
|
||||
quoteBattleResolution,
|
||||
BattleResolutionQuoteError,
|
||||
type ResolutionCombatant,
|
||||
type BattleResolutionQuote,
|
||||
} from './battle-resolution-quote';
|
||||
import {
|
||||
quoteRevivalCancellation,
|
||||
@@ -540,6 +542,7 @@ import {
|
||||
import {
|
||||
TERRITORIES,
|
||||
MOBILE_STRONGHOLD,
|
||||
MOBILE_LOCATION,
|
||||
gameTerritories,
|
||||
gameDistance,
|
||||
mobileRouteDistance,
|
||||
@@ -651,6 +654,12 @@ export type Battle = {
|
||||
diplomatDefenseVersion?: 1;
|
||||
diplomatDefenseEvent?: string;
|
||||
diplomatDefense?: DiplomatDefenseReceipt;
|
||||
/** New battles may pause after the outcome is known, before loser losses. */
|
||||
diplomatRetreatVersion?: 1;
|
||||
diplomatRetreat?: {
|
||||
player: string; event: string; battle: string; turn: number; frame: string;
|
||||
destinations: DiplomatRetreatDestination[];
|
||||
};
|
||||
/** Public face-up/behind-shield choice precedes faction battle powers. */
|
||||
leaderSkillHidden?: Record<string, boolean>;
|
||||
nexusInspection?: NexusInspection;
|
||||
@@ -713,6 +722,7 @@ export type Auction = {
|
||||
export type Decision =
|
||||
| { kind: 'harassWithdraw'; player: string; event: string }
|
||||
| { kind: 'diplomatDefense'; player: string; event: string; cards: string[]; source: string }
|
||||
| { kind: 'diplomatRetreat'; player: string; event: string; destinations: DiplomatRetreatDestination[] }
|
||||
| { kind: 'leaderSkillVisibility'; player: string; event: string; resumePowers?: boolean }
|
||||
| { kind: 'leaderSkillRevival'; player: string; event: string }
|
||||
| { kind: 'homeworldRevivalDeployment'; player: string; event: string }
|
||||
@@ -5796,6 +5806,7 @@ function leaderSkillsIntegrity(g: Game) {
|
||||
bureaucratPaymentIntegrity(g);
|
||||
mentatQuestionIntegrity(g);
|
||||
diplomatDefenseIntegrity(g);
|
||||
diplomatRetreatIntegrity(g);
|
||||
harassAllocationIntegrity(g);
|
||||
const noFieldShipment = leaderSkillNoFieldIntegrity(g);
|
||||
sandmasterIntegrity(g);
|
||||
@@ -16192,7 +16203,7 @@ function advanceHomeworldReveal(g: Game): boolean {
|
||||
if (owner) {
|
||||
requireRule(b.event, 'The Homeworld defense opportunity needs its battle event.');
|
||||
g.decision = {kind: 'homeworldDefense', player: owner, event: b.event};
|
||||
} else resolveBattle(g);
|
||||
} else advanceBattleOutcome(g);
|
||||
return true;
|
||||
}
|
||||
function traitorVoters(g: Game, b: Battle) {
|
||||
@@ -16352,8 +16363,89 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function resolveBattle(g: Game) {
|
||||
function diplomatRetreatBlocked(g: Game, key: string) {
|
||||
const sector = key === MOBILE_LOCATION ? splitLocation(g.mobileStronghold?.location ?? '').sector
|
||||
: splitLocation(key).sector;
|
||||
return sector > 0 && sector === g.storm;
|
||||
}
|
||||
function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) {
|
||||
const b = g.battle!;
|
||||
if (b.diplomatRetreatVersion !== 1 || quote.result !== 'normal' || !quote.winner ||
|
||||
b.territory.startsWith('homeworld:')) return null;
|
||||
const player = quote.winner === b.attacker ? b.defender : b.attacker;
|
||||
const p = getPlayer(g, player);
|
||||
const plan = b.plans[player];
|
||||
const leader = controlledLeaders(g, p).find(candidate => candidate.id === plan.leader && !candidate.dead);
|
||||
if (!leader || (player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender) ||
|
||||
!battleLeaderSkills(g, p).some(skill => skill.skill === 'diplomat' && skill.leader === leader.id &&
|
||||
(!skill.faceUp || skill.captured)) || p.noField?.deployed?.location.territory === b.territory) return null;
|
||||
const opponent = getPlayer(g, quote.winner);
|
||||
const forces = combatForces(g, p, b.territory, opponent);
|
||||
const strength = battleLeaderStrength(leader,
|
||||
controlledLeaders(g, opponent).find(candidate => candidate.id === b.plans[opponent.id].leader));
|
||||
const origins = Object.entries(p.forces).filter(([key, count]) =>
|
||||
count > 0 && splitLocation(key).territory === b.territory);
|
||||
const destinations = TERRITORIES.filter(candidate => candidate.type !== 'stronghold' &&
|
||||
candidate.id !== b.territory &&
|
||||
!g.players.some(side => Object.entries(side.forces).some(([key, count]) =>
|
||||
count > 0 && splitLocation(key).territory === candidate.id)))
|
||||
.flatMap(candidate => candidate.sectors.map(sector => location(candidate.id, sector)))
|
||||
.sort()
|
||||
.map(key => {
|
||||
const available = origins.reduce((sum, [origin, count]) => {
|
||||
if (gameDistance(g, origin, key, candidate => diplomatRetreatBlocked(g, candidate)) !== 1) return sum;
|
||||
const elite = p.elites?.forces[origin] ?? 0;
|
||||
return { normal: sum.normal + count - elite, elite: sum.elite + elite };
|
||||
}, { normal: 0, elite: 0 });
|
||||
return { location: key, choices: diplomatRetreatChoices(forces, plan.dial, plan.support, strength, available) };
|
||||
})
|
||||
.filter(option => option.choices.length);
|
||||
return destinations.length ? { player, destinations } : null;
|
||||
}
|
||||
function diplomatRetreatFrame(g: Game) {
|
||||
const b = g.battle!;
|
||||
return JSON.stringify({ battle: b.event, turn: g.turn, territory: b.territory,
|
||||
plans: b.plans, hidden: b.leaderSkillHidden, traitors: b.traitorCalls,
|
||||
forces: g.players.map(p => [p.id, p.forces, p.elites?.forces]),
|
||||
skills: g.leaderSkills?.assignments, storm: g.storm });
|
||||
}
|
||||
function diplomatRetreatIntegrity(g: Game) {
|
||||
const b = g.battle, receipt = b?.diplomatRetreat;
|
||||
const decision = g.decision;
|
||||
if (!receipt) {
|
||||
requireRule(decision?.kind !== 'diplomatRetreat', 'The Diplomat retreat lost its battle.');
|
||||
return;
|
||||
}
|
||||
requireRule(b?.diplomatRetreatVersion === 1 && g.status === 'playing' && g.phase === 6 &&
|
||||
b.revealed && b.event === receipt.battle && g.turn === receipt.turn &&
|
||||
receipt.event && receipt.frame === diplomatRetreatFrame(g) &&
|
||||
JSON.stringify(diplomatRetreatOffer(g, currentBattleResolutionQuote(g))) ===
|
||||
JSON.stringify({ player: receipt.player, destinations: receipt.destinations }) &&
|
||||
decision?.kind === 'diplomatRetreat' && decision.event === receipt.event &&
|
||||
decision.player === receipt.player &&
|
||||
JSON.stringify(decision.destinations) === JSON.stringify(receipt.destinations),
|
||||
'The saved Diplomat retreat changed its battle, forces, outcome or legal destinations.');
|
||||
}
|
||||
function advanceBattleOutcome(g: Game) {
|
||||
const b = g.battle!;
|
||||
if (b.diplomatRetreatVersion !== 1 || ![b.attacker, b.defender].some(id =>
|
||||
battleLeaderSkills(g, getPlayer(g, id)).some(skill => skill.skill === 'diplomat' &&
|
||||
b.plans[id].leader === skill.leader && (!skill.faceUp || skill.captured)))) {
|
||||
resolveBattle(g);
|
||||
return;
|
||||
}
|
||||
const offer = diplomatRetreatOffer(g, currentBattleResolutionQuote(g));
|
||||
if (!offer) { resolveBattle(g); return; }
|
||||
requireRule(b.event && !b.diplomatRetreat, 'The Diplomat retreat needs one current battle.');
|
||||
const event = crypto.randomUUID();
|
||||
b.diplomatRetreat = { ...offer, event, battle: b.event, turn: g.turn, frame: diplomatRetreatFrame(g) };
|
||||
g.decision = { kind: 'diplomatRetreat', event, ...offer };
|
||||
log(g, `${getPlayer(g, offer.player).name} may retreat surviving undialed forces with Diplomat before battle losses.`,
|
||||
{ faction: getPlayer(g, offer.player).faction, name: 'Diplomat retreat' });
|
||||
}
|
||||
function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
const b = g.battle!;
|
||||
requireRule(!!b.diplomatRetreat === !!retreat, 'Resolve the pending Diplomat retreat before battle losses.');
|
||||
const quote = currentBattleResolutionQuote(g);
|
||||
const a = getPlayer(g, b.attacker),
|
||||
d = getPlayer(g, b.defender),
|
||||
@@ -16522,6 +16614,30 @@ function resolveBattle(g: Game) {
|
||||
log(g, `${player.name} gained ${bonus.amount} battle strength from ${leaderSkillCard(bonus.skill).name}${bonus.mode === 'normal' ? ' while their skilled leader remained face up' : ' by using their surviving skilled leader'}.`, {faction:player.faction,name:'Leader Skill'});
|
||||
const loser = winner === a ? d : a;
|
||||
if (quote.bounty) winner!.spice += quote.bounty.amount;
|
||||
if (retreat?.destination) {
|
||||
let normal = retreat.normal, elite = retreat.elite;
|
||||
for (const key of Object.keys(loser.forces).sort()) {
|
||||
if (splitLocation(key).territory !== b.territory || (!normal && !elite) ||
|
||||
gameDistance(g, key, retreat.destination, candidate => diplomatRetreatBlocked(g, candidate)) !== 1) continue;
|
||||
const availableElite = loser.elites?.forces[key] ?? 0;
|
||||
const takenNormal = Math.min(normal, loser.forces[key] - availableElite);
|
||||
const takenElite = Math.min(elite, availableElite);
|
||||
loser.forces[key] -= takenNormal + takenElite;
|
||||
if (!loser.forces[key]) delete loser.forces[key];
|
||||
if (takenElite && loser.elites) {
|
||||
loser.elites.forces[key] -= takenElite;
|
||||
if (!loser.elites.forces[key]) delete loser.elites.forces[key];
|
||||
}
|
||||
normal -= takenNormal; elite -= takenElite;
|
||||
}
|
||||
requireRule(!normal && !elite, 'The Diplomat retreat lost its undialed physical counters.');
|
||||
loser.forces[retreat.destination] = (loser.forces[retreat.destination] ?? 0) + retreat.normal + retreat.elite;
|
||||
if (retreat.elite && loser.elites)
|
||||
loser.elites.forces[retreat.destination] = (loser.elites.forces[retreat.destination] ?? 0) + retreat.elite;
|
||||
log(g, `${loser.name} retreated ${retreat.normal} normal and ${retreat.elite} elite undialed forces with Diplomat to ${territory(splitLocation(retreat.destination).territory).name}.`,
|
||||
{ faction: loser.faction, name: 'Diplomat retreat' });
|
||||
observeOccupation(g);
|
||||
}
|
||||
killTerritory(g, loser, b.territory, Infinity, true);
|
||||
observeOccupation(g);
|
||||
if (quote.basicWinnerLosses !== null)
|
||||
@@ -19079,7 +19195,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
if (
|
||||
traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
|
||||
)
|
||||
resolveBattle(g);
|
||||
advanceBattleOutcome(g);
|
||||
} else if (response.kind === 'richesePurchaseIncome') {
|
||||
const pending = g.pendingRichesePurchaseIncome;
|
||||
requireRule(
|
||||
@@ -22708,6 +22824,20 @@ function applyActionInner(
|
||||
if (harkonnen) returnCaptives(g, harkonnen);
|
||||
settleAdvisors(g);
|
||||
finishBattle(g);
|
||||
} else if (decision.kind === 'diplomatRetreat') {
|
||||
const receipt = g.battle?.diplomatRetreat;
|
||||
const destination = action.destination;
|
||||
const normal = action.normal, elite = action.elite;
|
||||
requireRule(Object.keys(action).every(key => ['type', 'event', 'destination', 'normal', 'elite'].includes(key)) &&
|
||||
receipt?.event === decision.event && action.event === decision.event && receipt.player === id &&
|
||||
(destination === null && normal === 0 && elite === 0 ||
|
||||
typeof destination === 'string' && receipt.destinations.some(option => option.location === destination &&
|
||||
option.choices.some(choice => choice.normal === normal && choice.elite === elite))),
|
||||
'Choose a legal empty adjacent territory and undialed force count, or decline this Diplomat retreat.');
|
||||
g.decision = null;
|
||||
if (destination === null)
|
||||
log(g, `${p.name} declined the Diplomat retreat.`, { faction: p.faction, name: 'Diplomat retreat' });
|
||||
resolveBattle(g, { destination: destination as string | null, normal: normal as number, elite: elite as number });
|
||||
} else if (decision.kind === 'harassWithdraw') {
|
||||
const receipt = g.battle?.harassAllocation, offer = harassAllocationOffer(g);
|
||||
requireRule(Object.keys(action).every(key => ['type', 'event', 'returns'].includes(key)) &&
|
||||
@@ -24916,6 +25046,7 @@ function applyActionInner(
|
||||
...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
|
||||
...(g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
|
||||
...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 1 as const } : {}),
|
||||
...(g.leaderSkills && ordinaryLeaderSkillModeSupported(g) ? { diplomatRetreatVersion: 1 as const } : {}),
|
||||
...(g.ecazTreachery ? { harassAllocationVersion: 1 as const } : {}),
|
||||
...(byFaction(g, 'richese')
|
||||
? { preLeader: { event: battleEvent, ready: [], closed: false } }
|
||||
@@ -25149,7 +25280,7 @@ function applyActionInner(
|
||||
if (
|
||||
traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
|
||||
)
|
||||
if (!b.territory.startsWith('homeworld:')) resolveBattle(g);
|
||||
if (!b.territory.startsWith('homeworld:')) advanceBattleOutcome(g);
|
||||
else advanceHomeworldReveal(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
+6
-6
@@ -4168,20 +4168,20 @@ 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.',
|
||||
'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.',
|
||||
'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. A surviving trained leader that loses a supported battle may retreat selected undialed physical counters up to its printed/copied disc strength into one empty adjacent non-stronghold territory before ordinary loser losses; modified defenses remain unfinished.',
|
||||
'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
|
||||
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income and Richese sales have connected payment paths; split funding, other payment families and combined modules remain unfinished.',
|
||||
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
|
||||
'Other special Sandmaster relocation, 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.',
|
||||
'Other special Sandmaster relocation and Spice Banker normal income remain missing. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. 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: ['choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', '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, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.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, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.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 and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ 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, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Spice Banker normal income, other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.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, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.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 and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; 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/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.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 shipment and battle collection, losing/captured survival, Sandmaster order, explosion, legacy and concurrent 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. Bureaucrat checks cover once-per-phase use, private source receipts and resumed payment custody. Sandmaster worm checks cover typed forces, decline, saved arrival reactions, concurrent writes, repeated distinct rides and special-Karama origin. 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/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/bureaucrat-payment.test.ts','tests/bureaucrat-payment-controls.test.tsx','tests/bureaucrat-payment-bots.test.ts','tests/bureaucrat-payment-recovery.test.ts','tests/smuggler-battle.test.ts','tests/smuggler-battle-recovery.test.ts','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/sandmaster-worm.test.ts','tests/sandmaster-worm-controls.test.tsx','tests/sandmaster-worm-recovery.test.ts','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
|
||||
{ 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 shipment and battle collection, losing/captured survival, Sandmaster order, explosion, legacy and concurrent 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, legacy continuation, losing-battle ordinary/elite retreat and concurrent saved force custody. Bureaucrat checks cover once-per-phase use, private source receipts and resumed payment custody. Sandmaster worm checks cover typed forces, decline, saved arrival reactions, concurrent writes, repeated distinct rides and special-Karama origin. 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/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/bureaucrat-payment.test.ts','tests/bureaucrat-payment-controls.test.tsx','tests/bureaucrat-payment-bots.test.ts','tests/bureaucrat-payment-recovery.test.ts','tests/smuggler-battle.test.ts','tests/smuggler-battle-recovery.test.ts','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/sandmaster-worm.test.ts','tests/sandmaster-worm-controls.test.tsx','tests/sandmaster-worm-recovery.test.ts','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts','tests/diplomat-retreat-engine.test.ts','tests/diplomat-retreat-controls.test.tsx','tests/diplomat-retreat-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ const DECISIONS = {
|
||||
sukRescue: true,
|
||||
rihani: true,
|
||||
diplomatDefense: true,
|
||||
diplomatRetreat: true,
|
||||
harassWithdraw: true,
|
||||
choamAudit: true,
|
||||
choamAuditPayment: true,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { DiplomatRetreat } from '../components/diplomat-retreat';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type GameView } from '../game/engine';
|
||||
import { diplomatDefenseGame } from './diplomat-defense-fixture';
|
||||
|
||||
function offeredView(viewer = 'a'): GameView {
|
||||
const view = viewGame(diplomatDefenseGame(), viewer);
|
||||
view.decision = {
|
||||
kind: 'diplomatRetreat', player: 'a', event: 'retreat-event',
|
||||
destinations: [
|
||||
{ location: 'imperial_basin:9', choices: [
|
||||
{ normal: 1, elite: 0 },
|
||||
{ normal: 2, elite: 1 },
|
||||
{ normal: 1, elite: 2 },
|
||||
] },
|
||||
{ location: 'hagga_basin:12', choices: [
|
||||
{ normal: 0, elite: 1 },
|
||||
{ normal: 1, elite: 1 },
|
||||
] },
|
||||
],
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
void test('only the deciding player can select every offered destination and physical count or decline', () => {
|
||||
const view = offeredView();
|
||||
const markup = (game: GameView, busy = false) => renderToStaticMarkup(
|
||||
createElement(DiplomatRetreat, { game, act() {}, busy }),
|
||||
);
|
||||
const html = markup(view);
|
||||
assert.match(html, /aria-label="Diplomat retreat choice"/);
|
||||
assert.match(html, /Imperial Basin · sector 9/);
|
||||
assert.match(html, /Hagga Basin · sector 12/);
|
||||
assert.match(html, /2 ordinary · 1 elite/);
|
||||
assert.match(html, /1 ordinary · 2 elite/);
|
||||
assert.doesNotMatch(html, /0 ordinary · 1 elite/);
|
||||
assert.match(html, /Retreat to Imperial Basin · sector 9/);
|
||||
assert.match(html, /Decline Diplomat retreat/);
|
||||
assert.equal(markup(offeredView('d')), '');
|
||||
assert.equal((markup(view, true).match(/disabled=""/g) ?? []).length, 6);
|
||||
if (view.decision?.kind !== 'diplomatRetreat') throw new Error('Expected Diplomat retreat');
|
||||
view.decision.destinations.reverse();
|
||||
const alternate = markup(view);
|
||||
assert.match(alternate, /0 ordinary · 1 elite/);
|
||||
assert.doesNotMatch(alternate, /2 ordinary · 1 elite/);
|
||||
view.decision.destinations = [];
|
||||
assert.match(markup(view), /No legal retreat destination and force combination/);
|
||||
assert.doesNotMatch(markup(view), /Retreat to Imperial Basin/);
|
||||
assert.match(markup(view), /Decline Diplomat retreat/);
|
||||
});
|
||||
|
||||
void test('all bot profiles select the first destination and largest legal physical count, or decline if none', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = offeredView();
|
||||
view.players.find((player) => player.id === view.me)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view)[0], {
|
||||
type: 'decision', event: 'retreat-event',
|
||||
destination: 'imperial_basin:9', normal: 2, elite: 1,
|
||||
});
|
||||
if (view.decision?.kind !== 'diplomatRetreat') throw new Error('Expected Diplomat retreat');
|
||||
view.decision.destinations = [];
|
||||
assert.deepEqual(botActions(view)[0], {
|
||||
type: 'decision', event: 'retreat-event',
|
||||
destination: null, normal: 0, elite: 0,
|
||||
});
|
||||
const observer = offeredView('d');
|
||||
observer.players.find((player) => player.id === observer.me)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(observer), []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { leaderSkillBattle } from './leader-skill-battle-fixture';
|
||||
|
||||
function losingDiplomat(elite = false, territory = 'arrakeen', sector = 10, stage?: (game: Game) => void): Game {
|
||||
let game = leaderSkillBattle({ skill: 'diplomat', unsealed: true, hide: true, elite, territory, sector });
|
||||
stage?.(game);
|
||||
game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
|
||||
game = applyAction(game, 'd', { type: 'battlePlan', dial: 5, leader: 'guild-1' });
|
||||
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
|
||||
return applyAction(game, 'd', { type: 'traitorCall', call: false });
|
||||
}
|
||||
|
||||
void test('surviving skilled Diplomat retreats selected undialed ordinary and elite forces, then loses the remainder', () => {
|
||||
const pending = losingDiplomat(true);
|
||||
const decision = pending.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
assert.ok(decision.destinations[0].choices.some(choice => choice.normal === 2 && choice.elite === 2));
|
||||
const destination = decision.destinations[0].location;
|
||||
const restored = JSON.parse(JSON.stringify(pending)) as Game;
|
||||
assert.deepEqual(viewGame(restored, 'a').decision, decision);
|
||||
const resolved = applyAction(restored, 'a', {
|
||||
type: 'decision', event: decision.event, destination, normal: 2, elite: 2,
|
||||
});
|
||||
const owner = resolved.players.find(player => player.id === 'a')!;
|
||||
assert.equal(owner.forces[destination], 4);
|
||||
assert.equal(owner.elites?.forces[destination], 2);
|
||||
assert.equal(owner.forces['arrakeen:10'], undefined);
|
||||
assert.equal(owner.tanks, 1);
|
||||
assert.equal(owner.elites?.tanks, 0);
|
||||
assert.equal(resolved.phase, 7);
|
||||
assert.equal(resolved.battle, null);
|
||||
assert.equal(resolved.decision, null);
|
||||
assert.match(JSON.stringify(resolved.log), /retreated 2 normal and 2 elite/);
|
||||
});
|
||||
|
||||
void test('Diplomat may retreat through another safe sector of its battle territory to a true adjacent territory', () => {
|
||||
const pending = losingDiplomat(false, 'imperial_basin', 9);
|
||||
const decision = pending.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
assert.ok(decision.destinations.some(option => option.location === 'arsunt:11'));
|
||||
const resolved = applyAction(pending, 'a', {
|
||||
type: 'decision', event: decision.event, destination: 'arsunt:11', normal: 2, elite: 0,
|
||||
});
|
||||
assert.equal(resolved.players[0].forces['arsunt:11'], 2);
|
||||
assert.equal(resolved.players[0].tanks, 3);
|
||||
});
|
||||
|
||||
void test('split sectors cannot retreat counters from an origin blocked by the storm', () => {
|
||||
const pending = losingDiplomat(false, 'imperial_basin', 9, game => {
|
||||
game.players[0].forces = { 'imperial_basin:9': 2, 'imperial_basin:10': 3 };
|
||||
game.storm = 10;
|
||||
});
|
||||
const decision = pending.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
const exit = decision.destinations.find(option => option.location === 'oh_gap:9');
|
||||
if (!exit) throw new Error('Expected an adjacent retreat exit');
|
||||
assert.ok(exit.choices.some(choice => choice.normal === 2 && choice.elite === 0));
|
||||
assert.equal(exit.choices.some(choice => choice.normal === 3 && choice.elite === 0), false);
|
||||
assert.throws(() => applyAction(pending, 'a', {
|
||||
type: 'decision', event: decision.event, destination: exit.location, normal: 3, elite: 0,
|
||||
}));
|
||||
const resolved = applyAction(pending, 'a', {
|
||||
type: 'decision', event: decision.event, destination: exit.location, normal: 2, elite: 0,
|
||||
});
|
||||
assert.equal(resolved.players[0].forces['oh_gap:9'], 2);
|
||||
assert.equal(resolved.players[0].tanks, 3);
|
||||
});
|
||||
|
||||
void test('a legacy battle without retreat version resolves without a retroactive choice', () => {
|
||||
const resolved = losingDiplomat(false, 'arrakeen', 10, game => {
|
||||
if (game.battle) delete game.battle.diplomatRetreatVersion;
|
||||
});
|
||||
assert.notEqual(resolved.decision?.kind, 'diplomatRetreat');
|
||||
assert.equal(resolved.players[0].forces['arrakeen:10'], undefined);
|
||||
assert.equal(resolved.players[0].tanks, 5);
|
||||
assert.equal(resolved.phase, 7);
|
||||
});
|
||||
|
||||
void test('Diplomat retreat rejects stale or impossible destinations and counts without mutating the pending game', () => {
|
||||
const pending = losingDiplomat();
|
||||
const decision = pending.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
const before = JSON.stringify(pending);
|
||||
for (const action of [
|
||||
{ type: 'decision', event: 'stale', destination: decision.destinations[0].location, normal: 1, elite: 0 },
|
||||
{ type: 'decision', event: decision.event, destination: 'arrakeen:10', normal: 1, elite: 0 },
|
||||
{ type: 'decision', event: decision.event, destination: decision.destinations[0].location, normal: 6, elite: 0 },
|
||||
{ type: 'decision', event: decision.event, destination: null, normal: 1, elite: 0 },
|
||||
]) {
|
||||
assert.throws(() => applyAction(pending, 'a', action));
|
||||
assert.equal(JSON.stringify(pending), before);
|
||||
}
|
||||
assert.throws(() => applyAction(pending, 'd', {
|
||||
type: 'decision', event: decision.event, destination: null, normal: 0, elite: 0,
|
||||
}));
|
||||
const declined = applyAction(pending, 'a', {
|
||||
type: 'decision', event: decision.event, destination: null, normal: 0, elite: 0,
|
||||
});
|
||||
assert.equal(declined.players[0].forces['arrakeen:10'], undefined);
|
||||
assert.equal(declined.players[0].tanks, 5);
|
||||
assert.equal(declined.phase, 7);
|
||||
});
|
||||
|
||||
void test('a dead Diplomat has no retreat even when its faction loses', () => {
|
||||
let game = leaderSkillBattle({ skill: 'diplomat', unsealed: true, hide: true, weaponKills: true });
|
||||
const weapon = game.players[1].hand.find(card => card.kind === 'projectile')!;
|
||||
game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
|
||||
game = applyAction(game, 'd', { type: 'battlePlan', dial: 5, leader: 'guild-1', weapon: weapon.id });
|
||||
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
|
||||
assert.notEqual(game.decision?.kind, 'diplomatRetreat');
|
||||
assert.equal(game.players[0].forces['arrakeen:10'], undefined);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { leaderSkillBattle } from './leader-skill-battle-fixture';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
void test('two concurrent saved Diplomat retreats commit one exact physical transfer and one battle resolution', async () => {
|
||||
const store = unitStore();
|
||||
try {
|
||||
const created = await store.rooms.createRoom('Diplomat owner', 'emperor', false, []);
|
||||
const joined = await store.rooms.joinRoom(created.view.code, 'Opponent', 'guild');
|
||||
const code = created.view.code;
|
||||
const original = await Promise.all([created.token!, joined.token!].map(token =>
|
||||
store.rooms.authenticate(code, token)));
|
||||
for (const [index, seat] of original.entries())
|
||||
store.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([created.token!, joined.token!].map(token =>
|
||||
store.restart().authenticate(code, token)));
|
||||
let game = leaderSkillBattle({ skill: 'diplomat', unsealed: true, hide: true });
|
||||
game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
|
||||
game = applyAction(game, 'd', { type: 'battlePlan', dial: 5, leader: 'guild-1' });
|
||||
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, 'd', { type: 'traitorCall', call: false });
|
||||
const decision = game.decision;
|
||||
assert.equal(decision?.kind, 'diplomatRetreat');
|
||||
if (decision?.kind !== 'diplomatRetreat') return;
|
||||
game.code = code;
|
||||
game.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(game), game.version, code);
|
||||
store.writes.length = 0;
|
||||
const destination = decision.destinations[0].location;
|
||||
const action = { type: 'decision', event: decision.event, destination, normal: 5, elite: 0 };
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>(resolve => { release = resolve; });
|
||||
store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await ready; };
|
||||
const results = await Promise.allSettled([
|
||||
store.rooms.act(code, seats[0], game.version, action, clock),
|
||||
store.restart().act(code, seats[0], game.version, action, clock),
|
||||
]);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(store.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const restored = await store.restart().readRoom(code);
|
||||
assert.equal(restored.version, game.version + 1);
|
||||
const owner = restored.players.find(player => player.id === 'a')!;
|
||||
assert.equal(owner.forces[destination], 5);
|
||||
assert.equal(owner.forces['arrakeen:10'], undefined);
|
||||
assert.equal(owner.tanks, 0);
|
||||
assert.equal(restored.battle, null);
|
||||
assert.equal(restored.decision, null);
|
||||
assert.equal(restored.phase, 7);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user