Resolve ambiguous Stone battles with sealed physical totals
Add a provisional post-reveal physical-count commitment for new outcome-changing Basic/Advanced Stone and copied Mirror battles. Keep all original sides sealed until complete and retain matching later typed losses. Preserve legacy, ordinary and invariant-winner timing. Carry the same constraint through card cleanup and native casualty consumers. Validation: frozen 16 files146/146, types/lint/build and14 original-action/rendered-control programmes; four minimal policies make both owned choices legally. Source timing is explicitly provisional. Wider combinations, assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
This commit is contained in:
1 parent
677ad7aa3c
commit
6ccfaa1b4d
22 files changed
+1213
-49
No files matched your search
@@ -168,6 +168,7 @@ import {
|
||||
} from '@/game/battle-cards';
|
||||
import { validBattleSlotPair, usesNativeBattleSpecial } from '@/game/battle-card-slots';
|
||||
import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext } from '@/game/stone-burner';
|
||||
import { StoneBurnerAllocation } from './stone-burner-allocation';
|
||||
import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
|
||||
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
|
||||
import {
|
||||
@@ -629,6 +630,7 @@ export function GameTable({
|
||||
? 'attacker'
|
||||
: 'defender',
|
||||
stoneCandidateContext,
|
||||
stoneContext?.allowAllocations,
|
||||
),
|
||||
)
|
||||
.find((reason) => reason !== null) ?? null)));
|
||||
@@ -3737,6 +3739,11 @@ export function GameTable({
|
||||
accept: false,
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'stoneAllocation' ? (
|
||||
g.battle?.stoneAllocation && <StoneBurnerAllocation event={g.decision.event}
|
||||
totals={g.battle.stoneAllocation.totals}
|
||||
onCommit={undialed => act({type:'decision',event:g.battle!.stoneAllocation!.event,undialed})}
|
||||
busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||||
) : g.decision.kind === 'discoveryEntry' ? (
|
||||
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||||
) : g.decision.kind === 'ecologicalStorm' ? (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function StoneBurnerAllocation({
|
||||
event,
|
||||
totals,
|
||||
onCommit,
|
||||
busy,
|
||||
}: {
|
||||
event: string;
|
||||
totals: readonly number[];
|
||||
onCommit: (undialed: number) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const choices = [...new Set(totals)].filter(total => Number.isSafeInteger(total) && total >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (!event || !choices.length) return null;
|
||||
return (
|
||||
<section aria-label="Stone Burner undialed force commitment" className="notice space-y-3" data-event={event}>
|
||||
<h4>Commit undialed physical forces</h4>
|
||||
<p>
|
||||
Provisional timing: choose the number of your undialed physical counters
|
||||
for this Stone Burner comparison. Each ordinary or elite counter counts
|
||||
as one. Choices stay sealed until everyone has committed.
|
||||
</p>
|
||||
<p>
|
||||
The winner chooses their ordinary and elite losses afterward, consistent
|
||||
with the committed total. This choice does not change your dial or spice.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{choices.map(total => (
|
||||
<Button key={`${event}:${total}`} type="button" className="min-h-11 whitespace-normal text-base" disabled={busy}
|
||||
onClick={() => { if (!busy) onCommit(total); }}>
|
||||
Commit {total} undialed physical forces
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+23
-1
@@ -16,6 +16,28 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Stone Burner physical allocation — 18 October. Prototyped / Partial.**
|
||||
New original Basic/Advanced Stone and copied Mirror battles now finish
|
||||
outcome-changing mixed-force comparisons. Only ambiguous sides privately commit
|
||||
an undialed physical total; all original required values stay sealed until
|
||||
complete. The winner retains compatible later ordinary/elite loss choices.
|
||||
Ordinary/invariant battles and unstamped legacy frames retain their prior path.
|
||||
Timing is explicitly provisional under the 14 October choose-recommended policy.
|
||||
|
||||
Three disjoint lanes and owner integration pass frozen **16 files146/146**,
|
||||
types/lint/build and14 finite original-action/rendered-control programmes.
|
||||
Original paid Stone uses69 Basic/52 Advanced source actions, actual next-turn
|
||||
Mirror90, both native mixed sides95 in either seat order. Both outcomes and
|
||||
same-count typed alternatives complete; four minimal policies make both legal
|
||||
private choices. Actual Mirror feedback repaired a card-cleanup continuation
|
||||
that lost the narrowed physical count; native consumers now retain it too.
|
||||
Historical guard tests explicitly keep legacy coverage; no printed number changed.
|
||||
|
||||
Framework aliases/CSS stubs mean no visual/browser/deployed acceptance. Wider
|
||||
combinations, full modes and final assurance remain open. No saved room/database,
|
||||
reset, restart or deployment.
|
||||
[Exact source, procedure and proof](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026).
|
||||
|
||||
**Independent cards with Discovery — 18 October. Prototyped / Partial.**
|
||||
Fresh classic and selected-deck tables now combine independent36/38/50 with
|
||||
Discovery7/8, with or without Nexus12. Original World/all14/Tech3+/Advanced
|
||||
@@ -5708,7 +5730,7 @@ These links define bounded working behavior, not complete module certification.
|
||||
| Area | Current connected work and evidence |
|
||||
| --- | --- |
|
||||
| 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. |
|
||||
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement, [Advanced Loyalty](ECAZ_LOYALTY.md), separate optional card variants and private controls. The [adopted Ixians D source](RICHESE_AUCTION_RULES.md#explicit-source-boundaries) excludes Richese auctions from Technology; no special-lot exchange or decline is required. |
|
||||
| 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 Orgiz transfers, Jacurutu income, Testing Station and Shrine. Orgiz's per-deposit interpretation, contested/shared benefits and mixed physical allocation remain provisional or pending. The Orgiz path in current source is not yet deployed. |
|
||||
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Secret Ally trade and bounded after-victory inspection](NEXUS_CHOAM_SECRET_ALLY.md), [Moritani Secret Ally losing-card retention](NEXUS_MORITANI_RULES.md#secret-ally-losing-card-retention-prototype), [Ecaz private yes/no inquiry and bounded native Duke Cunning](NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal), [Emperor extra revival and bounded bank-auction purchase](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected. Uniform CHOAM winner/Moritani loser windows, Ecaz inquiry scope/audience and Ecaz Cunning's Battle-only window are product inferences, not publisher rulings. Source audits or helpers alone do not certify full module games. |
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 18 October 2026 — Outcome-changing Stone physical totals
|
||||
|
||||
New original Basic/Advanced Stone and copied Mirror now request sealed physical
|
||||
totals only when they can change the winner. Every originally ambiguous side
|
||||
commits; the eventual winner keeps all matching later typed loss choices.
|
||||
Ordinary/invariant and unstamped legacy battles retain their earlier paths.
|
||||
This is explicit provisional timing, with no new numerical rule.
|
||||
|
||||
Three exclusive lanes and owner integration pass frozen16-file146/146,
|
||||
types/lint/build and14 original-action/rendered-control programmes; four policies
|
||||
make both private choices legally. Actual Mirror feedback repaired the mandatory
|
||||
card-cleanup constraint and native suffix consumers retain the same count.
|
||||
Guard regressions now distinguish old and new frames; fixture expectations
|
||||
respect normal city income and closed battle frames.
|
||||
[Canonical source, proof and limitations](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026).
|
||||
No complete-mode, comprehensive combination/assurance or deployed claim;
|
||||
no saved room/database mutation, reset, restart or deployment.
|
||||
|
||||
## 18 October 2026 — Independent cards with Discovery
|
||||
|
||||
Fresh selected36/38/50 and original Discovery7/8 now compose with or without
|
||||
|
||||
@@ -874,7 +874,8 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
||||
| Boundary | Still unresolved | Existing record |
|
||||
| --- | --- | --- |
|
||||
| Reinforcements cost | The earlier all-outcome versus sole-Traitor-winner question remains unanswered. The bounded prototype provisionally charges on every revealed outcome, chooses normal reserves first and counts the transfer as battle losses. Same-plan Harass pays the original quote before returns; future returns cannot fund or change it. Native Advanced Occupy Stone/Mirror composition keeps the fee but ignores +2 normal score in physical-token comparison. These are product interpretations, not a new publisher ruling or user answer. Other profile/module gates remain; eligibility never depends on concealed Traitors. | [Source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Combined policy](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026) · [Stone composition](REINFORCEMENTS_RUNTIME.md#native-stone-composition--16-october-2026) |
|
||||
| Harass / Stone Burner | The earlier keep-gated versus compare-after-withdrawal question is unanswered. Standing choose-recommended provisionally compares counters left after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without hidden custody. Clean Ix priority follows its separate explicitly provisional contract; allocation ambiguity and other profile/module limits remain. This is implementation interpretation, not a new publisher ruling or user answer. | [Withdrawal](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) · [Clean Ix priority](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
|
||||
| Harass / Stone Burner | The earlier keep-gated versus compare-after-withdrawal question is unanswered. Standing choose-recommended provisionally compares counters left after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without hidden custody. Clean Ix priority follows its separate explicitly provisional contract; new physical commitments do not remove other profile/module limits. This is implementation interpretation, not a new publisher ruling or user answer. | [Withdrawal](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) · [Clean Ix priority](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
|
||||
| Stone mixed-force allocation timing | The older timing question remains unanswered by a source. Under 14 October choose-recommended, new original outcome-ambiguous Stone/Mirror battles provisionally commit each ambiguous side's undialed physical total after revealed modes/actual withdrawal and decisive Traitor/explosion branches. Storm-order owners choose privately; every original required value stays sealed until all commit. The eventual winner retains all legal typed casualty choices with the count. Ordinary/invariant and unstamped legacy frames keep their existing path. Publisher physical-count text and supplied Advanced physical12/logical10 casualty timing provide the numerical rules, not this online commitment convention. | [Source, versioned procedure and bounded proof](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026) |
|
||||
| Recruits late accounting and rates | The earlier one/two-further-free question remains unanswered. The prototype preserves paid spice, deducts recorded free usage from the doubled current rate and all returns from the total cap: two-free/one-paid Atreides can take two more free. Clean classic Homeworlds now provisionally recheck native thresholds on separate accepted requests, retaining completed prices/classifications; this is not a numerical user ruling or newly found timing FAQ. Unknown ledgers, pending repricing, later Fremen grants and same-turn replay remain guarded. | [Source alternatives](RECRUITS_RULES.md), [current native runtime](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) |
|
||||
| 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) |
|
||||
| Semuta Drug | Full-hand exchange is user-approved; neutral all-seat acknowledgements remain the explicit application policy. Other discard producers, nested continuation coverage and full-game acceptance remain unfinished. | [Source update and selected exchange](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
|
||||
|
||||
@@ -2,6 +2,76 @@
|
||||
|
||||
Audit: 2026-09-06. Scope: the canonical weapon, its revealed-plan choice and physical undialed-force comparison. No runtime files were changed in this audit. This is not a full optional-module or expansion certification.
|
||||
|
||||
## Outcome-changing physical totals — 18 October 2026
|
||||
|
||||
**Prototyped / Partial.** New original battles now finish the previously guarded
|
||||
Basic Cyborg/Suboid and Advanced mixed-force cases where choosing physical
|
||||
casualties can change Stone's winner. The publisher face below compares
|
||||
undialed physical counters but supplies no commitment procedure. The authorized
|
||||
supplied revision, physical p12/logical p10, retains the winner's later casualty
|
||||
choice and the Emperor dial3/support1 example with three-or-five physical
|
||||
losses; original E1 pp9–10 supplies Cyborg/Suboid strength and later substitution.
|
||||
Neither establishes simultaneous online allocation timing. Under the 14 October
|
||||
choose-recommended instruction, the following is an explicit provisional
|
||||
procedure, not a retrieved publisher ruling or new numerical rule.
|
||||
|
||||
After original reveal modes, actual Harass returns and decisive Traitor/explosion
|
||||
branches, the shared calculator identifies whether legal physical totals can
|
||||
change the winner. Only then does each ambiguous side privately commit its
|
||||
**undialed physical total**. Singleton totals are automatic. Storm order queues
|
||||
the original physical owners, including Occupy's variable ally. Every originally
|
||||
required side must still commit even when the first private value would already
|
||||
fix the winner. Other seats receive no selected value and the public log names
|
||||
no count until all commitments are complete. The final comparison reveals them
|
||||
together. Ordinary and invariant-winner battles receive no new prompt.
|
||||
|
||||
This commits a physical count rather than a full ordinary/elite casualty tuple.
|
||||
The eventual winner retains every original legal typed casualty choice with
|
||||
that count. For example, genuine Advanced4N2E/dial3/support1 retains both2N1E
|
||||
and1N2E losses at physical3. Later native Cyborg substitution remains later.
|
||||
Support, fees, Harass complements, fixed Ecaz losses, temporary normal roles,
|
||||
No-Field reconstruction and native World pools remain calculator inputs.
|
||||
Diplomat retreat must leave at least one compatible typed loss; Jacurutu uses
|
||||
a compatible tuple with the committed scalar. Card cleanup, native Homeworld
|
||||
losses, Suk and temporary Sardaukar receipts carry the same physical-count
|
||||
constraint rather than reopening the original unfiltered loss menu.
|
||||
|
||||
New `chooseBattle` frames receive additive `stoneAllocationVersion:1`. Legacy
|
||||
unstamped battles retain their conservative public-only guard and no saved
|
||||
battle is converted. Precommit validation still checks legal physical pools,
|
||||
dial/support and zero-spice compulsion without inspecting hidden opposing plans.
|
||||
The private owned control stays visible until a legal commitment. All four bot
|
||||
profiles select a legal own offered value; strategy/calibration remains deferred.
|
||||
|
||||
Three disjoint helper/fixture/source-review lanes plus owner integration pass
|
||||
frozen **16 files146/146**, types/lint/build and **14** finite original-action/
|
||||
actual-GameTable rendering programmes. Basic paid Stone reaches its offer in69
|
||||
actions; Advanced Sardaukar52; actual paid Stone plus next-turn retained Mirror90.
|
||||
Both native mixed sides use95 source actions in either original seating order;
|
||||
both extreme outcomes complete after7 actions, and either same-count typed loss
|
||||
after8. Ordinary/invariant mixed Ix uses68/69 source actions and retains its
|
||||
original later choice. Four minimal policies make both private choices legally
|
||||
(eight proposals), with no tuning. Focused pure cases cover fixed Occupy,
|
||||
Harass, temporary roles, reconstructed Richese/World pools and decisive outcomes;
|
||||
related suffix/default regressions are not complete native-combination proof.
|
||||
|
||||
Actual Mirror feedback found that mandatory disposal rejected a correctly
|
||||
narrowed casualty menu. The physical constraint now survives that continuation
|
||||
and its other native consumers. Iteration also corrected city-income expectations,
|
||||
closed-battle projection access and the semantic disabled-button matcher.
|
||||
Historical guard tests now explicitly exercise unstamped frames alongside new
|
||||
admission. Type feedback added the new decision to both exhaustive registries.
|
||||
No printed number was changed to fit a fixture.
|
||||
|
||||
Proof base `677ad7aa3ca9833feb6edad82b82487debf340bb`, frozen tree
|
||||
`dd7eb5392d6c59a1b7c99a22c439daa48dc1ee7bd84eed62118c0aae91e6e73d`;
|
||||
private reports/HTML are under
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261018-stone-physical-allocation/`.
|
||||
Framework aliases/CSS stubs mean no visual/browser/deployed acceptance. Wider
|
||||
optional combinations, comprehensive assurance and complete-mode gates remain
|
||||
open. No saved room/database mutation, reset, restart or deployment. The older
|
||||
guarded allocation descriptions below remain dated historical evidence.
|
||||
|
||||
## Stone Burner, complete ordinary portraits and recovery checkpoint (2026-09-06)
|
||||
|
||||
- Canonical Stone Burner now uses the weapon slot without changing its saved `kind: special` identity. It has a named Voice role, Prescience/Truth-compatible original commitments, a private precommit availability check, and an event-bound owner choice after both plans reveal. Kill mode kills both participating leader discs; ignore mode preserves other weapon attacks. Both replace ordinary strength with undialed physical-token comparison, with the aggressor winning ties. Neither adds the Kwisatz Haderach bonus or kills that companion. Normal support, bounty, traitor/explosion precedence, casualty choices and winner/loser/Moritani retention remain shared with the existing battle flow.
|
||||
|
||||
@@ -76,6 +76,7 @@ const decisions = {
|
||||
techToken: true,
|
||||
poisonTooth: true,
|
||||
stoneBurner: true,
|
||||
stoneAllocation: true,
|
||||
sukRescue: true,
|
||||
rihani: true,
|
||||
fullPlanOffer: true,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { casualtyOptions, type CombatForces } from './combat';
|
||||
|
||||
/** Printed physical commitments survive later card disposal and native aftercare. */
|
||||
export type BattleCasualtyConstraint = {
|
||||
fullyCommitted?: true;
|
||||
physicalLossCount?: number;
|
||||
};
|
||||
|
||||
export function casualtyConstraintFields(constraint: BattleCasualtyConstraint) {
|
||||
return {
|
||||
...(constraint.fullyCommitted ? {fullyCommitted:true as const} : {}),
|
||||
...(constraint.physicalLossCount !== undefined ? {physicalLossCount:constraint.physicalLossCount} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function committedCasualtyOptions(forces: CombatForces, dial: number, support: number,
|
||||
constraint: BattleCasualtyConstraint = {}) {
|
||||
const {fullyCommitted, physicalLossCount} = constraint;
|
||||
if ((fullyCommitted !== undefined && fullyCommitted !== true) ||
|
||||
(physicalLossCount !== undefined && (!Number.isSafeInteger(physicalLossCount) ||
|
||||
physicalLossCount < 0 || physicalLossCount > forces.normal + forces.elite))) return [];
|
||||
return casualtyOptions(forces, dial, support).filter(option =>
|
||||
(!fullyCommitted || option.normal === forces.normal && option.elite === forces.elite) &&
|
||||
(physicalLossCount === undefined || option.normal + option.elite === physicalLossCount));
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
stoneBurnerComparison,
|
||||
stoneBurnerWithdrawalContext,
|
||||
stoneBurnerUndialedTotal,
|
||||
type StoneBurnerContext,
|
||||
type StoneBurnerComparison,
|
||||
} from './stone-burner';
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
} from './leader-skill-combat';
|
||||
import { quoteEcazOccupyOutcome, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
|
||||
import { ecazSpecialKaramaBonus, ecazSpecialKaramaPlanAllowed } from './ecaz-special-karama';
|
||||
import type { StoneBurnerAllocationChoice, StoneBurnerAllocationLoss } from './stone-burner-allocation';
|
||||
|
||||
export class BattleResolutionQuoteError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -63,6 +65,20 @@ export class BattleResolutionQuoteError extends Error {
|
||||
this.name = 'BattleResolutionQuoteError';
|
||||
}
|
||||
}
|
||||
/** Pure prerequisite only: no combat resources have been applied. The engine
|
||||
* owns the private simultaneous commitment protocol and its battle stamp. */
|
||||
export class StoneBurnerAllocationRequiredError extends BattleResolutionQuoteError {
|
||||
readonly choices: readonly StoneBurnerAllocationChoice[];
|
||||
readonly losses: Readonly<Record<string, readonly StoneBurnerAllocationLoss[]>>;
|
||||
constructor(choices: readonly StoneBurnerAllocationChoice[],
|
||||
losses: Readonly<Record<string, readonly StoneBurnerAllocationLoss[]>> = {}) {
|
||||
super('Stone Burner requires private undialed physical totals before determining this battle winner.');
|
||||
this.name = 'StoneBurnerAllocationRequiredError';
|
||||
this.choices = choices.map(choice => ({ ...choice, totals: [...choice.totals] }));
|
||||
this.losses = Object.fromEntries(Object.entries(losses).map(([player, options]) => [player,
|
||||
options.map(option => ({ undialed: option.undialed, losses: { ...option.losses } }))]));
|
||||
}
|
||||
}
|
||||
export type ResolutionPlan = {
|
||||
bankerSpice?: number;
|
||||
planetologistSpecial?: true;
|
||||
@@ -113,6 +129,9 @@ export type ResolutionCombatant = ResolutionParticipant & {
|
||||
ecazSpecialKarama?: true;
|
||||
};
|
||||
export type BattleResolutionInput = {
|
||||
/** New battle protocol: physical undialed totals, keyed by the stable side
|
||||
* player, without preselecting the winner's later typed casualty tuple. */
|
||||
stoneAllocations?: Readonly<Record<string, number>>;
|
||||
ecazOccupy?: EcazOccupyBattleProfile;
|
||||
/** Stable physical slots remain unchanged when Sapho changes aggressor. */
|
||||
aggressor?: string;
|
||||
@@ -187,6 +206,9 @@ export type BattleResolutionQuote = {
|
||||
sandmaster?: string;
|
||||
casualties: {
|
||||
owner?: string;
|
||||
/** A selected Stone total constrains the later variable physical losses,
|
||||
* after any fixed Ecaz contribution and actual withdrawal. */
|
||||
physicalLossCount?: number;
|
||||
/** The returned Harass complement fixes all remaining physical fighters. */
|
||||
fullyCommitted?: true;
|
||||
forces: CombatForces;
|
||||
@@ -212,6 +234,25 @@ export type BattleResolutionQuote = {
|
||||
function requireQuote(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new BattleResolutionQuoteError(message);
|
||||
}
|
||||
/** Shared pure validation accepts partial commitments while gathering choices.
|
||||
* The resolution itself requires every originally ambiguous side to commit. */
|
||||
export function validateStoneBurnerAllocationSelections(
|
||||
choices: readonly StoneBurnerAllocationChoice[],
|
||||
selections: Readonly<Record<string, number>> | undefined,
|
||||
complete = false,
|
||||
): Readonly<Record<string, number>> {
|
||||
requireQuote(selections === undefined || (!!selections && typeof selections === 'object' &&
|
||||
!Array.isArray(selections) && [Object.prototype, null].includes(Object.getPrototypeOf(selections))),
|
||||
'Stone Burner allocations must identify original side players and legal physical totals.');
|
||||
const selected = selections ?? {};
|
||||
requireQuote(Object.entries(selected).every(([player, total]) => {
|
||||
const choice = choices.find(choice => choice.player === player);
|
||||
return choice && Number.isSafeInteger(total) && choice.totals.includes(total);
|
||||
}), 'Stone Burner allocations must identify original side players and legal physical totals.');
|
||||
if (complete && !choices.every(choice => Object.hasOwn(selected, choice.player)))
|
||||
throw new StoneBurnerAllocationRequiredError(choices);
|
||||
return selected;
|
||||
}
|
||||
const id = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
const whole = (value: number) => Number.isSafeInteger(value) && value >= 0;
|
||||
@@ -525,7 +566,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
stoneContext = stoneBurnerWithdrawalContext(
|
||||
stoneContext, side === a ? 'attacker' : 'defender', side.harassWithdraw?.occupy,
|
||||
);
|
||||
const stone = hasStone
|
||||
let stone = hasStone
|
||||
? stoneBurnerComparison(
|
||||
a.forces,
|
||||
a.plan.dial,
|
||||
@@ -537,10 +578,37 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
stoneContext,
|
||||
)
|
||||
: null;
|
||||
const explosion = battleWeaponsExplode(aw, ad, dw, dd);
|
||||
const allocationChoices: StoneBurnerAllocationChoice[] = stone && !stone.winner && !ac && !dc && !explosion
|
||||
? ([['attacker', a], ['defender', d]] as const).flatMap(([slot, side]) => stone![slot].length > 1 ? [{
|
||||
player: side.id,
|
||||
owner: input.ecazOccupy && !input.ecazOccupy.canceled && input.ecazOccupy.lead === side.id
|
||||
? input.ecazOccupy.forceOwner : side.id,
|
||||
totals: [...stone![slot]],
|
||||
}] : []) : [];
|
||||
const stoneAllocationLosses = Object.fromEntries(allocationChoices.map(choice => {
|
||||
const side = choice.player === a.id ? a : d, context = stoneContext?.[side === a ? 'attacker' : 'defender'];
|
||||
const withdrawal = harassWithdraw.find(withdrawal => withdrawal.player === choice.owner);
|
||||
const options = casualtyOptions(side.forces, side.plan.dial - (context?.fixedEcazDial ?? 0), side.plan.support)
|
||||
.filter(loss => !withdrawal || loss.normal === withdrawal.remaining.normal && loss.elite === withdrawal.remaining.elite)
|
||||
.map(losses => ({ undialed: stoneBurnerUndialedTotal(side.forces, losses, context), losses }));
|
||||
return [choice.player, options];
|
||||
}));
|
||||
const stoneAllocations = validateStoneBurnerAllocationSelections(allocationChoices, input.stoneAllocations);
|
||||
if (!allocationChoices.every(choice => Object.hasOwn(stoneAllocations, choice.player)))
|
||||
throw new StoneBurnerAllocationRequiredError(allocationChoices, stoneAllocationLosses);
|
||||
if (stone && allocationChoices.length) {
|
||||
const attacker = Object.hasOwn(stoneAllocations, a.id) ? [stoneAllocations[a.id]] : stone.attacker;
|
||||
const defender = Object.hasOwn(stoneAllocations, d.id) ? [stoneAllocations[d.id]] : stone.defender;
|
||||
requireQuote(attacker.length === 1 && defender.length === 1,
|
||||
'Complete both original Stone Burner physical allocations before resolving battle.');
|
||||
stone = { attacker, defender, winner: attacker[0] > defender[0] ||
|
||||
attacker[0] === defender[0] && tie === 'attacker' ? 'attacker' : 'defender' };
|
||||
}
|
||||
if (stone) {
|
||||
requireQuote(
|
||||
stone.attacker.length > 0 && stone.defender.length > 0 &&
|
||||
(!!stone.winner || ac || dc),
|
||||
(!!stone.winner || ac || dc || explosion),
|
||||
'Stone Burner has an unresolved physical casualty allocation. No battle resources were spent.',
|
||||
);
|
||||
for (const [side, weapon] of [
|
||||
@@ -614,7 +682,6 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
const explosion = battleWeaponsExplode(aw, ad, dw, dd);
|
||||
const result =
|
||||
ac && dc
|
||||
? 'mutualTraitors'
|
||||
@@ -822,6 +889,15 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (winner && result === 'normal' && casualties && Object.hasOwn(stoneAllocations, winner.id)) {
|
||||
const options = casualties.options.filter(option => stoneBurnerUndialedTotal(winner.forces, option,
|
||||
stoneContext?.[winner === a ? 'attacker' : 'defender']) === stoneAllocations[winner.id]);
|
||||
requireQuote(options.length > 0, 'The Stone Burner winner must retain a typed casualty allocation matching its committed physical total.');
|
||||
const physicalLossCount = options[0].normal + options[0].elite;
|
||||
requireQuote(options.every(option => option.normal + option.elite === physicalLossCount),
|
||||
'The selected Stone Burner total must retain one variable physical loss count.');
|
||||
casualties = { ...casualties, options, physicalLossCount };
|
||||
}
|
||||
const strongholdIncome =
|
||||
result === 'mutualTraitors'
|
||||
? []
|
||||
|
||||
@@ -1008,6 +1008,7 @@ function plans(g: GameView): Action[] {
|
||||
b.attacker === me.id ? 'attacker' : 'defender',
|
||||
b.tieWinner === b.attacker ? 'attacker' : 'defender',
|
||||
stoneContext,
|
||||
b.stoneBurnerContext?.allowAllocations,
|
||||
),
|
||||
))
|
||||
)
|
||||
@@ -2429,6 +2430,12 @@ function policyActions(g: GameView): Action[] {
|
||||
)[0];
|
||||
return [{ type: 'decision', token }];
|
||||
}
|
||||
if (d.kind === 'stoneAllocation') {
|
||||
const offer = g.battle?.stoneAllocation;
|
||||
const undialed = offer?.totals.at(-1);
|
||||
return offer && offer.owner === me.id && offer.event === d.event && undialed !== undefined
|
||||
? [{type:'decision',event:d.event,undialed}] : [];
|
||||
}
|
||||
if (d.kind === 'stoneBurner') {
|
||||
const b = g.battle!,
|
||||
own = b.plans[me.id];
|
||||
|
||||
+161
-29
@@ -334,6 +334,7 @@ import {
|
||||
BattleResolutionQuoteError,
|
||||
type ResolutionCombatant,
|
||||
type BattleResolutionQuote,
|
||||
type BattleResolutionInput,
|
||||
} from './battle-resolution-quote';
|
||||
import {
|
||||
quoteRevivalCancellation,
|
||||
@@ -381,6 +382,8 @@ import {
|
||||
sampleAuditCards,
|
||||
} from './choam-auditor';
|
||||
import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext, type StoneBurnerContext } from './stone-burner';
|
||||
import { quoteStoneBurnerAllocations, stoneBurnerCommittedLosses, type StoneBurnerAllocationChoice } from './stone-burner-allocation';
|
||||
import { casualtyConstraintFields, committedCasualtyOptions } from './battle-casualty-commitment';
|
||||
import {
|
||||
STRONGHOLD_CARDS,
|
||||
createStrongholdCards,
|
||||
@@ -768,6 +771,14 @@ export type Battle = {
|
||||
truthPromises?: BattlePromise[];
|
||||
poisonTooth?: Record<string, boolean>;
|
||||
stoneBurner?: Record<string, 'kill' | 'ignore'>;
|
||||
/** New ambiguous Stone battles commit physical totals without changing ordinary casualty timing. */
|
||||
stoneAllocationVersion?: 1;
|
||||
stoneAllocation?: {
|
||||
event: string; battle: string; turn: number; context: string;
|
||||
choices: readonly StoneBurnerAllocationChoice[];
|
||||
selections: Record<string, number>;
|
||||
complete?: true;
|
||||
};
|
||||
/** Supplemental revealed defense; never rewrites the original sealed plan. */
|
||||
lateDefense?: Record<string, string>;
|
||||
fullPlan?: { owner: string; target: string };
|
||||
@@ -916,6 +927,7 @@ export type Decision =
|
||||
| { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
|
||||
| { kind: 'poisonTooth'; player: string; event?: string; physicalId?: string; copiedFrom?: string | null }
|
||||
| { kind: 'stoneBurner'; player: string; event: string; physicalId?: string; copiedFrom?: string | null }
|
||||
| { kind: 'stoneAllocation'; player: string; event: string }
|
||||
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[]; reserveHomes?: { id: string; name: string; secondary: boolean }[] }
|
||||
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
|
||||
| { kind: 'fullPlanOffer'; player: string }
|
||||
@@ -1565,6 +1577,7 @@ export type Game = {
|
||||
owner?: string;
|
||||
/** Harass has already returned the complement of this physical pool. */
|
||||
fullyCommitted?: true;
|
||||
physicalLossCount?: number;
|
||||
forces: CombatForces;
|
||||
dial: number;
|
||||
support: number;
|
||||
@@ -2004,7 +2017,7 @@ export type Game = {
|
||||
nexusAdvisorLast?: { event: string; stage: 'pending' | 'completed' | 'canceled' };
|
||||
nexusSardaukarHistory?: {receipt: NexusSardaukarReceipt; stage: 'pending' | 'active' | 'canceled'; parent: string; signature: string;
|
||||
casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete';
|
||||
reserveFee?: {normal: number; elite: number}}}[];
|
||||
reserveFee?: {normal: number; elite: number}; fullyCommitted?: true; physicalLossCount?: number}}[];
|
||||
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
|
||||
nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[];
|
||||
nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[];
|
||||
@@ -2214,7 +2227,7 @@ export type Game = {
|
||||
options: HomeworldForces[];
|
||||
/** Original printed face; reserve-card fees cannot reprice this explosion. */
|
||||
faceAtResolution?: 'high' | 'low';
|
||||
commitment?: { forces: CombatForces; dial: number; support: number; fullyCommitted?: true };
|
||||
commitment?: { forces: CombatForces; dial: number; support: number; fullyCommitted?: true; physicalLossCount?: number };
|
||||
} | null;
|
||||
lastBattleContext?: {
|
||||
event: string;
|
||||
@@ -3817,11 +3830,14 @@ function nexusSardaukarIntegrity(g: Game) {
|
||||
JSON.stringify(reinforcementsBattleForces(original,fee)) === JSON.stringify(losses.forces),
|
||||
'The reduced temporary Sardaukar need their original own-World reserve fee.');
|
||||
}
|
||||
const constraints = casualtyConstraintFields(losses);
|
||||
const commitment = {forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options,...constraints};
|
||||
requireRule(record.stage === 'active' && ['pending','complete'].includes(losses.outcome) &&
|
||||
Object.keys(losses).sort().join(',') === (fee ? 'dial,forces,options,outcome,reserveFee,support' : 'dial,forces,options,outcome,support') &&
|
||||
Object.keys(losses).sort().join(',') ===
|
||||
['dial','forces','options','outcome','support',...(fee ? ['reserveFee'] : []),...Object.keys(constraints)].sort().join(',') &&
|
||||
validCombatForces(losses.forces) && (fee ? Number(losses.forces.temporaryElite) >= 2 : losses.forces.temporaryElite === 5) &&
|
||||
Array.isArray(losses.options) && losses.options.length > 0 &&
|
||||
JSON.stringify(losses.options) === JSON.stringify(casualtyOptions(losses.forces,losses.dial,losses.support)),
|
||||
JSON.stringify(losses.options) === JSON.stringify(committedCasualtyOptions(losses.forces,losses.dial,losses.support,losses)),
|
||||
'The Nexus Sardaukar casualties have lost their committed force allocation.');
|
||||
if (losses.outcome === 'pending') {
|
||||
const context = g.lastBattleContext;
|
||||
@@ -3838,16 +3854,16 @@ function nexusSardaukarIntegrity(g: Game) {
|
||||
requireRule(decisions.length > 0 || committed || rescue?.losses, 'The Nexus Sardaukar battle has lost its casualty choice.');
|
||||
if (rescue) requireRule(rescue.player === record.receipt.owner &&
|
||||
rescue.turn === record.receipt.turn && rescue.territory === record.receipt.territory &&
|
||||
JSON.stringify(rescue.commitment) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}) &&
|
||||
JSON.stringify(rescue.commitment) === JSON.stringify(commitment) &&
|
||||
(!rescue.losses || losses.options.some(option => JSON.stringify(option) === JSON.stringify(rescue.losses))),
|
||||
'The Suk rescue differs from the committed Nexus Sardaukar casualties.');
|
||||
for (const decision of decisions) requireRule(decision.kind === 'battleLosses' &&
|
||||
decision.territory === record.receipt.territory && JSON.stringify(decision.options) === JSON.stringify(losses.options),
|
||||
'The casualty choices differ from the committed Nexus Sardaukar plan.');
|
||||
if (committed) requireRule(JSON.stringify(committed) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}),
|
||||
if (committed) requireRule(JSON.stringify(committed) === JSON.stringify(commitment),
|
||||
'The suspended Sardaukar casualty allocation was changed.');
|
||||
if (g.homeworldBattleLoss) requireRule(JSON.stringify(g.homeworldBattleLoss.commitment) ===
|
||||
JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support}),
|
||||
JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,...constraints}),
|
||||
'The Homeworld Sardaukar casualties have lost their effective combat roles.');
|
||||
}
|
||||
}
|
||||
@@ -7308,7 +7324,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'The mandatory battle discard cause is inconsistent.',
|
||||
);
|
||||
if (c.casualties) {
|
||||
const { forces, dial, support, options, fullyCommitted } = c.casualties;
|
||||
const { forces, dial, support, options, fullyCommitted, physicalLossCount } = c.casualties;
|
||||
const winner = g.players.find((p) => p.id === c.winner);
|
||||
const forceOwner = g.players.find((p) => p.id === (c.casualties!.owner ?? c.winner));
|
||||
const pool = forceOwner ? combatArmy(g, forceOwner.id, c.territory) : { normal: 0, elite: 0 };
|
||||
@@ -7320,6 +7336,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
winner.ally === forceOwner.id &&
|
||||
(winner.faction === 'ecaz' || forceOwner.faction === 'ecaz'))) &&
|
||||
(fullyCommitted === undefined || fullyCommitted === true) &&
|
||||
(physicalLossCount === undefined || (Number.isSafeInteger(physicalLossCount) &&
|
||||
physicalLossCount >= 0 && physicalLossCount <= total)) &&
|
||||
forces &&
|
||||
validCombatForces(forces) &&
|
||||
Number.isSafeInteger(forces.normal) &&
|
||||
@@ -7337,10 +7355,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
Array.isArray(options) &&
|
||||
options.length > 0 &&
|
||||
JSON.stringify(options) ===
|
||||
JSON.stringify(fullyCommitted
|
||||
? casualtyOptions(forces, dial, support).filter(option =>
|
||||
option.normal === forces.normal && option.elite === forces.elite)
|
||||
: casualtyOptions(forces, dial, support)),
|
||||
JSON.stringify(committedCasualtyOptions(forces, dial, support, {fullyCommitted, physicalLossCount})),
|
||||
'The saved battle casualties do not match the committed dial, support and effective forces.',
|
||||
);
|
||||
}
|
||||
@@ -8263,11 +8278,11 @@ function homeworldBattleLossIntegrity(g: Game) {
|
||||
} else {
|
||||
const commitment = pending.commitment;
|
||||
requireRule(commitment && commitment.forces.normal === pool.normal && commitment.forces.elite === pool.elite &&
|
||||
(commitment.fullyCommitted === undefined || commitment.fullyCommitted === true),
|
||||
(commitment.fullyCommitted === undefined || commitment.fullyCommitted === true) &&
|
||||
(commitment.physicalLossCount === undefined || (Number.isSafeInteger(commitment.physicalLossCount) &&
|
||||
commitment.physicalLossCount >= 0 && commitment.physicalLossCount <= pool.normal + pool.elite)),
|
||||
'The Homeworld winner needs its committed physical forces.');
|
||||
options = casualtyOptions(commitment.forces, commitment.dial, commitment.support)
|
||||
.filter(choice => !commitment.fullyCommitted ||
|
||||
choice.normal === commitment.forces.normal && choice.elite === commitment.forces.elite)
|
||||
options = committedCasualtyOptions(commitment.forces, commitment.dial, commitment.support, commitment)
|
||||
.map(({normal, elite}) => ({normal, elite}));
|
||||
}
|
||||
requireRule(options.length > 0 && JSON.stringify(options) === JSON.stringify(pending.options),
|
||||
@@ -14506,6 +14521,7 @@ function validateKaramaUse(
|
||||
'The canceled allied traitor call needs its original revealed battle.',
|
||||
);
|
||||
if (traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined))
|
||||
if (g.battle!.stoneAllocationVersion !== 1 || !currentStoneAllocationQuote(g, owner.id))
|
||||
currentBattleResolutionQuote(g, owner.id);
|
||||
}
|
||||
if (use.response.kind === 'moritaniRetention')
|
||||
@@ -21450,6 +21466,7 @@ function stonePlanBlock(
|
||||
b.attacker === p.id ? 'attacker' : 'defender',
|
||||
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
|
||||
context,
|
||||
b.stoneAllocationVersion === 1,
|
||||
),
|
||||
)
|
||||
.find(Boolean) ??
|
||||
@@ -21484,6 +21501,7 @@ function stoneCompulsionBlock(g: Game, target: Player): string | null {
|
||||
b.attacker === target.id ? 'attacker' : 'defender',
|
||||
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
|
||||
context,
|
||||
b.stoneAllocationVersion === 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -22923,6 +22941,7 @@ function revealedWeaponAnswered(
|
||||
function battleSpecialWeaponIntegrity(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b) return;
|
||||
stoneAllocationIntegrity(g);
|
||||
for (const id of [b.attacker, b.defender]) {
|
||||
const cardId = b.plans[id]?.weapon;
|
||||
if (cardId !== 'richese-stone-burner' && cardId !== 'richese-mirror-weapon') continue;
|
||||
@@ -23255,7 +23274,7 @@ function smugglerBattleIntegrity(g: Game) {
|
||||
'The completed Smuggler collection changed its original battle receipt.');
|
||||
}
|
||||
/** Prepare current server-only inputs once; the pure module cannot inspect Game or mutate it. */
|
||||
function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
function currentBattleResolutionInput(g: Game, canceledVoter?: string): BattleResolutionInput {
|
||||
const b = g.battle!;
|
||||
const combatant = (id: string, opponentId: string): ResolutionCombatant => {
|
||||
const p = getPlayer(g, id),
|
||||
@@ -23293,7 +23312,6 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
...(aid ? { aid: { donor: p.ally!, amount: aid.amount } } : {}),
|
||||
};
|
||||
};
|
||||
try {
|
||||
const attacker = combatant(b.attacker, b.defender);
|
||||
const defender = combatant(b.defender, b.attacker);
|
||||
const homeworld = currentHomeworldBattleRules(g, b.territory);
|
||||
@@ -23302,7 +23320,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
homeworld.native === defender.id ? defender : null;
|
||||
if (native) homeworld.nativeForces = { normal: native.forces.normal, elite: native.forces.elite };
|
||||
}
|
||||
const quote = quoteBattleResolution({
|
||||
return {
|
||||
advanced: g.advanced,
|
||||
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
|
||||
aggressor: battleAggressor(g),
|
||||
@@ -23329,7 +23347,15 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
pendingAuditorPresent: !!g.pendingAuditor,
|
||||
pendingRetentionPresent: !!g.moritaniRetention,
|
||||
nexusMoritani: !!g.nexusCards?.cards && !byFaction(g, 'moritani'),
|
||||
});
|
||||
...(b.stoneAllocation ? { stoneAllocations: b.stoneAllocation.selections } : {}),
|
||||
};
|
||||
}
|
||||
function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
const b = g.battle!;
|
||||
try {
|
||||
const input = currentBattleResolutionInput(g, canceledVoter);
|
||||
const homeworld = input.homeworld;
|
||||
const quote = quoteBattleResolution(input);
|
||||
for (const withdrawal of quote.harassWithdraw ?? [])
|
||||
validateReserveReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
|
||||
if (quote.sukGraduate) requireRule(
|
||||
@@ -23415,6 +23441,8 @@ function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) {
|
||||
const forces = combatForces(g, p, b.territory, opponent);
|
||||
const occupy = ecazBattleProfile(g);
|
||||
const combined = occupy?.lead === player && !occupy.canceled;
|
||||
const committedStoneLosses = b.stoneAllocation?.complete && !(combined && player === occupy.ecaz)
|
||||
? stoneBurnerCommittedLosses(currentBattleResolutionInput(g), player) : null;
|
||||
const dial = combined
|
||||
? player === occupy.ecaz ? occupy.fixedEcazDial : plan.dial - occupy.fixedEcazDial
|
||||
: plan.dial;
|
||||
@@ -23436,7 +23464,10 @@ function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) {
|
||||
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, dial, support, strength, available) };
|
||||
const choices = diplomatRetreatChoices(forces, dial, support, strength, available)
|
||||
.filter(choice => !committedStoneLosses || committedStoneLosses.some(loss =>
|
||||
loss.normal + choice.normal <= forces.normal && loss.elite + choice.elite <= forces.elite));
|
||||
return { location: key, choices };
|
||||
})
|
||||
.filter(option => option.choices.length);
|
||||
return destinations.length ? { player, destinations } : null;
|
||||
@@ -23465,8 +23496,76 @@ function diplomatRetreatIntegrity(g: Game) {
|
||||
JSON.stringify(decision.destinations) === JSON.stringify(receipt.destinations),
|
||||
'The saved Diplomat retreat changed its battle, forces, outcome or legal destinations.');
|
||||
}
|
||||
function currentStoneAllocationQuote(g: Game, canceledVoter?: string) {
|
||||
try {
|
||||
return quoteStoneBurnerAllocations(currentBattleResolutionInput(g, canceledVoter));
|
||||
} catch (error) {
|
||||
if (error instanceof BattleResolutionQuoteError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Only the revealed physical commitment is frozen; unrelated hands and wallets are not. */
|
||||
function stoneAllocationContext(g: Game) {
|
||||
const b = g.battle!, input = currentBattleResolutionInput(g);
|
||||
const physical = (side: ResolutionCombatant) => ({
|
||||
player: side.id, faction: side.faction, forces: side.forces, plan: side.plan,
|
||||
stoneMode: side.stoneMode, harass: side.harassWithdraw, harassSelection: side.harassSelection,
|
||||
});
|
||||
return JSON.stringify({ battle: b.event, turn: g.turn, territory: b.territory,
|
||||
attacker: physical(input.attacker), defender: physical(input.defender),
|
||||
occupy: input.ecazOccupy, aggressor: input.aggressor,
|
||||
traitors: b.traitorCalls, weaponModes: b.poisonTooth });
|
||||
}
|
||||
function stoneAllocationIntegrity(g: Game) {
|
||||
const b = g.battle, frame = b?.stoneAllocation;
|
||||
if (!frame) {
|
||||
requireRule(g.decision?.kind !== 'stoneAllocation', 'The physical-count choice lost its Stone battle.');
|
||||
return;
|
||||
}
|
||||
requireRule(b && b.stoneAllocationVersion === 1 && g.status === 'playing' && g.phase === 6 &&
|
||||
b.revealed && b.event === frame.battle && frame.turn === g.turn && frame.event &&
|
||||
frame.context === stoneAllocationContext(g),
|
||||
'The Stone physical-count commitment changed its original revealed battle.');
|
||||
const offer = currentStoneAllocationQuote(g);
|
||||
requireRule(offer && frame.choices.length === offer.choices.length &&
|
||||
frame.choices.every(choice => offer.choices.some(candidate => JSON.stringify(candidate) === JSON.stringify(choice))) &&
|
||||
Object.entries(frame.selections).every(([player, total]) =>
|
||||
frame.choices.some(choice => choice.player === player && choice.totals.includes(total))),
|
||||
'The Stone physical-count choices no longer match their original legal pools.');
|
||||
const pending = frame.choices.find(choice => !Object.hasOwn(frame.selections, choice.player));
|
||||
requireRule(!!frame.complete === !pending,
|
||||
'Finish every private Stone count before exposing the comparison.');
|
||||
if (pending) requireRule(homeworldSavedDecisions(g).some(decision => decision.kind === 'stoneAllocation' &&
|
||||
decision.event === frame.event && decision.player === pending.owner),
|
||||
'The unfinished Stone count lost its owned decision.');
|
||||
}
|
||||
function offerStoneAllocation(g: Game): boolean {
|
||||
const b = g.battle!;
|
||||
if (b.stoneAllocationVersion !== 1 || !revealedWeaponChoices(g).some(choice => choice.kind === 'stoneBurner')) return false;
|
||||
const offer = currentStoneAllocationQuote(g);
|
||||
if (!offer) return false;
|
||||
b.stoneAllocation ??= {
|
||||
event: crypto.randomUUID(), battle: b.event!, turn: g.turn,
|
||||
context: stoneAllocationContext(g), selections: {},
|
||||
choices: [...offer.choices].sort((a, d) => g.order.indexOf(a.owner) - g.order.indexOf(d.owner)),
|
||||
};
|
||||
const frame = b.stoneAllocation;
|
||||
const choice = frame.choices.find(candidate => !Object.hasOwn(frame.selections, candidate.player));
|
||||
if (choice) {
|
||||
g.decision = {kind: 'stoneAllocation', player: choice.owner, event: frame.event};
|
||||
return true;
|
||||
}
|
||||
if (!frame.complete) {
|
||||
frame.complete = true;
|
||||
log(g, `Stone Burner physical counts are committed: ${frame.choices.map(candidate =>
|
||||
`${getPlayer(g, candidate.player).name} ${frame.selections[candidate.player]} undialed`).join('; ')}. The winner's later typed losses must retain that physical count.`,
|
||||
{faction:getPlayer(g, b.attacker).faction,name:'Stone Burner physical counts'});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function advanceBattleOutcome(g: Game) {
|
||||
const b = g.battle!;
|
||||
if (offerStoneAllocation(g)) return;
|
||||
const quote = currentBattleResolutionQuote(g);
|
||||
if (quote.smugglerAllocation || quote.sandmasterSelection) {
|
||||
g.decision = quote.smugglerAllocation ?? quote.sandmasterSelection;
|
||||
@@ -23502,6 +23601,16 @@ function currentJacurutuIncome(g: Game, quote: ReturnType<typeof currentBattleRe
|
||||
? casualtyOptions(forces,plan.dial,plan.support).find(choice =>
|
||||
choice.normal === withdrawal.remaining.normal && choice.elite === withdrawal.remaining.elite)
|
||||
: undefined;
|
||||
const sentToTanks = forces ? {
|
||||
normal: forces.normal - (withdrawal?.returned.normal ?? 0) - (retreat?.normal ?? 0),
|
||||
elite: forces.elite - (withdrawal?.returned.elite ?? 0) - (retreat?.elite ?? 0),
|
||||
} : null;
|
||||
const committedStoneOptions = opponent && b.stoneAllocation?.complete
|
||||
? stoneBurnerCommittedLosses(currentBattleResolutionInput(g), opponent.id) : null;
|
||||
const committedStoneDial = committedStoneOptions?.find(option => sentToTanks &&
|
||||
option.normal <= sentToTanks.normal && option.elite <= sentToTanks.elite);
|
||||
requireRule(!committedStoneOptions || committedStoneDial,
|
||||
'The Jacurutu loss and retreat must retain the committed Stone physical count.');
|
||||
requireRule(!withdrawal || withdrawalDial, 'Harass & Withdraw must retain its original physical dial.');
|
||||
return nexusRule(() => quoteJacurutuBattleIncome({
|
||||
territory: b.territory,
|
||||
@@ -23511,11 +23620,8 @@ function currentJacurutuIncome(g: Game, quote: ReturnType<typeof currentBattleRe
|
||||
opponentForces: forces,
|
||||
opponentDial: plan?.dial ?? null,
|
||||
opponentSupport: plan?.support ?? null,
|
||||
sentToTanks: forces ? {
|
||||
normal: forces.normal - (withdrawal?.returned.normal ?? 0) - (retreat?.normal ?? 0),
|
||||
elite: forces.elite - (withdrawal?.returned.elite ?? 0) - (retreat?.elite ?? 0),
|
||||
} : null,
|
||||
selectedDial: b.jacurutuDial?.selectedDial ?? withdrawalDial,
|
||||
sentToTanks,
|
||||
selectedDial: b.jacurutuDial?.selectedDial ?? withdrawalDial ?? committedStoneDial,
|
||||
}));
|
||||
}
|
||||
function jacurutuDialIntegrity(g: Game) {
|
||||
@@ -23997,7 +24103,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
pool: combatArmy(g, owner, b.territory),
|
||||
options: (quote.homeworldExplosion?.options ?? casualtyCommitment!.options).map(({normal, elite}) => ({normal, elite})),
|
||||
...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support,
|
||||
...(casualtyCommitment.fullyCommitted ? { fullyCommitted: true as const } : {}) } } : {}),
|
||||
...(casualtyCommitment.fullyCommitted ? { fullyCommitted: true as const } : {}),
|
||||
...(casualtyCommitment.physicalLossCount !== undefined ? { physicalLossCount: casualtyCommitment.physicalLossCount } : {}) } } : {}),
|
||||
};
|
||||
}
|
||||
if (discarded.length) {
|
||||
@@ -24242,7 +24349,8 @@ function sukRescueIntegrity(g: Game) {
|
||||
JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(g, player, pending.territory)) &&
|
||||
pending.commitment.forces.normal === pending.pool.reduce((sum, group) => sum + group.normal, 0) &&
|
||||
pending.commitment.forces.elite === pending.pool.reduce((sum, group) => sum + group.elite, 0) &&
|
||||
JSON.stringify(pending.commitment.options) === JSON.stringify(casualtyOptions(pending.commitment.forces, pending.commitment.dial, pending.commitment.support)) &&
|
||||
JSON.stringify(pending.commitment.options) === JSON.stringify(committedCasualtyOptions(
|
||||
pending.commitment.forces, pending.commitment.dial, pending.commitment.support, pending.commitment)) &&
|
||||
pending.commitment.options.length > 0 && pending.cards.every((id) => player.hand.some((c) => c.id === id)),
|
||||
'The saved Suk Graduate rescue no longer matches its battle, cards or physical forces.');
|
||||
if (pending.losses) {
|
||||
@@ -24274,7 +24382,8 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean, d
|
||||
record.receipt.battle === pending.event && record.receipt.owner === pending.player);
|
||||
if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' &&
|
||||
JSON.stringify(pending.commitment) === JSON.stringify({forces:sardaukar.casualties.forces,
|
||||
dial:sardaukar.casualties.dial,support:sardaukar.casualties.support,options:sardaukar.casualties.options}) &&
|
||||
dial:sardaukar.casualties.dial,support:sardaukar.casualties.support,options:sardaukar.casualties.options,
|
||||
...casualtyConstraintFields(sardaukar.casualties)}) &&
|
||||
sardaukar.casualties.options.some(choice => JSON.stringify(choice) === JSON.stringify(pending.losses)),
|
||||
'The Suk rescue lost its original Nexus Sardaukar casualty allocation.');
|
||||
let physical: SukPhysicalRescueQuote | undefined;
|
||||
@@ -31844,6 +31953,18 @@ function applyActionInner(
|
||||
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 === 'stoneAllocation') {
|
||||
const frame = g.battle?.stoneAllocation;
|
||||
const choice = frame?.choices.find(candidate => !Object.hasOwn(frame.selections, candidate.player));
|
||||
requireRule(frame && choice && choice.owner === id && !frame.complete &&
|
||||
decision.event === frame.event && action.event === frame.event &&
|
||||
Object.keys(action).every(key => ['type','event','undialed'].includes(key)) &&
|
||||
typeof action.undialed === 'number' && choice.totals.includes(action.undialed),
|
||||
'Commit one offered undialed physical count for the current revealed Stone battle.');
|
||||
frame.selections[choice.player] = action.undialed;
|
||||
log(g, `${p.name} committed a Stone Burner physical count. Counts remain sealed until every required side commits.`,
|
||||
{faction:p.faction,name:'Stone Burner commitment'});
|
||||
advanceBattleOutcome(g);
|
||||
} else if (decision.kind === 'jacurutuDial') {
|
||||
const frame = g.battle?.jacurutuDial;
|
||||
requireRule(frame && frame.owner === id && frame.event === decision.event && action.event === decision.event &&
|
||||
@@ -34149,6 +34270,7 @@ function applyActionInner(
|
||||
...choice,
|
||||
chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
|
||||
event: battleEvent,
|
||||
stoneAllocationVersion: 1,
|
||||
...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
|
||||
...(g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
|
||||
...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 3 as const } : {}),
|
||||
@@ -35687,9 +35809,19 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
poisonTooth: b.revealed ? (b.poisonTooth ?? {}) : {},
|
||||
stoneBurner: b.revealed ? (b.stoneBurner ?? {}) : {},
|
||||
stoneAllocation: b.stoneAllocation?.choices.some(choice => choice.owner === id)
|
||||
? (() => {
|
||||
const choice = b.stoneAllocation!.choices.find(candidate => candidate.owner === id &&
|
||||
!Object.hasOwn(b.stoneAllocation!.selections, candidate.player)) ??
|
||||
b.stoneAllocation!.choices.find(candidate => candidate.owner === id)!;
|
||||
return {event:b.stoneAllocation!.event,player:choice.player,owner:choice.owner,totals:[...choice.totals],
|
||||
selected:b.stoneAllocation!.selections[choice.player] ?? null};
|
||||
})()
|
||||
: null,
|
||||
stoneBurnerContext: [b.attacker, b.defender].includes(id)
|
||||
? {
|
||||
blocked: stoneTimingBlock(g),
|
||||
allowAllocations: b.stoneAllocationVersion === 1,
|
||||
occupy: stoneOccupyContext(g),
|
||||
opponentPools: stonePublicPools(
|
||||
g,
|
||||
|
||||
+16
-7
@@ -1130,8 +1130,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Compare the number of physical force tokens not dialed into battle. The aggressor wins a tie unless the actual held Habbanya Ridge Sietch advantage decides it. Dialed losses and spice support still follow their normal rules; elite strength is not an extra physical token.',
|
||||
'With Harass & Withdraw, this prototype provisionally compares undialed tokens remaining after successful withdrawal. An opposing successful Traitor cancels the return and decides the outcome instead. In Occupy, only the card user’s pool returns; original fixed Ecaz commitment and the other ally’s undialed counters remain. This timing is an explicit implementation interpretation, not a publisher ruling.',
|
||||
'In the existing native Advanced Occupy prototype, Reinforcements still pays its original own-reserve cost with same-plan, opposing or Mirror-copied Stone. Its +2 normal-score bonus adds no physical undialed tokens, dial, support or on-board casualties. Original mode choices, Traitor precedence and physical Stone/Mirror retention remain unchanged; other profile/module boundaries stay.',
|
||||
'The plan editor checks dial/support against every public opposing force possibility, never a hidden plan or No-Field value. Winner-changing physical allocations remain guarded. Clean selected CHOAM/Ix profiles now reuse the existing provisional original Stone-first, then uncopied Tooth order; Mirror copies still choose before their originals as printed. The earlier priority question remains unanswered.',
|
||||
'Clean selected Ix/CHOAM retains original Tech3+ and/or Advanced Strongholds2+, using genuine setup, delayed income and earned end-Mentat custody. Original47 and independent50 decks keep the separate Richese cache. Homeworlds, Nexus, Skills, Discoveries, active Sandtrout and mixed E3 with these phase modules stay guarded. Native Ecaz/Moritani combinations remain Advanced-only; other non-Ix behavior and public starts are unchanged.',
|
||||
'New battles with winner-changing physical allocations provisionally ask each ambiguous side to commit its undialed physical total after revealed modes and actual withdrawal. All required values stay sealed until everyone commits. The winner chooses compatible ordinary/elite losses afterward. Ordinary and invariant-winner battles keep their usual later casualty choice. Older battles retain their original public-only guard. No hidden opposing plan or No-Field value is inspected to decide precommit availability.',
|
||||
'Clean selected CHOAM/Ix profiles reuse the existing provisional original Stone-first, then uncopied Tooth order; Mirror copies still choose before their originals as printed. The earlier priority question remains unanswered. Physical-count commitment timing is also an explicit prototype procedure, rather than a publisher clarification.',
|
||||
'Selected Ix/CHOAM retains original Tech3+ and Advanced Strongholds2+, with genuine setup, delayed income and earned end-Mentat custody. Original47 and independent50 decks keep the separate Richese cache. Native optional-module and Ecaz/Moritani integrations retain their existing setup requirements and preview warnings; a physical-count commitment does not certify every combination.',
|
||||
'Traitor and Lasgun–shield explosion precedence remain intact. Ordinary bounty applies unless another effect suppresses it. Stone Burner uses the general winning battle-card retention rule: a winner may keep it, and a losing played card is discarded. Normal Moritani ally retention may apply.',
|
||||
'The post-reveal choice is bound to the current battle event and cannot be repeated after refresh. Review both revealed plans and select one mode; there is no separate victim or death confirmation.',
|
||||
],
|
||||
@@ -1146,32 +1147,40 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Canonical weapon admission, public pre-commit safety, persisted reveal choice, physical-token comparison, provisional post-Harass comparison and ordinary cleanup are integrated. Public fixed Ecaz withdrawal alternatives are checked without hidden-card reads. Ambiguous combinations remain guarded.',
|
||||
'New original Basic/Advanced mixed-force and copied Stone comparisons collect sealed physical totals only when the winner depends on them. Later typed losses, mandatory cleanup and native aftercare retain that count. Ordinary/invariant and legacy behavior remains; broader combinations and complete modes stay unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Weapon and prescience selectors include Stone Burner; the editor explains chosen dial/support restrictions, provides an inspector and exposes both reveal modes.',
|
||||
'Weapon/prescience selectors, inspectors and both reveal modes remain. The owned physical-total control explains provisional sealed timing and stays visible until commitment; later matching ordinary/elite loss choices remain separate.',
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'All profiles generate guarded low-dial Stone candidates and choose a reveal mode from public force/leader information. Broader expansion strategy remains unverified.',
|
||||
'All profiles generate legal Stone candidates, choose a reveal mode and commit an offered own physical total. Native two-sided programmes verify both owned choices; strategic refinement and calibration remain deferred.',
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Explains physical-token victory, both leader choices, ordinary winner retention and explicit combined timing limits.',
|
||||
'Explains physical-token victory, both leader choices, sealed total commitments, later typed losses and the explicitly provisional timing/source boundary.',
|
||||
},
|
||||
{
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Earlier pure/engine/review/recovery evidence remains separate. Genuine native cache acquisition, paid marker/typed attack and earned Habbanya now cover physical fees/losses and original/copied retention. Two mobile journeys/20 actions reach Collection;32 legal policy programmes validate870 candidates/256 continuations. All32 naturally call a held Traitor; normal physical outcomes are separate Node/UI proof, not complete modes or calibration.',
|
||||
'The physical-total checkpoint passes frozen16-file146/146, types/lint/build and14 original-action/rendered-control programmes: paid native Basic/Advanced Stone, actual next-turn Mirror, both mixed sides in either seat order, both winners and matching later typed alternatives. Four policies make both private choices legally. Earlier mobile/marker/Habbanya evidence stays dated. Framework aliases/CSS stubs mean no visual/browser/deployed acceptance; comprehensive combinations, assurance and complete modes remain open.',
|
||||
evidence: [
|
||||
'game/stone-burner-allocation.ts',
|
||||
'game/battle-casualty-commitment.ts',
|
||||
'components/stone-burner-allocation.tsx',
|
||||
'tests/stone-burner-allocation.test.ts',
|
||||
'tests/stone-burner-allocation-engine.test.ts',
|
||||
'tests/stone-burner-allocation-controls.test.tsx',
|
||||
'tests/fixture-stone-burner-allocation.ts',
|
||||
'docs/STONE_BURNER_ENGINE_AUDIT.md',
|
||||
'tests/stone-burner.test.ts',
|
||||
'tests/stone-burner-engine.test.ts',
|
||||
'tests/stone-burner-review.test.ts',
|
||||
|
||||
@@ -62,6 +62,7 @@ const DECISIONS = {
|
||||
techToken: true,
|
||||
poisonTooth: true,
|
||||
stoneBurner: true,
|
||||
stoneAllocation: true,
|
||||
fullPlanOffer: true,
|
||||
fullPlanRead: true,
|
||||
guildShipment: true,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
quoteBattleResolution,
|
||||
StoneBurnerAllocationRequiredError,
|
||||
validateStoneBurnerAllocationSelections,
|
||||
type BattleResolutionInput,
|
||||
} from './battle-resolution-quote';
|
||||
import type { Casualties } from './combat';
|
||||
|
||||
export type StoneBurnerAllocationChoice = Readonly<{
|
||||
/** Stable battle side key; may differ from the physical variable owner. */
|
||||
player: string;
|
||||
owner: string;
|
||||
totals: readonly number[];
|
||||
}>;
|
||||
export type StoneBurnerAllocationQuote = Readonly<{
|
||||
choices: readonly StoneBurnerAllocationChoice[];
|
||||
}>;
|
||||
/** Calculator-owned native variable options before either side is destroyed. */
|
||||
export type StoneBurnerAllocationLoss = Readonly<{ undialed: number; losses: Casualties }>;
|
||||
export { validateStoneBurnerAllocationSelections } from './battle-resolution-quote';
|
||||
|
||||
/** Ask only for an actual unresolved Stone outcome after original effective
|
||||
* weapons, successful traitors and withdrawals. A minimum legal completion is
|
||||
* used solely to run the same remaining validation; no outcome is selected or
|
||||
* returned by this quote and no state, resource or random stream is changed.
|
||||
* Return the original full roster even with partial/full valid commitments so
|
||||
* the caller can retain sealed simultaneous information until all are ready. */
|
||||
function allocationPrerequisite(input: BattleResolutionInput): StoneBurnerAllocationRequiredError | null {
|
||||
try {
|
||||
quoteBattleResolution({ ...input, stoneAllocations: undefined });
|
||||
} catch (error) {
|
||||
if (!(error instanceof StoneBurnerAllocationRequiredError)) throw error;
|
||||
const selected = validateStoneBurnerAllocationSelections(error.choices, input.stoneAllocations);
|
||||
const completion = Object.fromEntries(error.choices.map(choice => [choice.player,
|
||||
Object.hasOwn(selected, choice.player) ? selected[choice.player] : choice.totals[0]]));
|
||||
// Reuse native funding, outcome, Occupy and physical custody validation.
|
||||
quoteBattleResolution({ ...input, stoneAllocations: completion });
|
||||
return error;
|
||||
}
|
||||
validateStoneBurnerAllocationSelections([], input.stoneAllocations);
|
||||
return null;
|
||||
}
|
||||
export function quoteStoneBurnerAllocations(input: BattleResolutionInput): StoneBurnerAllocationQuote | null {
|
||||
const prerequisite = allocationPrerequisite(input);
|
||||
return prerequisite ? { choices: prerequisite.choices.map(choice => ({ ...choice, totals: [...choice.totals] })) } : null;
|
||||
}
|
||||
|
||||
/** Stable side key, including a coalition lead, selects the same original
|
||||
* variable loss options used by Stone. Fixed Ecaz and withdrawn complements
|
||||
* are already represented by the calculator's authoritative physical total. */
|
||||
export function stoneBurnerCommittedLosses(input: BattleResolutionInput, player: string): readonly Casualties[] | null {
|
||||
if (!input.stoneAllocations || !Object.hasOwn(input.stoneAllocations, player)) return null;
|
||||
const prerequisite = allocationPrerequisite(input);
|
||||
return prerequisite!.losses[player]
|
||||
.filter(option => option.undialed === input.stoneAllocations![player])
|
||||
.map(option => ({ ...option.losses }));
|
||||
}
|
||||
+20
-2
@@ -4,6 +4,7 @@ import {
|
||||
maxCombatDial,
|
||||
maxCombatSupport,
|
||||
type CombatForces,
|
||||
type Casualties,
|
||||
} from './combat';
|
||||
import type { HarassWithdrawOccupyContext } from './harass-withdraw';
|
||||
|
||||
@@ -54,6 +55,18 @@ function validSide(side: StoneBurnerSide): boolean {
|
||||
function sorted(values: Iterable<number>): number[] {
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
/** The score uses physical undialed counters after the original withdrawal,
|
||||
* including only the surviving fixed Ecaz contribution. Paid support is not
|
||||
* another casualty and temporary roles retain their ordinary physical type. */
|
||||
export function stoneBurnerUndialedTotal(
|
||||
forces: CombatForces,
|
||||
losses: Pick<Casualties, 'normal' | 'elite'>,
|
||||
context?: StoneBurnerSideContext,
|
||||
): number {
|
||||
const fixedUndialed = context?.withdrawal === 'ecaz' ? 0 : context?.ecazUndialed ?? 0;
|
||||
return context?.withdrawal === 'variable' ? fixedUndialed
|
||||
: fixedUndialed + forces.normal + forces.elite - losses.normal - losses.elite;
|
||||
}
|
||||
function totals(
|
||||
forces: CombatForces,
|
||||
dial: number,
|
||||
@@ -78,7 +91,7 @@ function totals(
|
||||
if (context?.withdrawal === 'variable') return [fixedUndialed];
|
||||
return sorted(
|
||||
losses.map(
|
||||
(loss) => fixedUndialed + forces.normal + forces.elite - loss.normal - loss.elite,
|
||||
(loss) => stoneBurnerUndialedTotal(forces, loss, context),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -181,6 +194,7 @@ export function stoneBurnerPlanBlock(
|
||||
side: StoneBurnerSide,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
allowAllocations = false,
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
@@ -191,6 +205,9 @@ export function stoneBurnerPlanBlock(
|
||||
const ownTotals = totals(own, dial, support, ownOccupy);
|
||||
if (!ownTotals.length)
|
||||
return 'Your dial and support do not permit a legal Stone Burner force allocation.';
|
||||
// New battles finish ambiguous outcomes through separate sealed undialed
|
||||
// totals. Legacy battles retain the original public-only preflight boundary.
|
||||
if (allowAllocations === true) return null;
|
||||
const possibleFixedReturn = opponentOccupy?.possibleEcazWithdrawal &&
|
||||
opponentOccupy.withdrawal !== 'ecaz' ? opponentOccupy.ecazUndialed ?? 0 : 0;
|
||||
for (const otherTotals of allOpposingTotals(opponent, opponentOccupy)) {
|
||||
@@ -215,6 +232,7 @@ export function stoneBurnerCompulsionBlock(
|
||||
side: StoneBurnerSide,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
allowAllocations = false,
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
@@ -226,7 +244,7 @@ export function stoneBurnerCompulsionBlock(
|
||||
for (let halfDial = fixedDial * 2; halfDial <= maximumStrength * 2; halfDial++) {
|
||||
if (!totals(own, halfDial / 2, 0, ownOccupy).length) continue;
|
||||
if (
|
||||
stoneBurnerPlanBlock(own, halfDial / 2, 0, opponent, side, aggressor, context) ===
|
||||
stoneBurnerPlanBlock(own, halfDial / 2, 0, opponent, side, aggressor, context, allowAllocations) ===
|
||||
null
|
||||
)
|
||||
return null;
|
||||
|
||||
@@ -20,9 +20,11 @@ export type SukRescueReceipt = {
|
||||
player: string;
|
||||
territory: string;
|
||||
skill: SukGraduateSkill;
|
||||
commitment: { forces: CombatForces; dial: number; support: number; options: Casualties[] };
|
||||
commitment: { forces: CombatForces; dial: number; support: number; options: Casualties[];
|
||||
fullyCommitted?: true; physicalLossCount?: number };
|
||||
/** Ecaz-led Occupy settles the ally's variable losses before this own-force rescue. */
|
||||
occupyCasualties?: { owner: string; forces: CombatForces; dial: number; support: number; options: Casualties[] };
|
||||
occupyCasualties?: { owner: string; forces: CombatForces; dial: number; support: number; options: Casualties[];
|
||||
fullyCommitted?: true; physicalLossCount?: number };
|
||||
pool: SukForceGroup[];
|
||||
cards: string[];
|
||||
physical: string;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export const ALLOCATION_STONE = 'richese-stone-burner';
|
||||
export const ALLOCATION_MIRROR = 'richese-mirror-weapon';
|
||||
export type StoneBurnerAllocationStep = { actor: string; action: Action };
|
||||
export type StoneBurnerAllocationOptions = {
|
||||
advanced?: boolean;
|
||||
mirror?: boolean;
|
||||
comparison?: 'ambiguous' | 'invariant' | 'normal';
|
||||
/** A genuine Advanced Emperor/Ix battle requires both sealed counts. */
|
||||
mixedOpponent?: boolean;
|
||||
/** Reverse the two human combatants' original join/seat order. */
|
||||
reverseSeats?: boolean;
|
||||
/** Original Advanced shipment choice; keeps the default programme unchanged. */
|
||||
ownElite?: 1 | 2;
|
||||
};
|
||||
export const stoneBurnerAllocationPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const stoneBurnerAllocationClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function actStoneBurnerAllocation(game: Game, actor: string, action: Action,
|
||||
actions: StoneBurnerAllocationStep[] = []): Game {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
}
|
||||
|
||||
/** Original cache offers are explicit choices. One cache Stone is paid for by
|
||||
* the opponent; the optional next-turn Mirror is retained by its native owner
|
||||
* through the printed unbid-cache choice. Neither card is moved by staging. */
|
||||
function nextStoneBurnerAllocationStep(game: Game): StoneBurnerAllocationStep | null {
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const decision = game.decision;
|
||||
if (decision.kind === 'stoneAllocation' || decision.kind === 'battleLosses') return null;
|
||||
if (decision.kind === 'richeseCache') {
|
||||
const id = game.turn === 1 ? ALLOCATION_STONE : ALLOCATION_MIRROR;
|
||||
const card = game.richeseCache!.find(card => card.id === id) ?? game.richeseCache![0]; assert.ok(card);
|
||||
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'onceAround', direction: 'clockwise' } };
|
||||
}
|
||||
if (decision.kind === 'richeseUnbid') return { actor: decision.player, action: { type: 'decision',
|
||||
event: game.richeseBidding!.event, keep: game.richeseAuction!.cardId === ALLOCATION_MIRROR } };
|
||||
}
|
||||
const lot = game.richeseAuction;
|
||||
const buyer = game.players.find(player => player.faction === 'emperor')?.id ??
|
||||
game.players.find(player => player.faction === 'ixians')?.id;
|
||||
if (stoneBurnerAllocationClean(game) && lot && !lot.outcome && lot.cardId === ALLOCATION_STONE &&
|
||||
lot.active === buyer && lot.bid === 0)
|
||||
return { actor: lot.active!, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceStoneBurnerAllocation(game: Game, until: (state: Game) => boolean,
|
||||
actions: StoneBurnerAllocationStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine Stone programme ended before its next owned control');
|
||||
const step = nextStoneBurnerAllocationStep(game);
|
||||
assert.ok(step, `Choose the real ${game.decision?.kind ?? 'Battle Plan'} explicitly`);
|
||||
game = actStoneBurnerAllocation(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Stone allocation programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Genuine host/private setup, paid native cache Stone, typed funded arrivals
|
||||
* and original revealed plans. Basic Ix dial2 uses one Cyborg or four Suboids;
|
||||
* Advanced Emperor dial3/support1 uses three or five actual counters. */
|
||||
export function createStoneBurnerAllocationFixture(options: StoneBurnerAllocationOptions = {}) {
|
||||
const advanced = options.advanced ?? !!options.mixedOpponent, comparison = options.comparison ?? 'ambiguous';
|
||||
assert.ok(!options.mixedOpponent || (advanced && !options.mirror && comparison === 'ambiguous'),
|
||||
'The bounded two-count programme uses Advanced Emperor/Ix and the original Stone');
|
||||
const owner = advanced ? 'emperor' : 'ixians', opponent = options.mixedOpponent ? 'ixians' : 'richese';
|
||||
let game = createGame('STONEBURNERALLOCATION', newPlayer('richese', 'Richese', 'richese'), advanced,
|
||||
advanced && !options.mixedOpponent ? ['choam'] : ['ix', 'choam']);
|
||||
const seats: ('emperor' | 'ixians' | 'guild')[] = options.mixedOpponent
|
||||
? options.reverseSeats ? ['ixians', 'emperor', 'guild'] : ['emperor', 'ixians', 'guild'] : [owner, 'guild'];
|
||||
for (const faction of seats) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: StoneBurnerAllocationStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actStoneBurnerAllocation(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceStoneBurnerAllocation(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const front: Game['spiceDeck'] = [];
|
||||
for (let count = 0; count < (options.mirror ? 2 : 1) * (advanced ? 2 : 1); count++) {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
|
||||
front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
reach(state => stoneBurnerAllocationPlayer(state, owner).hand.some(card => card.id === ALLOCATION_STONE));
|
||||
const acquiredStone = structuredClone(game);
|
||||
assert.ok(actions.some(step => step.actor === owner && step.action.type === 'richeseBid' && step.action.amount === 1),
|
||||
'The canonical Stone must be acquired through its original paid native cache lot');
|
||||
if (options.mirror) reach(state => state.turn === 2 &&
|
||||
stoneBurnerAllocationPlayer(state, opponent).hand.some(card => card.id === ALLOCATION_MIRROR));
|
||||
const acquiredMirror = options.mirror ? structuredClone(game) : null;
|
||||
const turn = options.mirror ? 2 : 1, shipments: { before: Game; step: StoneBurnerAllocationStep; after: Game }[] = [];
|
||||
const shipped = new Set<string>();
|
||||
for (let count = 0; shipped.size < 2 && count < 500; count++) {
|
||||
if (game.turn === turn && game.phase === 5 && stoneBurnerAllocationClean(game) && game.active &&
|
||||
[owner, opponent].includes(game.active) && !shipped.has(game.active)) {
|
||||
const actor = game.active, before = structuredClone(game), step: StoneBurnerAllocationStep = { actor, action: {
|
||||
type: 'ship', territory: 'arrakeen', sector: 10,
|
||||
amount: actor === owner || options.mixedOpponent ? 6 : advanced ? 3 : 4,
|
||||
elite: actor === owner ? advanced ? options.ownElite ?? 1 : 2 : options.mixedOpponent ? 2 : 0, allyPayment: 0,
|
||||
} };
|
||||
act(actor, step.action); reach(stoneBurnerAllocationClean);
|
||||
shipments.push({ before, step, after: structuredClone(game) }); shipped.add(actor);
|
||||
} else {
|
||||
const step = nextStoneBurnerAllocationStep(game); assert.ok(step);
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.equal(shipped.size, 2);
|
||||
reach(state => state.phase === 6 && !!state.active && stoneBurnerAllocationClean(state));
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
|
||||
.find(choice => choice.territory === 'arrakeen'); assert.ok(choice);
|
||||
const chooseBattle: StoneBurnerAllocationStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: 'arrakeen', target: choice.chooser === owner ? opponent : owner,
|
||||
} };
|
||||
act(chooseBattle.actor, chooseBattle.action);
|
||||
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && stoneBurnerAllocationClean(state));
|
||||
const beforePlans = structuredClone(game), leaders = (actor: string) => stoneBurnerAllocationPlayer(game, actor).leaders
|
||||
.filter(leader => !leader.dead && !leader.usedAt);
|
||||
const ownLeader = leaders(owner).sort((a, b) => b.strength - a.strength)[0],
|
||||
enemyLeader = leaders(opponent).sort((a, b) => a.strength - b.strength)[0]; assert.ok(ownLeader && enemyLeader);
|
||||
const opponentDial = options.mixedOpponent ? 3.5 : comparison === 'invariant' ? advanced ? 3 : 4 : 1;
|
||||
const plans: StoneBurnerAllocationStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: ownLeader.id, dial: advanced ? 3 : 2, support: advanced ? 1 : 0,
|
||||
weapon: comparison === 'normal' ? null : ALLOCATION_STONE } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: opponentDial,
|
||||
support: options.mixedOpponent ? 1 : advanced ? opponentDial : 0, weapon: options.mirror ? ALLOCATION_MIRROR : null } },
|
||||
];
|
||||
for (const step of plans) act(step.actor, step.action);
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => !state.response && !state.phaseOpening &&
|
||||
(state.decision?.kind === 'stoneAllocation' || state.decision?.kind === 'battleLosses' ||
|
||||
(!state.battle && stoneBurnerAllocationClean(state))));
|
||||
return { game, lobby, setup, afterSetup, acquiredStone, acquiredMirror, shipments, beforeBattle,
|
||||
chooseBattle, beforePlans, plans, revealed, owner, opponent, advanced, comparison, mirror: options.mirror ?? false,
|
||||
mixedOpponent: options.mixedOpponent ?? false, reverseSeats: options.reverseSeats ?? false,
|
||||
ownElite: options.ownElite ?? 1,
|
||||
location: 'arrakeen:10', actions,
|
||||
staging: ['Only unopened scalar/deck lotteries are selected. The Stone is bought from its real native cache; optional Mirror is genuinely retained from the next original unbid cache lot. All forces and support are funded original actions.'] };
|
||||
}
|
||||
export type StoneBurnerAllocationFixture = ReturnType<typeof createStoneBurnerAllocationFixture>;
|
||||
|
||||
/** Every total comes from the authenticated current owner's actual view.
|
||||
* Later ordinary casualty and Ixian substitution choices retain their native
|
||||
* controls; choosing a total never installs a typed loss mix. */
|
||||
export function completeStoneBurnerAllocation(fixture: StoneBurnerAllocationFixture,
|
||||
undialed?: number | Readonly<Record<string, number>>, lossChoice = 0) {
|
||||
let game = structuredClone(fixture.game);
|
||||
const actions: StoneBurnerAllocationStep[] = [], committed: { before: Game; step: StoneBurnerAllocationStep; after: Game }[] = [];
|
||||
for (let count = 0; count < 700; count++) {
|
||||
if (!game.battle && stoneBurnerAllocationClean(game)) return { game, actions, committed };
|
||||
const decision = game.decision;
|
||||
let step: StoneBurnerAllocationStep | null;
|
||||
if (!game.response && !game.phaseOpening && decision?.kind === 'stoneAllocation') {
|
||||
const offer = viewGame(game, decision.player).battle!.stoneAllocation; assert.ok(offer);
|
||||
const total = typeof undialed === 'object' ? undialed[offer.owner]
|
||||
: offer.owner === fixture.owner ? undialed : offer.totals[0];
|
||||
assert.ok(total !== undefined && offer.totals.includes(total));
|
||||
step = { actor: decision.player, action: { type: 'decision', event: offer.event, undialed: total } };
|
||||
const before = structuredClone(game);
|
||||
game = actStoneBurnerAllocation(game, step.actor, step.action, actions);
|
||||
committed.push({ before, step, after: structuredClone(game) });
|
||||
continue;
|
||||
} else if (!game.response && !game.phaseOpening && decision?.kind === 'battleLosses') {
|
||||
const view = viewGame(game, decision.player), current = view.decision;
|
||||
assert.ok(current?.kind === 'battleLosses');
|
||||
const choice = Math.min(lossChoice, current.options.length - 1);
|
||||
step = { actor: decision.player, action: { type: 'decision', choice } };
|
||||
} else step = nextStoneBurnerAllocationStep(game);
|
||||
assert.ok(step, 'The original Stone consequence needs its next owned native action');
|
||||
game = actStoneBurnerAllocation(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('Original Stone allocation and typed consequences did not complete');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { createElement, type MouseEvent, type ReactElement, type ButtonHTMLAttributes } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { StoneBurnerAllocation } from '../components/stone-burner-allocation';
|
||||
|
||||
void test('Stone Burner allocation keeps explicit physical-total commitments and provisional sealed timing readable until chosen', () => {
|
||||
const committed: number[] = [], props = { event: 'stone-original-event', totals: [5, 2, 5],
|
||||
onCommit: (total: number) => committed.push(total), busy: false };
|
||||
const html = renderToStaticMarkup(createElement(StoneBurnerAllocation, props));
|
||||
assert.match(html, /aria-label="Stone Burner undialed force commitment"/);
|
||||
assert.match(html, /Provisional timing/);
|
||||
assert.match(html, /Choices stay sealed until everyone has committed/);
|
||||
assert.match(html, /ordinary and elite losses afterward/);
|
||||
assert.match(html, /Commit 2 undialed physical forces/);
|
||||
assert.match(html, /Commit 5 undialed physical forces/);
|
||||
assert.equal((html.match(/<button/g) ?? []).length, 2);
|
||||
assert.deepEqual(committed, [], 'Rendering never commits or starts an automatic countdown');
|
||||
const panel = StoneBurnerAllocation(props)!;
|
||||
const buttons = panel.props.children[3].props.children as ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>[];
|
||||
buttons[0].props.onClick!({} as MouseEvent<HTMLButtonElement>);
|
||||
buttons[1].props.onClick!({} as MouseEvent<HTMLButtonElement>);
|
||||
assert.deepEqual(committed, [2, 5]);
|
||||
});
|
||||
|
||||
void test('busy Stone Burner commitments cannot submit through their callback, and a fresh event retains its own original choices', () => {
|
||||
const committed: number[] = [], onCommit = (total: number) => committed.push(total);
|
||||
const props = { event: 'first-original-event', totals: [1, 3], onCommit, busy: true };
|
||||
const html = renderToStaticMarkup(createElement(StoneBurnerAllocation, props));
|
||||
assert.equal((html.match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2);
|
||||
const panel = StoneBurnerAllocation(props)!;
|
||||
const buttons = panel.props.children[3].props.children as ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>[];
|
||||
for (const button of buttons) button.props.onClick!({} as MouseEvent<HTMLButtonElement>);
|
||||
assert.deepEqual(committed, []);
|
||||
const next = renderToStaticMarkup(createElement(StoneBurnerAllocation,
|
||||
{ event: 'next-original-event', totals: [0, 4], onCommit, busy: false }));
|
||||
assert.match(next, /data-event="next-original-event"/);
|
||||
assert.match(next, /Commit 0 undialed physical forces/);
|
||||
assert.match(next, /Commit 4 undialed physical forces/);
|
||||
assert.doesNotMatch(next, /Commit 1 undialed|Commit 3 undialed/);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
ALLOCATION_MIRROR, ALLOCATION_STONE, actStoneBurnerAllocation, completeStoneBurnerAllocation,
|
||||
createStoneBurnerAllocationFixture, stoneBurnerAllocationPlayer as player,
|
||||
type StoneBurnerAllocationFixture,
|
||||
} from './fixture-stone-burner-allocation';
|
||||
|
||||
function genuineSource(f: StoneBurnerAllocationFixture) {
|
||||
assert.equal(f.beforePlans.battle!.stoneAllocationVersion, 1);
|
||||
assert.ok(f.acquiredStone.players.find(seat => seat.id === f.owner)!.hand.some(card => card.id === ALLOCATION_STONE));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(f.shipments.length, 2);
|
||||
for (const shipment of f.shipments) {
|
||||
assert.equal(player(shipment.after, shipment.step.actor).reserves,
|
||||
player(shipment.before, shipment.step.actor).reserves - Number(shipment.step.action.amount));
|
||||
assert.equal(player(shipment.after, shipment.step.actor).spice,
|
||||
player(shipment.before, shipment.step.actor).spice - Number(shipment.step.action.amount),
|
||||
'Each real off-planet stronghold shipment pays its original printed cost');
|
||||
}
|
||||
assert.equal(player(f.beforePlans, f.owner).forces[f.location], 6);
|
||||
assert.equal(player(f.beforePlans, f.owner).elites!.forces[f.location], f.advanced ? f.ownElite : 2);
|
||||
assert.equal(f.beforePlans.battle!.plans[f.owner], undefined, 'The new allocation is not an early Battle Plan field');
|
||||
assert.equal(viewGame(f.revealed, f.owner).battle!.stoneAllocation, null,
|
||||
'Revealed weapon modes and actual traitor decisions precede the new count commitment');
|
||||
}
|
||||
|
||||
void test('genuine Basic mixed Ix Stone commits either legal physical total before deciding the winner', () => {
|
||||
const f = createStoneBurnerAllocationFixture();
|
||||
genuineSource(f);
|
||||
assert.equal(f.game.decision?.kind, 'stoneAllocation');
|
||||
assert.equal(f.game.decision?.player, f.owner);
|
||||
const offer = viewGame(f.game, f.owner).battle!.stoneAllocation!;
|
||||
assert.deepEqual(offer.totals, [2, 5]);
|
||||
assert.equal(offer.selected, null);
|
||||
assert.equal(viewGame(f.game, f.opponent).battle!.stoneAllocation, null,
|
||||
'The other side receives no pending private commitment values');
|
||||
const before = player(f.game, f.owner), enemy = player(f.game, f.opponent);
|
||||
for (const total of offer.totals) {
|
||||
const { game, committed } = completeStoneBurnerAllocation(f, total), after = player(game, f.owner);
|
||||
assert.equal(committed.length, 1);
|
||||
assert.equal(committed[0].step.action.undialed, total);
|
||||
assert.equal(game.lastBattleContext!.winner, total === 5 ? f.owner : f.opponent);
|
||||
assert.equal(after.reserves, before.reserves, 'A count commitment neither spends nor returns a reserve counter');
|
||||
assert.equal(after.forces['hidden_mobile_stronghold:0'], before.forces['hidden_mobile_stronghold:0']);
|
||||
assert.equal(after.elites!.forces['hidden_mobile_stronghold:0'], before.elites!.forces['hidden_mobile_stronghold:0']);
|
||||
assert.equal(after.tanks, before.tanks + (total === 5 ? 1 : 6));
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + (total === 5 ? 1 : 2));
|
||||
assert.equal(after.forces[f.location] ?? 0, total === 5 ? 5 : 0);
|
||||
assert.equal(after.elites!.forces[f.location] ?? 0, total === 5 ? 1 : 0);
|
||||
assert.equal(player(game, f.opponent).tanks, enemy.tanks + (total === 5 ? 4 : 1));
|
||||
assert.equal(game.discard.filter(card => card.id === ALLOCATION_STONE).length, total === 5 ? 0 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine Advanced mixed Sardaukar Stone retains three-or-five-counter alternatives and settles the selected physical result', () => {
|
||||
const f = createStoneBurnerAllocationFixture({ advanced: true });
|
||||
genuineSource(f);
|
||||
const offer = viewGame(f.game, f.owner).battle!.stoneAllocation!;
|
||||
assert.deepEqual(offer.totals, [1, 2, 3]);
|
||||
assert.equal(f.plans[0].action.dial, 3);
|
||||
assert.equal(f.plans[0].action.support, 1);
|
||||
const before = player(f.game, f.owner), enemy = player(f.game, f.opponent);
|
||||
for (const total of [1, 3]) {
|
||||
const { game } = completeStoneBurnerAllocation(f, total), after = player(game, f.owner);
|
||||
assert.equal(game.lastBattleContext!.winner, total === 3 ? f.owner : f.opponent);
|
||||
assert.equal(after.tanks, before.tanks + (total === 3 ? 3 : 6));
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
|
||||
assert.equal(after.forces[f.location] ?? 0, total === 3 ? 3 : 0);
|
||||
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(game, f.opponent).tanks, enemy.tanks + (total === 3 ? 3 : 1));
|
||||
assert.equal(after.spice, before.spice - 1 + (total === 3 ? 2 : 0),
|
||||
'Original support is paid once, then the surviving city occupant earns its ordinary Collection income');
|
||||
for (const plan of f.plans)
|
||||
assert.equal(player(game, plan.actor).leaders.find(leader => leader.id === plan.action.leader)!.dead, false,
|
||||
'The genuinely selected ignore mode leaves leaders alive when no other weapon kills them');
|
||||
}
|
||||
});
|
||||
|
||||
void test('normal and invariant-winner original mixed Ix battles keep their source-backed later typed casualty choice', () => {
|
||||
for (const comparison of ['normal', 'invariant'] as const) {
|
||||
const f = createStoneBurnerAllocationFixture({ comparison });
|
||||
genuineSource(f);
|
||||
assert.equal(f.game.decision?.kind, 'battleLosses');
|
||||
assert.equal(f.game.decision?.player, f.owner);
|
||||
assert.equal(viewGame(f.game, f.owner).battle?.stoneAllocation ?? null, null);
|
||||
assert.ok(f.actions.every(step => step.action.type !== 'decision' || step.action.undialed === undefined));
|
||||
const decision = f.game.decision!;
|
||||
assert.ok(decision.kind === 'battleLosses');
|
||||
const physical = decision.options.map(option => option.normal + option.elite);
|
||||
assert.deepEqual([...new Set(physical)].sort((a, b) => a - b), [1, 4]);
|
||||
for (const index of [0, decision.options.length - 1]) {
|
||||
const { game, committed } = completeStoneBurnerAllocation(f, undefined, index);
|
||||
assert.deepEqual(committed, []);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(game, f.owner).tanks, player(f.game, f.owner).tanks + physical[index]);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 6 - physical[index]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual paid Stone and next-turn native unbid Mirror share the same count protocol without fabricating either held card', () => {
|
||||
const f = createStoneBurnerAllocationFixture({ mirror: true });
|
||||
genuineSource(f);
|
||||
assert.equal(f.acquiredMirror!.turn, 2);
|
||||
assert.ok(player(f.acquiredMirror!, f.opponent).hand.some(card => card.id === ALLOCATION_MIRROR));
|
||||
assert.ok(f.actions.some(step => step.actor === f.opponent && step.action.type === 'decision' && step.action.keep === true));
|
||||
const choices = f.actions.filter(step => step.action.type === 'decision' && step.action.mode === 'ignore');
|
||||
assert.equal(choices.length, 2);
|
||||
assert.ok(choices.some(step => step.actor === f.owner));
|
||||
assert.ok(choices.some(step => step.actor === f.opponent));
|
||||
const offer = viewGame(f.game, f.owner).battle!.stoneAllocation!;
|
||||
assert.deepEqual(offer.totals, [2, 5]);
|
||||
for (const total of offer.totals) {
|
||||
const { game } = completeStoneBurnerAllocation(f, total);
|
||||
assert.equal(game.lastBattleContext!.winner, total === 5 ? f.owner : f.opponent);
|
||||
assert.equal(game.discard.filter(card => card.id === ALLOCATION_STONE).length, total === 5 ? 0 : 1);
|
||||
assert.equal(game.discard.filter(card => card.id === ALLOCATION_MIRROR).length, total === 5 ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine two-sided mixed Stone counts stay sealed until both commit in either original seating order', () => {
|
||||
const queues: string[][] = [];
|
||||
for (const reverseSeats of [false, true]) {
|
||||
const f = createStoneBurnerAllocationFixture({ mixedOpponent: true, reverseSeats });
|
||||
genuineSource(f);
|
||||
const order = f.game.order.filter(actor => actor === f.owner || actor === f.opponent);
|
||||
queues.push(order);
|
||||
assert.equal(f.game.decision!.player, order[0]);
|
||||
assert.equal(f.game.battle!.attacker, order[0]);
|
||||
assert.deepEqual(viewGame(f.game, f.owner).battle!.stoneAllocation!.totals, [1, 2, 3]);
|
||||
assert.deepEqual(viewGame(f.game, f.opponent).battle!.stoneAllocation!.totals, [2, 3]);
|
||||
for (const selection of [{ emperor: 3, ixians: 2 }, { emperor: 1, ixians: 3 }]) {
|
||||
const { game, committed } = completeStoneBurnerAllocation(f, selection);
|
||||
assert.equal(committed.length, 2, 'Both original mixed sides commit even after a decisive first extreme');
|
||||
assert.deepEqual(committed.map(entry => entry.step.actor), order);
|
||||
const first = committed[0], waiting = first.after, next = order[1];
|
||||
assert.equal(waiting.decision?.kind, 'stoneAllocation');
|
||||
assert.equal(waiting.decision?.player, next);
|
||||
const own = viewGame(waiting, order[0]).battle!.stoneAllocation!, rival = viewGame(waiting, next).battle!.stoneAllocation!;
|
||||
assert.equal(own.selected, first.step.action.undialed);
|
||||
assert.equal(rival.selected, null);
|
||||
assert.deepEqual(Object.keys(rival).sort(), ['event', 'owner', 'player', 'selected', 'totals']);
|
||||
const messages = waiting.log.slice(first.before.log.length).map(entry => entry.text);
|
||||
assert.ok(messages.length > 0);
|
||||
assert.ok(messages.every(message => !/\d/.test(message)), 'The first public commitment message reveals no selected count');
|
||||
assert.deepEqual(player(waiting, f.owner).forces, player(f.game, f.owner).forces);
|
||||
assert.deepEqual(player(waiting, f.opponent).forces, player(f.game, f.opponent).forces);
|
||||
assert.equal(game.lastBattleContext!.winner, selection.emperor > selection.ixians ? f.owner : f.opponent);
|
||||
const completedMessages = game.log.slice(f.game.log.length).map(entry => entry.text);
|
||||
for (const actor of [f.owner, f.opponent])
|
||||
assert.ok(completedMessages.some(message => message.includes(`${player(game, actor).name} ${selection[actor as keyof typeof selection]} undialed`)),
|
||||
'Only the completed sealed queue publishes both original counts');
|
||||
}
|
||||
}
|
||||
assert.deepEqual(queues, [['emperor', 'ixians'], ['ixians', 'emperor']],
|
||||
'Actual reversed human joins change the original storm queue without a saved-order edit');
|
||||
});
|
||||
|
||||
void test('all four minimal policies commit legal own counts from both sealed private views before original settlement', () => {
|
||||
const f = createStoneBurnerAllocationFixture({ mixedOpponent: true });
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
let game = structuredClone(f.game);
|
||||
const owners: string[] = [];
|
||||
for (let count = 0; count < 2; count++) {
|
||||
const decision = game.decision; assert.ok(decision?.kind === 'stoneAllocation');
|
||||
const view = viewGame(game, decision.player), offer = view.battle!.stoneAllocation!;
|
||||
view.players.find(player => player.id === decision.player)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'decision' && action.event === offer.event &&
|
||||
typeof action.undialed === 'number'); assert.ok(action, `${difficulty} must answer its current owned count`);
|
||||
assert.ok(offer.totals.includes(Number(action.undialed)));
|
||||
assert.equal(offer.selected, null);
|
||||
owners.push(decision.player);
|
||||
game = actStoneBurnerAllocation(game, decision.player, action);
|
||||
}
|
||||
assert.deepEqual(owners, ['emperor', 'ixians']);
|
||||
const completed = completeStoneBurnerAllocation({ ...f, game });
|
||||
assert.ok([f.owner, f.opponent].includes(completed.game.lastBattleContext!.winner!));
|
||||
}
|
||||
});
|
||||
|
||||
void test('a genuinely committed Stone total retains both later winner choices of ordinary and Sardaukar losses', () => {
|
||||
const f = createStoneBurnerAllocationFixture({ mixedOpponent: true, ownElite: 2 });
|
||||
genuineSource(f);
|
||||
assert.deepEqual(viewGame(f.game, f.owner).battle!.stoneAllocation!.totals, [2, 3, 4]);
|
||||
const selection = { emperor: 3, ixians: 2 }, first = completeStoneBurnerAllocation(f, selection);
|
||||
const later = first.committed.at(-1)!.after.decision;
|
||||
assert.ok(later?.kind === 'battleLosses');
|
||||
assert.equal(later.player, f.owner);
|
||||
assert.deepEqual(later.options.map(option => [option.normal, option.elite]), [[2, 1], [1, 2]]);
|
||||
const eliteLosses: number[] = [];
|
||||
for (const index of [0, later.options.length - 1]) {
|
||||
const { game } = completeStoneBurnerAllocation(f, selection, index), before = player(f.game, f.owner), after = player(game, f.owner);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(after.tanks, before.tanks + 3);
|
||||
assert.equal(after.forces[f.location], 3);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + later.options[index].elite);
|
||||
eliteLosses.push(after.elites!.tanks - before.elites!.tanks);
|
||||
}
|
||||
assert.deepEqual(eliteLosses, [1, 2], 'The count commitment never preselects the winner’s typed physical loss mix');
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { casualtyOptions, type CombatForces } from '../game/combat';
|
||||
import {
|
||||
quoteBattleResolution, StoneBurnerAllocationRequiredError, BattleResolutionQuoteError,
|
||||
type BattleResolutionInput, type ResolutionCombatant,
|
||||
} from '../game/battle-resolution-quote';
|
||||
import { quoteStoneBurnerAllocations, stoneBurnerCommittedLosses } from '../game/stone-burner-allocation';
|
||||
import { stoneBurnerPlanBlock, stoneBurnerCompulsionBlock } from '../game/stone-burner';
|
||||
import { quoteEcazOccupyBattle } from '../game/ecaz-occupy-battle';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { casualtyConstraintFields, committedCasualtyOptions } from '../game/battle-casualty-commitment';
|
||||
|
||||
const catalog = (): Card[] => [...baseDeck(), ...ixBattleCards(), ...richeseCards(), ...ecazTreacheryCards()];
|
||||
const ordinary = (normal: number, freeSupport = false): CombatForces => ({ normal, elite: 0, eliteStrength: 1, freeSupport });
|
||||
const emperor: CombatForces = { normal: 4, elite: 2, eliteStrength: 2, freeSupport: false };
|
||||
function combatant(id: string, faction: ResolutionCombatant['faction'], forces: CombatForces, dial: number, support: number): ResolutionCombatant {
|
||||
const leader = { ...leaders(faction)[0] };
|
||||
return { id, faction, forces: { ...forces }, spice: 10, hand: [], leader,
|
||||
plan: { dial, support, leader: leader.id, weapon: null, defense: null } };
|
||||
}
|
||||
function fixture(): BattleResolutionInput {
|
||||
const attacker = combatant('a', 'emperor', emperor, 3, 1);
|
||||
const defender = combatant('d', 'guild', ordinary(4), 1, 1);
|
||||
const cards = catalog(), stone = cards.find(card => card.effect === 'stoneBurner')!;
|
||||
attacker.hand = [stone]; attacker.plan.weapon = stone.id; attacker.stoneMode = 'ignore';
|
||||
return { advanced: true, turn: 2, territory: 'carthag', attacker, defender,
|
||||
participants: [attacker, defender].map(side => ({ id: side.id, faction: side.faction })),
|
||||
voters: [attacker, defender].map(side => ({ id: side.id, beneficiary: side.id, called: false, traitors: [] })),
|
||||
physicalCards: cards, pendingAuditorPresent: false, pendingRetentionPresent: false };
|
||||
}
|
||||
|
||||
void test('a committed Stone physical loss count survives card cleanup and native aftercare without reopening other typed totals', () => {
|
||||
const input = fixture();
|
||||
const result = quoteBattleResolution({...input,stoneAllocations:{a:3}});
|
||||
const commitment = result.casualties!;
|
||||
assert.equal(commitment.physicalLossCount, 3);
|
||||
const saved = JSON.parse(JSON.stringify(commitment));
|
||||
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support,saved),saved.options);
|
||||
assert.equal(new Set(saved.options.map((option: {normal:number;elite:number}) => option.normal + option.elite)).size,1);
|
||||
assert.ok(saved.options.length > 1, 'Choosing a physical total still leaves the original typed winner choice');
|
||||
assert.deepEqual(casualtyConstraintFields(saved),{physicalLossCount:3});
|
||||
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support),
|
||||
casualtyOptions(saved.forces,saved.dial,saved.support), 'Legacy unconstrained casualty choices remain complete');
|
||||
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support,{physicalLossCount:21}),[]);
|
||||
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support,{fullyCommitted:true,physicalLossCount:3}),[],
|
||||
'A partial Stone commitment cannot replace the already fixed full Harass remainder');
|
||||
});
|
||||
|
||||
void test('only outcome-ambiguous Stone asks for physical totals, allowing either winner and retaining typed alternatives within one committed total', () => {
|
||||
const input = fixture(), before = structuredClone(input);
|
||||
const choices = { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] };
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(input), choices);
|
||||
assert.throws(() => quoteBattleResolution(input), StoneBurnerAllocationRequiredError);
|
||||
const loser = quoteBattleResolution({ ...input, stoneAllocations: { a: 2 } });
|
||||
assert.equal(loser.winner, 'd');
|
||||
assert.deepEqual(loser.stone, { winner: 'defender', attacker: [2], defender: [3] });
|
||||
assert.deepEqual(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { a: 2 } }, 'a'), [
|
||||
{ normal: 3, elite: 1, paidNormal: 1, paidElite: 0 },
|
||||
]);
|
||||
assert.equal(stoneBurnerCommittedLosses(input, 'a'), null);
|
||||
assert.equal(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { a: 2 } }, 'd'), null);
|
||||
const winner = quoteBattleResolution({ ...input, stoneAllocations: { a: 3 } });
|
||||
assert.equal(winner.winner, 'a');
|
||||
assert.equal(winner.casualties!.physicalLossCount, 3);
|
||||
assert.deepEqual(winner.stone, { winner: 'attacker', attacker: [3], defender: [3] });
|
||||
assert.deepEqual(winner.casualties!.options, [
|
||||
{ normal: 2, elite: 1, paidNormal: 0, paidElite: 1 },
|
||||
{ normal: 1, elite: 2, paidNormal: 1, paidElite: 0 },
|
||||
]);
|
||||
for (const option of winner.casualties!.options) assert.equal(6 - option.normal - option.elite, 3);
|
||||
assert.deepEqual(quoteStoneBurnerAllocations({ ...input, stoneAllocations: { a: 3 } }), choices);
|
||||
assert.deepEqual(input, before);
|
||||
});
|
||||
|
||||
void test('both variable sides must commit even when the first chosen total already fixes the winner', () => {
|
||||
const input = fixture();
|
||||
input.defender = { ...input.defender, forces: { ...emperor }, plan: { ...input.defender.plan, dial: 3, support: 1 } };
|
||||
const required = { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }, { player: 'd', owner: 'd', totals: [2, 3, 4] }] };
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(input), required);
|
||||
assert.deepEqual(quoteStoneBurnerAllocations({ ...input, stoneAllocations: { a: 4 } }), required);
|
||||
assert.throws(() => quoteBattleResolution({ ...input, stoneAllocations: { a: 4 } }), StoneBurnerAllocationRequiredError);
|
||||
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 4, d: 2 } }).winner, 'a');
|
||||
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 2, d: 4 } }).winner, 'd');
|
||||
input.aggressor = 'd';
|
||||
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 3, d: 3 } }).winner, 'd');
|
||||
});
|
||||
|
||||
void test('invariant Stone and ordinary battles keep their original winner-later allocation and gain no commitment roster', () => {
|
||||
const input = fixture();
|
||||
input.defender.forces = ordinary(1);
|
||||
const options = casualtyOptions(input.attacker.forces, 3, 1);
|
||||
assert.equal(quoteStoneBurnerAllocations(input), null);
|
||||
assert.deepEqual(quoteBattleResolution(input).casualties!.options, options);
|
||||
input.attacker.hand = []; input.attacker.plan.weapon = null; delete input.attacker.stoneMode;
|
||||
assert.equal(quoteStoneBurnerAllocations(input), null);
|
||||
const ordinaryResult = quoteBattleResolution(input);
|
||||
assert.equal(ordinaryResult.stone, null);
|
||||
assert.deepEqual(ordinaryResult.casualties!.options, options);
|
||||
});
|
||||
|
||||
void test('Basic Cyborg/Suboid pools use the same physical commitment without granting Advanced strength or support', () => {
|
||||
const input = fixture(); input.advanced = false;
|
||||
input.attacker = combatant('a', 'ixians', { normal: 6, elite: 2, eliteStrength: 2, normalFixedHalf: true, freeSupport: true }, 2, 0);
|
||||
const stone = catalog().find(card => card.effect === 'stoneBurner')!;
|
||||
input.attacker.hand = [stone]; input.attacker.plan.weapon = stone.id; input.attacker.stoneMode = 'ignore';
|
||||
input.defender = combatant('d', 'guild', ordinary(7, true), 1, 0);
|
||||
input.participants = [input.attacker, input.defender].map(side => ({ id: side.id, faction: side.faction }));
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [4, 7] }] });
|
||||
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 4 } }).winner, 'd');
|
||||
const result = quoteBattleResolution({ ...input, stoneAllocations: { a: 7 } });
|
||||
assert.equal(result.winner, 'a');
|
||||
assert.deepEqual(result.casualties!.options, [{ normal: 0, elite: 1, paidNormal: 0, paidElite: 0 }]);
|
||||
assert.deepEqual(result.payments, []);
|
||||
});
|
||||
|
||||
void test('physical Mirror retains its own card while the copied Stone uses the same committed comparison', () => {
|
||||
const input = fixture(), mirror = catalog().find(card => card.effect === 'mirrorWeapon')!;
|
||||
input.defender.hand = [mirror]; input.defender.plan.weapon = mirror.id; input.defender.stoneMode = 'ignore';
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
|
||||
const result = quoteBattleResolution({ ...input, stoneAllocations: { a: 2 } });
|
||||
assert.equal(result.winner, 'd');
|
||||
assert.ok(result.winnerCards.includes(mirror.id));
|
||||
assert.ok(result.discarded.some(entry => entry.player === 'a' && entry.card === input.attacker.plan.weapon));
|
||||
});
|
||||
|
||||
function occupyFixture(canceled = false): BattleResolutionInput {
|
||||
const input = fixture();
|
||||
const profile = quoteEcazOccupyBattle({ advanced: true, battleOrderActor: 'ecaz',
|
||||
ecaz: { id: 'ecaz', faction: 'ecaz', ally: 'ally', forces: ordinary(5) },
|
||||
ally: { id: 'ally', faction: 'emperor', ally: 'ecaz', forces: { ...emperor } },
|
||||
lead: canceled ? 'ally' : 'ecaz', canceled });
|
||||
const own = combatant(profile.lead, canceled ? 'emperor' : 'ecaz', profile.forces, profile.fixedEcazDial + 3, 1);
|
||||
own.ally = canceled ? 'ecaz' : 'ally';
|
||||
own.hand = input.attacker.hand; own.plan.weapon = input.attacker.plan.weapon; own.stoneMode = 'ignore';
|
||||
input.attacker = own; input.ecazOccupy = profile;
|
||||
input.defender = combatant('d', 'guild', ordinary(canceled ? 4 : 6), 1, 1);
|
||||
input.participants = [{ id: 'ecaz', faction: 'ecaz', ally: 'ally' }, { id: 'ally', faction: 'emperor', ally: 'ecaz' }, { id: 'd', faction: 'guild' }];
|
||||
input.voters = ['ecaz', 'ally'].map(id => ({ id, beneficiary: profile.lead, called: false, traitors: [] }))
|
||||
.concat([{ id: 'd', beneficiary: 'd', called: false, traitors: [] }]);
|
||||
return input;
|
||||
}
|
||||
|
||||
void test('active Occupy asks the variable physical owner for the lead side total, retaining fixed Ecaz loss and same-total typed alternatives', () => {
|
||||
const input = occupyFixture();
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'ecaz', owner: 'ally', totals: [4, 5, 6] }] });
|
||||
const result = quoteBattleResolution({ ...input, stoneAllocations: { ecaz: 5 } });
|
||||
assert.equal(result.winner, 'ecaz');
|
||||
assert.deepEqual(result.fixedLosses, [{ owner: 'ecaz', normal: 3, elite: 0 }]);
|
||||
assert.equal(result.casualties!.owner, 'ally');
|
||||
assert.equal(result.casualties!.physicalLossCount, 3, 'The fixed Ecaz losses are outside the committed variable loss count');
|
||||
assert.equal(result.casualties!.options.length, 2);
|
||||
assert.deepEqual(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { ecaz: 5 } }, 'ecaz'), result.casualties!.options);
|
||||
for (const option of result.casualties!.options) assert.equal(2 + 6 - option.normal - option.elite, 5);
|
||||
assert.deepEqual(quoteBattleResolution({ ...input, stoneAllocations: { ecaz: 4 } }).destroyedArmies, ['ecaz', 'ally']);
|
||||
assert.throws(() => quoteStoneBurnerAllocations({ ...input, stoneAllocations: { ally: 5 } }), /original side players/);
|
||||
const canceled = occupyFixture(true);
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(canceled), { choices: [{ player: 'ally', owner: 'ally', totals: [2, 3, 4] }] });
|
||||
assert.deepEqual(quoteBattleResolution({ ...canceled, stoneAllocations: { ally: 3 } }).fixedLosses, []);
|
||||
});
|
||||
|
||||
void test('Ecaz Harass removes only its fixed undialed complement before variable totals, without changing the original mandatory loss', () => {
|
||||
const input = occupyFixture(), own = input.attacker, profile = input.ecazOccupy!;
|
||||
const harass = catalog().find(card => card.id === HARASS_WITHDRAW_CARD)!;
|
||||
own.hand = [...own.hand, harass]; own.plan.defense = harass.id;
|
||||
own.harassWithdraw = { blocked: null, forces: profile.ecazForces,
|
||||
locations: { 'carthag:11': { normal: 5, elite: 0 } },
|
||||
occupy: { cardUser: 'ecaz', ecaz: 'ecaz', ally: 'ally', canceled: false,
|
||||
fixedEcazDial: 3, variableForces: profile.forces } };
|
||||
input.defender.forces = ordinary(4);
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'ecaz', owner: 'ally', totals: [2, 3, 4] }] });
|
||||
const result = quoteBattleResolution({ ...input, stoneAllocations: { ecaz: 3 } });
|
||||
assert.deepEqual(result.harassWithdraw![0].returned, { normal: 2, elite: 0 });
|
||||
assert.deepEqual(result.fixedLosses, [{ owner: 'ecaz', normal: 3, elite: 0 }]);
|
||||
assert.equal(result.casualties!.options.length, 2);
|
||||
assert.deepEqual(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { ecaz: 3 } }, 'ecaz'), result.casualties!.options);
|
||||
for (const option of result.casualties!.options) assert.equal(6 - option.normal - option.elite, 3);
|
||||
});
|
||||
|
||||
void test('temporary elite roles commit physical normal totals and never manufacture starred losses', () => {
|
||||
const input = fixture(); input.attacker.forces = { normal: 6, elite: 0, temporaryElite: 2, eliteStrength: 2, freeSupport: false };
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
|
||||
const result = quoteBattleResolution({ ...input, stoneAllocations: { a: 3 } });
|
||||
assert.deepEqual(result.casualties!.options, [{ normal: 3, elite: 0, paidNormal: 1, paidElite: 0 }]);
|
||||
});
|
||||
|
||||
void test('native World and reconstructed visible Richese pools retain the same physical total rule', () => {
|
||||
const input = fixture(); input.territory = 'homeworld:emperor';
|
||||
input.homeworld = { native: 'a', card: 'kaitain', side: 'high', nativeForces: { normal: 4, elite: 2 } };
|
||||
input.voters = [input.voters[0]];
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
|
||||
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 3 } }).winner, 'a');
|
||||
const revealed = fixture();
|
||||
revealed.defender = combatant('d', 'richese', ordinary(4), 1, 1);
|
||||
revealed.defender.noFieldAtTerritory = false;
|
||||
revealed.participants = [revealed.attacker, revealed.defender].map(side => ({ id: side.id, faction: side.faction, noFieldAtTerritory: false }));
|
||||
assert.deepEqual(quoteStoneBurnerAllocations(revealed), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
|
||||
});
|
||||
|
||||
void test('successful traitors and Lasgun–shield explosions have no allocation opportunity', () => {
|
||||
const input = fixture();
|
||||
input.voters = input.voters.map((voter, index) => index ? voter : {
|
||||
...voter, called: true, traitors: [input.defender.leader!.id] });
|
||||
assert.equal(quoteStoneBurnerAllocations(input), null);
|
||||
assert.equal(quoteBattleResolution(input).result, 'traitor');
|
||||
const explosion = fixture(), cards = catalog(), shield = cards.find(card => card.kind === 'shield')!, lasgun = cards.find(card => card.kind === 'lasgun')!;
|
||||
explosion.attacker.hand = [...explosion.attacker.hand, shield]; explosion.attacker.plan.defense = shield.id;
|
||||
explosion.defender.hand = [lasgun]; explosion.defender.plan.weapon = lasgun.id;
|
||||
assert.equal(quoteStoneBurnerAllocations(explosion), null);
|
||||
assert.equal(quoteBattleResolution(explosion).result, 'explosion');
|
||||
});
|
||||
|
||||
void test('invalid totals, unrelated keys, missing modes and unfunded support fail before offering an allocation', () => {
|
||||
const input = fixture();
|
||||
const invalid: Record<string, number>[] = [{ a: 1 }, { a: 2.5 }, { d: 3 }, { unrelated: 2 }];
|
||||
for (const stoneAllocations of invalid)
|
||||
assert.throws(() => quoteStoneBurnerAllocations({ ...input, stoneAllocations }), BattleResolutionQuoteError);
|
||||
assert.throws(() => quoteStoneBurnerAllocations({ ...input, stoneAllocations: Object.create({ a: 3 }) as Record<string, number> }), /original side players/);
|
||||
input.attacker.spice = 0;
|
||||
assert.throws(() => quoteStoneBurnerAllocations(input), /funded/);
|
||||
input.attacker.spice = 10; delete input.attacker.stoneMode;
|
||||
assert.throws(() => quoteStoneBurnerAllocations(input), /revealed mode/);
|
||||
});
|
||||
|
||||
void test('new-battle precommit opt-in removes only the old timing boundary and retains invalid-force/dial/zero-spice plan checks', () => {
|
||||
const input = fixture();
|
||||
assert.ok(stoneBurnerPlanBlock(input.attacker.forces, 3, 1, input.defender.forces, 'attacker'));
|
||||
assert.equal(stoneBurnerPlanBlock(input.attacker.forces, 3, 1, input.defender.forces, 'attacker', 'attacker', undefined, true), null);
|
||||
assert.ok(stoneBurnerPlanBlock(input.attacker.forces, 99, 1, input.defender.forces, 'attacker', 'attacker', undefined, true));
|
||||
assert.ok(stoneBurnerPlanBlock({ ...input.attacker.forces, normal: -1 }, 3, 1, input.defender.forces, 'attacker', 'attacker', undefined, true));
|
||||
assert.ok(stoneBurnerCompulsionBlock(ordinary(0), emperor, 'attacker'));
|
||||
assert.equal(stoneBurnerCompulsionBlock(ordinary(0), emperor, 'attacker', 'attacker', undefined, true), null);
|
||||
});
|
||||
@@ -90,10 +90,11 @@ void test('all profiles offer guarded low-dial Stone plans on both sides in Basi
|
||||
}
|
||||
});
|
||||
|
||||
void test('every public pool is checked and blocked, missing or noncanonical Stone inputs are not emitted', () => {
|
||||
void test('legacy battles check every public pool and blocked, missing or noncanonical Stone inputs are not emitted', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const g = fixture(true, true),
|
||||
v = view(g, difficulty);
|
||||
v.battle!.stoneBurnerContext!.allowAllocations = false;
|
||||
v.battle!.ownForces = {
|
||||
normal: 2,
|
||||
elite: 1,
|
||||
|
||||
@@ -147,7 +147,7 @@ void test('forged Stone roles reject before any hidden plan commitment', () => {
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced Emperor outcome-changing allocation is rejected identically before or after an unrelated hidden opposing plan', () => {
|
||||
void test('legacy Advanced Emperor guard remains hidden-plan independent while new battles admit later physical commitments', () => {
|
||||
let g = fixture();
|
||||
g.advanced = true;
|
||||
g.players[0].faction = 'emperor';
|
||||
@@ -162,6 +162,9 @@ void test('Advanced Emperor outcome-changing allocation is rejected identically
|
||||
revived: 0,
|
||||
};
|
||||
g = prepare(start(g));
|
||||
for (const state of [g, ...[0, 1, 2].map(dial => plan(g, 'g', {dial}))])
|
||||
assert.ok(plan(state, 'r', {dial: 3, support: 1}).battle!.plans.r);
|
||||
delete g.battle!.stoneAllocationVersion;
|
||||
const attempt = (state: Game) => {
|
||||
try {
|
||||
plan(state, 'r', { dial: 3, support: 1 });
|
||||
@@ -181,7 +184,7 @@ void test('Advanced Emperor outcome-changing allocation is rejected identically
|
||||
assert.ok(valid.battle!.plans.r);
|
||||
});
|
||||
|
||||
void test('public zero-spice compulsion guard is independent of Stone possession and leaves ordinary Voice decline available', () => {
|
||||
void test('legacy zero-spice compulsion guard remains custody independent while new Stone has a finishable later commitment', () => {
|
||||
let g = fixture();
|
||||
g.advanced = true;
|
||||
g.players[0].forces = { 'arrakeen:10': 1 };
|
||||
@@ -201,6 +204,11 @@ void test('public zero-spice compulsion guard is independent of Stone possession
|
||||
g.players[2].ally = 'g';
|
||||
g.players[1].ally = 'e';
|
||||
g = start(g);
|
||||
const withoutStone = reload(g);
|
||||
withoutStone.richeseCache!.push(withoutStone.players[0].hand.pop()!);
|
||||
for (const state of [g, withoutStone]) assert.doesNotThrow(() =>
|
||||
send(state, 'e', {type: 'voice', kind: 'stoneBurner', must: true}));
|
||||
delete g.battle!.stoneAllocationVersion;
|
||||
const error = (state: Game) => {
|
||||
try {
|
||||
send(state, 'e', { type: 'voice', kind: 'stoneBurner', must: true });
|
||||
@@ -220,7 +228,7 @@ void test('public zero-spice compulsion guard is independent of Stone possession
|
||||
assert.ok(plan(g, 'r', { weapon: null, dial: 0 }).battle!.plans.r);
|
||||
});
|
||||
|
||||
void test('required Mirror Voice rejects unfinishable public zero-spice plans without testing card possession', () => {
|
||||
void test('required Mirror Voice preserves the legacy guard and new count capability without testing card possession', () => {
|
||||
let g = fixture();
|
||||
g.advanced = true;
|
||||
g.richeseCache!.push(g.players[0].hand.pop()!);
|
||||
@@ -242,6 +250,10 @@ void test('required Mirror Voice rejects unfinishable public zero-spice plans wi
|
||||
const withoutMirror = reload(g);
|
||||
withoutMirror.richeseCache!.push(withoutMirror.players[0].hand.pop()!);
|
||||
for (const state of [g, withoutMirror]) {
|
||||
assert.doesNotThrow(() => send(state, 'e', {
|
||||
type: 'voice', kind: 'mirrorWeapon', must: true,
|
||||
}));
|
||||
delete state.battle!.stoneAllocationVersion;
|
||||
const before = structuredClone(state);
|
||||
assert.throws(() => send(state, 'e', {
|
||||
type: 'voice', kind: 'mirrorWeapon', must: true,
|
||||
|
||||
Reference in new issue
Block a user