Connect Harass & Withdraw to the battle preview
The independent Ecaz Treachery variant now supports Harass in either physical battle-card slot. A category-null Prescience answer keeps its identity private without erasing the selected card. Exact undialed normal/elite counters return before battle losses; opposing successful Traitors cancel withdrawal, ordinary leader deaths still resolve, and the card is discarded exactly once. Controls, all four AI profiles and saved continuation share the same physical quote. Existing Planetologist inspection and literal Truthtrance slot promises retain their meanings. Unique Basic/Advanced physical allocations are supported. Ambiguous allocations, co-present Ecaz allied armies on either side, Richese card-family configurations and other optional modules remain gated before sealing from public/own context. The pending Stone Burner timing question is not assumed answered. Reveal-before-explosion order and mandatory disposal on an own-Traitor win are explicit source-timing/specificity inferences, consistent with existing Planetologist precedence. Reinforcements and full variant/public mode acceptance remain unfinished. Independent rules/privacy/persistence review fixed asymmetric selector legality, misleading blocked-card guidance, invalid restored battle pairs, native-only inspection bypass and unsupported allied-army admission. Ixian fixtures preserve all seven physical cyborgs. The first broad run caught a semantic-status lint error in the blocked guidance; replacing the paragraph role with an output element fixed it. A subsequent broad run passed 4,775 of 4,776 tests and exposed an old UI assertion requiring category-null slots to be disabled. The updated regression verifies enabled category-null selectors exclude ordinary held cards, while exact leader/dial locks and submitted values remain bound; focused controls pass 14/14. No game source changed after the eight samples or browser replay. Types, lint, 4776 offline tests, build and 43 HTTP tests pass with unchanged source. Authenticated SQLite cases verify sealed private restoration and competing duplicate traitor actions with one physical resolution. Eight fixed genuine six-seat games finish: basic/Easy: 1803 actions, 0 Harass plans, 0 withdrawals, 48 periodic restores; basic/Medium: 225 actions, 0 Harass plans, 0 withdrawals, 6 periodic restores; basic/Hard: 2184 actions, 1 Harass plans, 1 withdrawals, 59 periodic restores; basic/Brutal: 2073 actions, 1 Harass plans, 1 withdrawals, 56 periodic restores; advanced/Easy: 2057 actions, 1 Harass plans, 1 withdrawals, 55 periodic restores; advanced/Medium: 532 actions, 0 Harass plans, 0 withdrawals, 14 periodic restores; advanced/Hard: 538 actions, 0 Harass plans, 0 withdrawals, 14 periodic restores; advanced/Brutal: 544 actions, 0 Harass plans, 0 withdrawals, 14 periodic restores. All 218 game-source files match their source-stable runs; exact card/traitor/force custody and periodic/terminal private views are checked. Browser controls verify the full card, category None, Defense-first legality, actual sealing/reveal, four returned forces, ordinary leader death, disposal and refresh. A separate final-source replay verifies the conserved browser fixture and opposing continuation, including Face Dancer replacement. This is bounded playability evidence, not full compliance or AI calibration. All 936 opening games and their original seat/recovery records remain unchanged (970 after checks); all 937 present immediately before checks are also preserved. Database integrity and foreign keys pass. The healthy server was reused after the power failure without restart, reset or recurring automation. Verified source tree: be193613c455c5b76b49800fe3a7400504ea879af34c59621ade3d44f0fe019d
This commit is contained in:
1 parent
c04cb93830
commit
04501567d0
26 files changed
+2164
-127
No files matched your search
@@ -13,6 +13,32 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Action, GameView, PlanField } from '@/game/engine';
|
||||
import type { CommittedPlanElement } from '@/game/battle-inspections';
|
||||
import {
|
||||
battleCardSlotEligible,
|
||||
fixedBattleInspectionMatches,
|
||||
validBattleSlotPair,
|
||||
type BattleCardSlot,
|
||||
} from '@/game/battle-card-slots';
|
||||
import { canUsePlanetologistBattleSpecial } from '@/game/leader-skill-combat';
|
||||
|
||||
/** Both selectors evaluate the complete physical pair, in either selection order. */
|
||||
export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, leader: string, weapon: string, defense: string) {
|
||||
const me = game.players.find(player => player.id === game.me)!;
|
||||
const fixed = battlePlanCommitments(game)[slot];
|
||||
return (me.hand ?? []).filter(card => {
|
||||
const w = slot === 'weapon' ? card : me.hand?.find(c => c.id === weapon);
|
||||
const d = slot === 'defense' ? card : me.hand?.find(c => c.id === defense);
|
||||
const planetologistWeapon = canUsePlanetologistBattleSpecial({
|
||||
assignments: game.leaderSkills?.assignments.filter(a => a.controller === game.me) ?? [],
|
||||
selectedLeader: leader, card: w,
|
||||
});
|
||||
return card.id !== 'ecaz-reinforcements' &&
|
||||
(card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked)) &&
|
||||
battleCardSlotEligible(slot, card, { planetologistWeapon }) &&
|
||||
(!fixed || fixedBattleInspectionMatches(slot, fixed.value, card.id, card)) &&
|
||||
validBattleSlotPair(w, d, planetologistWeapon);
|
||||
});
|
||||
}
|
||||
|
||||
/** Read only the recipient's projected commitments, including an explicit null. */
|
||||
export function battlePlanCommitments(game: GameView): Partial<Record<PlanField, CommittedPlanElement>> {
|
||||
@@ -30,7 +56,19 @@ export function battlePlanCommitments(game: GameView): Partial<Record<PlanField,
|
||||
|
||||
export function bindBattlePlanCommitments(game: GameView, plan: Action): Action {
|
||||
if (plan.type !== 'battlePlan') return plan;
|
||||
return { ...plan, ...Object.fromEntries(Object.values(battlePlanCommitments(game)).map((item) => [item.field, item.value])) };
|
||||
return { ...plan, ...Object.fromEntries(Object.values(battlePlanCommitments(game)).map((item) => [item.field,
|
||||
item.field === 'weapon' || item.field === 'defense'
|
||||
? battlePlanCardValue(game, item.field, typeof plan[item.field] === 'string' ? plan[item.field] as string : '') || null
|
||||
: item.value])) };
|
||||
}
|
||||
|
||||
/** An inspected absence of a category still permits a legal slot-only Special. */
|
||||
export function battlePlanCardValue(game: GameView, slot: BattleCardSlot, selected: string): string {
|
||||
const commitment = battlePlanCommitments(game)[slot];
|
||||
if (!commitment) return selected;
|
||||
if (commitment.value !== null) return String(commitment.value);
|
||||
const card = game.players.find((player) => player.id === game.me)?.hand?.find((card) => card.id === selected);
|
||||
return card && fixedBattleInspectionMatches(slot, null, card.id, card) ? card.id : '';
|
||||
}
|
||||
|
||||
export function battlePreparationField(game: GameView): PlanField | null {
|
||||
@@ -222,6 +260,12 @@ export function BattlePreparation({
|
||||
{beneficiary.name} will see this element. You may still change the rest
|
||||
of your plan before sealing it.
|
||||
</p>
|
||||
{(field === 'weapon' || field === 'defense') && b.harassWithdraw && !b.harassWithdraw.blocked && (
|
||||
<p className="fine">
|
||||
Harass & Withdraw is neither a weapon nor a defense. Choose None
|
||||
for the category it will occupy; its identity stays private until plans are revealed.
|
||||
</p>
|
||||
)}
|
||||
{b.voice && (
|
||||
<p className="notice">
|
||||
Voice: {b.voice.must ? 'must play' : 'cannot play'}{' '}
|
||||
|
||||
+26
-18
@@ -117,10 +117,9 @@ import { FaceDanceDecision } from './face-dance-decision';
|
||||
import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors';
|
||||
import {
|
||||
battleCardLabel,
|
||||
isWeaponCard,
|
||||
isStoneBurner,
|
||||
isDefenseCard,
|
||||
} from '@/game/battle-cards';
|
||||
import { validBattleSlotPair } from '@/game/battle-card-slots';
|
||||
import { stoneBurnerPlanBlock } from '@/game/stone-burner';
|
||||
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
|
||||
import Link from 'next/link';
|
||||
@@ -152,10 +151,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { BattlePreparation, battlePlanCommitments, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation';
|
||||
import { BattlePreparation, battlePlanCommitments, battlePlanCardValue, battlePlanCardOptions, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation';
|
||||
import { HarassWithdrawGuide, harassWithdrawControlState } from './harass-withdraw';
|
||||
import { PrivateBattlePlan } from './private-battle-plan';
|
||||
import { SpiceBankerControl, spiceBankerControlState } from './spice-banker';
|
||||
import { canUsePlanetologistBattleSpecial, validLeaderSkillBattleCardPair } from '../game/leader-skill-combat';
|
||||
import { canUsePlanetologistBattleSpecial } from '../game/leader-skill-combat';
|
||||
import type { Card } from '../game/cards';
|
||||
import { RevealedBattle } from './revealed-battle';
|
||||
import { DiplomatDefense } from './diplomat-defense';
|
||||
@@ -463,10 +463,19 @@ export function GameTable({
|
||||
selectedLeader: committed.leader ? String(committed.leader.value ?? '') : leader,
|
||||
card,
|
||||
});
|
||||
const selectedBattleWeapon = me.hand?.find(c => c.id === (committed.weapon ? committed.weapon.value : weapon));
|
||||
const selectedWeaponId = battlePlanCardValue(g, 'weapon', weapon);
|
||||
const selectedDefenseId = battlePlanCardValue(g, 'defense', defense);
|
||||
const selectedBattleWeapon = me.hand?.find(c => c.id === selectedWeaponId);
|
||||
const selectedBattleDefense = me.hand?.find(c => c.id === selectedDefenseId);
|
||||
const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense,
|
||||
!!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon));
|
||||
const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw,
|
||||
selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw',
|
||||
committed.dial ? Number(committed.dial.value) : battleDial,
|
||||
me.faction === 'fremen' && !g.battle?.fremenSupportBlocked ? 0 : battleSupport);
|
||||
const selectedStone = me.hand?.find(
|
||||
(c) =>
|
||||
c.id === (committed.weapon ? committed.weapon.value : weapon) &&
|
||||
c.id === selectedWeaponId &&
|
||||
isStoneBurner(c),
|
||||
);
|
||||
const stoneContext = g.battle?.stoneBurnerContext;
|
||||
@@ -4579,24 +4588,19 @@ export function GameTable({
|
||||
<select
|
||||
id={`battle-${String(name).toLowerCase()}`}
|
||||
value={
|
||||
committed[name === 'Weapon' ? 'weapon' : 'defense']
|
||||
? String(committed[name === 'Weapon' ? 'weapon' : 'defense']!.value ?? '')
|
||||
: (v as string)
|
||||
battlePlanCardValue(g, name === 'Weapon' ? 'weapon' : 'defense', v as string)
|
||||
}
|
||||
disabled={
|
||||
!!committed[name === 'Weapon' ? 'weapon' : 'defense']
|
||||
!!committed[name === 'Weapon' ? 'weapon' : 'defense'] &&
|
||||
committed[name === 'Weapon' ? 'weapon' : 'defense']!.value !== null
|
||||
}
|
||||
onChange={(e) =>
|
||||
(fn as (s: string) => void)(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{me.hand
|
||||
?.filter((c) =>
|
||||
name === 'Defense'
|
||||
? isDefenseCard(c) && (!selectedBattleWeapon || !planetologistSpecial(selectedBattleWeapon) || validLeaderSkillBattleCardPair(selectedBattleWeapon,c,true))
|
||||
: isWeaponCard(c) || planetologistSpecial(c),
|
||||
)
|
||||
{battlePlanCardOptions(g, name === 'Weapon' ? 'weapon' : 'defense',
|
||||
selectedBattleLeader, selectedWeaponId, selectedDefenseId)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}{name === 'Weapon' && planetologistSpecial(c) ? ' · Planetologist +2, discard after battle' : ''}
|
||||
@@ -4605,6 +4609,8 @@ export function GameTable({
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
<HarassWithdrawGuide preview={g.battle.harassWithdraw} state={harassControl} />
|
||||
{!battlePairValid && <p role="status" className="notice">Choose two compatible battle cards. A single physical card cannot fill both slots.</p>}
|
||||
{selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon) && (
|
||||
<p className="notice">
|
||||
{selectedBattleWeapon.name} adds 2 to the surviving Planetologist leader.
|
||||
@@ -4691,7 +4697,7 @@ export function GameTable({
|
||||
weapon,
|
||||
defense,
|
||||
}),
|
||||
!!stonePlanReason,
|
||||
!!stonePlanReason || !!harassControl.blocked || !battlePairValid,
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@@ -4814,7 +4820,9 @@ export function GameTable({
|
||||
(ecaz
|
||||
? ecaz.card.effect === 'recruits'
|
||||
? g.recruitsPreview?.play?.blocked ?? 'Use the Recruits panel during Revival to play this card.'
|
||||
: 'This card’s battle effect is still being implemented.'
|
||||
: ecaz.card.effect === 'harassWithdraw'
|
||||
? g.battle?.harassWithdraw?.blocked ?? 'Use either battle-card slot to play Harass & Withdraw.'
|
||||
: 'This card’s battle effect is still being implemented.'
|
||||
: null) ??
|
||||
(richese?.card.effect === 'distrans'
|
||||
? 'Use the Distrans transfer panel above to choose a recipient and another card.'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { quoteHarassWithdraw, type HarassWithdrawPreview } from '@/game/harass-withdraw';
|
||||
|
||||
export function harassWithdrawControlState(
|
||||
preview: HarassWithdrawPreview | null | undefined,
|
||||
selected: boolean,
|
||||
dial: number,
|
||||
support: number,
|
||||
) {
|
||||
if (!selected) return { blocked: null, returned: null };
|
||||
if (!preview) return { blocked: 'Harass & Withdraw is not available in this battle.', returned: null };
|
||||
try {
|
||||
return { blocked: null, returned: quoteHarassWithdraw(preview, dial, support).returned };
|
||||
} catch (error) {
|
||||
return { blocked: error instanceof Error ? error.message : 'Choose a supported force commitment.', returned: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function HarassWithdrawGuide({
|
||||
preview, state,
|
||||
}: {
|
||||
preview: HarassWithdrawPreview | null | undefined;
|
||||
state: ReturnType<typeof harassWithdrawControlState>;
|
||||
}) {
|
||||
if (!preview) return null;
|
||||
return <section aria-label="Harass and Withdraw battle guidance" className="notice space-y-2">
|
||||
<h4>Harass & Withdraw</h4>
|
||||
{preview.blocked || state.blocked
|
||||
? <output className="block">{preview.blocked ?? state.blocked}</output>
|
||||
: state.returned
|
||||
? <p>Return {state.returned.normal} ordinary and {state.returned.elite} elite undialed forces to reserves when plans resolve.</p>
|
||||
: <p>Select this card in either battle-card slot. Your undialed forces return to reserves.</p>}
|
||||
<p>Your leader can still die. An opponent’s successful traitor call cancels the withdrawal. Discard this card after use.</p>
|
||||
</section>;
|
||||
}
|
||||
+20
-30
@@ -6,38 +6,28 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Previous verified checkpoint: `9ca17a6`, [Fremen/Ecaz final-turn victory](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up)
|
||||
and [deferred shipment repair](DEFERRED_SHIPMENT_ARRIVAL.md). Types, lint,
|
||||
**4,716 offline tests**, build and **43 HTTP tests** passed. All 835 opening games
|
||||
and the 869 present after the outage, including their seat/recovery records,
|
||||
were preserved (902 after verification). Three source-stable samples finish,
|
||||
including the exact failed shipment resumption; full scope remains unfinished.
|
||||
Detailed earlier capabilities and evidence are linked below and retained in the
|
||||
[implementation history](IMPLEMENTATION_STATUS.md).
|
||||
Previous verified checkpoint: `c04cb93`, [Recruits](RECRUITS_RUNTIME.md).
|
||||
Types, lint, **4,743 offline tests**, build and **43 HTTP tests** passed.
|
||||
Eight fixed Basic/Advanced six-seat samples finished in 7,995 actions with no
|
||||
rejected candidates and three actual Recruits plays. All opening games and
|
||||
seat/recovery records were preserved, with 936 rooms after verification.
|
||||
Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md).
|
||||
|
||||
Current prototype: [Recruits](RECRUITS_RUNTIME.md) connects the independent complete
|
||||
three-card inventory before genuine Basic/Advanced faction setup, owned card
|
||||
play/discard, public current revival rates, existing CHOAM/Tleilaxu decisions,
|
||||
four AI profiles and saved continuation. Exact free usage and physical custody
|
||||
are retained. Prior-free activation is supported; prior-paid or unknown accounting,
|
||||
later grants, pending repricing, same-turn replay and other optional modules stay
|
||||
gated. Reinforcements and Harass & Withdraw are present but their effects remain
|
||||
unfinished. Independent-cap precedence is explicitly labeled as an inference.
|
||||
|
||||
Independent review's 37-case union and four authenticated recovery cases pass.
|
||||
Eight fixed Basic/Advanced six-seat samples finish in 7,995 actions with no
|
||||
rejected candidates and three actual Recruits plays. Exact custody, 212 periodic
|
||||
all-seat JSON restores and terminal private views pass. A separate conserved
|
||||
browser fixture verifies inspection, play/discard, public rates, four free returns
|
||||
and refresh. Final broad-check totals, source fingerprints and Git delivery are
|
||||
recorded with the checkpoint; these are bounded playability results.
|
||||
Current prototype: [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects either
|
||||
battle-card slot, private category inspections, exact undialed normal/elite
|
||||
returns, opponent-Traitor cancellation, mandatory disposal, controls and all
|
||||
four AI profiles. Physical allocation must be unique. Richese card combinations
|
||||
and additional optional modules stay gated. Withdrawal before explosion and
|
||||
card-specific discard precedence are explicitly labeled inferences. The Stone
|
||||
Burner timing question is pending; no user answer is assumed. Reinforcements
|
||||
remains unfinished. This does not open variant, mode or publication gates.
|
||||
|
||||
After the latest reported power failure, the existing server responds normally.
|
||||
All **902 rooms**, **1,888 seats** and original recovery/entry/handover records
|
||||
match the opening backup; SQLite integrity and foreign-key checks pass. No server
|
||||
restart, database reset or recurring automation is needed. Final review, sample and
|
||||
browser evidence, broad verification and Git delivery are recorded with this
|
||||
checkpoint before handoff. Public mode and publication gates remain closed.
|
||||
All **936 rooms**, **1,959 seats** and original recovery/entry/handover records
|
||||
match the opening backup; SQLite integrity and foreign-key checks pass. Browser
|
||||
refresh restores the existing test seat. No restart, database reset or recurring
|
||||
automation was needed. Current checkpoint verification and delivery are recorded
|
||||
with the final source-bound report and Git message.
|
||||
|
||||
## Authoritative navigation
|
||||
|
||||
@@ -68,7 +58,7 @@ These links define bounded working behavior, not complete module certification.
|
||||
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
|
||||
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
|
||||
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
|
||||
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play in its independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
|
||||
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects the bounded battle return in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
|
||||
| Multiplayer | Saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
|
||||
|
||||
## Remaining readiness
|
||||
|
||||
@@ -83,3 +83,10 @@ A persistent battle-resolution continuation should preserve withdrawal intent th
|
||||
- Harass & Withdraw: winner and loser, zero dial, Traitor cancellation, returned reserves versus Face Dancer replacement, independent ally groups and prohibited own-Homeworld use.
|
||||
|
||||
No runtime tests were added or claimed for this source task. At this source audit’s initial snapshot the repository had no factory entries for these names. The subsequent isolated definitions and inspector guides in `game/ecaz-cards.ts` are now tracked in `CARD_INVENTORY.md`; they do not activate the variant. Existing revival and battle helpers remain integration foundations, not evidence of complete card effects.
|
||||
|
||||
## 14 September runtime follow-up
|
||||
|
||||
[Recruits](RECRUITS_RUNTIME.md) and [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md)
|
||||
now have connected development prototypes. The linked contracts supersede the
|
||||
historical runtime-absence statements above within their explicit boundaries.
|
||||
Reinforcements and complete combined-module acceptance remain unfinished.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Harass & Withdraw development prototype
|
||||
|
||||
Updated 14 September 2026. This connects the physical card to battle plans,
|
||||
private inspections, force returns, controls, AI and saved continuation. It does
|
||||
not complete the independent three-card variant or open public mode gates.
|
||||
|
||||
## Authority and interpretation
|
||||
|
||||
The [Ecaz card audit](ECAZ_TREACHERY_RULES.md) records the inspected physical face,
|
||||
designer walkthrough and publisher rules. The [GF9 Ecaz & Moritani
|
||||
rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf),
|
||||
printed pp. 11 and 16, establishes the slot/category distinction, mandatory
|
||||
after-use disposal and the restriction to the card user's undialed forces.
|
||||
The [designer walkthrough at 9:11](https://www.youtube.com/watch?v=iF5E3-LOfhY&t=551s)
|
||||
and [physical close-up at 4:28](https://www.youtube.com/watch?v=P9ZTV1uZqsM&t=268s)
|
||||
establish reveal timing, opponent-Traitor cancellation, ordinary leader death,
|
||||
Face Dancer protection for returned forces and the own-Homeworld prohibition.
|
||||
|
||||
Two implementation inferences are explicit:
|
||||
|
||||
- Withdrawal precedes weapon/explosion losses because it acts during wheel
|
||||
reveal. Execution waits atomically for Traitor declarations so a successful
|
||||
opposing call cancels the return without reversing a previous resource change.
|
||||
No retrieved publisher FAQ directly answers the explosion combination.
|
||||
- Discard the card after use even when its owner wins through a Traitor call.
|
||||
The later card-specific instruction overrides ordinary winner retention,
|
||||
using the same recorded precedence as [Planetologist](PLANETOLOGIST_RULES.md#mandatory-discard).
|
||||
This is a specificity inference, not a dedicated Harass FAQ.
|
||||
|
||||
Planetologist's existing inspection answers remain unchanged. Ecaz's explicit
|
||||
instruction to report no weapon/defense is not silently extended to other
|
||||
slot substitutions.
|
||||
|
||||
## Connected behavior
|
||||
|
||||
Use the existing independent Ecaz card development setup. All three cards enter
|
||||
the physical deck before genuine Basic or Advanced setup; selecting Ecaz alone
|
||||
does not enable this variant. The owner may place Harass in either card slot.
|
||||
It supplies no weapon attack or defense, cannot occupy both slots, and cannot
|
||||
supply the actual opposite category needed by Chemistry or Weirding Way's
|
||||
alternate role. Ordinary leader and Voice requirements remain in force.
|
||||
|
||||
Atreides receives a category-null answer without learning the physical card's
|
||||
identity. The owner can retain a slot-only card after that answer. The sealed
|
||||
plan still contains its exact ID. Complete-plan inspection reveals the actual
|
||||
physical plan; Truthtrance's existing literal slot-occupancy promises keep their
|
||||
meaning. An empty-category answer is not a promise that the physical slot is empty.
|
||||
|
||||
The shared physical quote derives dialed counters from `casualtyOptions`, then
|
||||
returns the undialed normal and elite counters to their owner's reserves. The
|
||||
leader resolves normally. An opponent's successful Traitor call, including mutual
|
||||
calls, cancels withdrawal. Returned reserves are outside subsequent board losses
|
||||
and Face Dancer replacement. Harass is discarded once and excluded from ordinary
|
||||
winner retention and Moritani ally retention.
|
||||
|
||||
Both selectors enforce the complete pair in either selection order. The private
|
||||
guide shows the exact return and a reason for an unsupported choice. All four AI
|
||||
profiles use the same physical quote and category-aware inspection comparison;
|
||||
the card adds no fictitious weapon or defense value. Stronger profiles value
|
||||
saved forces when expecting defeat and discourage use when expecting to win.
|
||||
This is a legal strategy path, not strength calibration.
|
||||
|
||||
## Current boundaries
|
||||
|
||||
- Basic and Advanced physical allocations must identify one normal/elite
|
||||
commitment and one return allocation among sectors. Zero/all returns and
|
||||
unique typed allocations are supported. Ambiguous allocations reject before
|
||||
sealing; explicit private allocation controls remain unfinished.
|
||||
- Additional optional modules remain gated, including Homeworlds, Nexus,
|
||||
Leader Skills, Tech Tokens, Stronghold Cards and Discoveries. This includes
|
||||
the printed own-Homeworld prohibition without claiming other Homeworld
|
||||
battles are already implemented for this card.
|
||||
- Richese card-family configurations remain gated because Stone Burner may
|
||||
compare original or post-withdrawal undialed forces, which can reverse the
|
||||
winner. A user question has been sent; no answer is assumed. The guard uses
|
||||
public configuration, never Stone Burner's hidden custody. CHOAM without
|
||||
Richese remains eligible. This is a development boundary, not a printed ban.
|
||||
- Co-present reciprocal Ecaz allied armies on either side reject before
|
||||
sealing; other faction integration gates remain in force. This prototype does not settle Ecaz's undialed allied support allocation.
|
||||
- Reinforcements remains unavailable pending its separate cost and outcome
|
||||
accounting. Neither slot recognition nor card text proves its effect complete.
|
||||
|
||||
## Verification and preservation
|
||||
|
||||
Focused rules, controls, AI and authenticated recovery suites record the exact
|
||||
supported paths. Fixtures explicitly distinguish genuine variant setup from
|
||||
later conserved battle staging. Independent review precedes final complete-game
|
||||
samples and browser checks. Final commands and source fingerprints are retained
|
||||
in the private checkpoint report and Git message; a sample does not certify
|
||||
every faction or expansion combination.
|
||||
|
||||
The opening baseline contains 936 rooms and 1,959 seats. Every room and original
|
||||
seat/recovery/entry/handover record survived the reported outage unchanged;
|
||||
SQLite integrity and foreign-key checks passed. Browser refresh restored the
|
||||
existing test seat. The server was reused without a reset or scheduled restart.
|
||||
|
||||
The isolated browser room used a genuine three-seat Emperor/Atreides/Tleilaxu
|
||||
setup, followed by an explicitly conserved battle fixture. Actual controls
|
||||
verified full-card inspection, the category-None answer, Defense-first selection
|
||||
without a duplicate Harass or alternate Chemistry weapon, a one-force plan,
|
||||
public reveal and the owner’s traitor decline. Four forces returned; one went to
|
||||
the Tanks and the leader died. Opposing decisions completed through the engine,
|
||||
including a genuine Face Dancer replacement of the other army. Refresh restored
|
||||
the collection-phase state, exact private views and the single discarded Harass.
|
||||
The private script’s first comparison failed only because its expected version
|
||||
omitted the HTTP layer’s increment; the preserved failure was corrected without
|
||||
changing production code or overwriting a game.
|
||||
@@ -1,5 +1,23 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 14 September 2026 — Harass & Withdraw battle prototype
|
||||
|
||||
[Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) now connects either physical card
|
||||
slot, category-aware private inspections, unique typed undialed returns before
|
||||
battle losses, successful opponent-Traitor cancellation and mandatory disposal.
|
||||
Both selectors, all four AI profiles and saved continuation use the shared
|
||||
physical quote. Existing Planetologist inspection and literal Truthtrance slot
|
||||
promises remain unchanged. Richese/Stone Burner timing, ambiguous allocations
|
||||
and optional-module combinations remain gated; public starts stay closed.
|
||||
|
||||
Focused rules, controls, bot and authenticated recovery evidence is paired with
|
||||
independent review. Final complete-game samples, browser checks, preservation,
|
||||
required broad checks and Git delivery are recorded in the source-bound checkpoint
|
||||
report and commit message. This is a connected prototype, not full rules certification.
|
||||
All 936 opening games and 1,959 seats survived the reported outage unchanged; the
|
||||
healthy server was reused without resetting saved state or recreating automation.
|
||||
|
||||
|
||||
## 14 September 2026 — Recruits independent-card preview
|
||||
|
||||
[Recruits](RECRUITS_RUNTIME.md) is connected in an explicit development preview.
|
||||
|
||||
@@ -6,6 +6,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
|
||||
|
||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||
| --- | --- | --- |
|
||||
| Harass & Withdraw | Either slot but neither category; private Prescience answers null. Return uniquely determined own undialed physical forces before weapon losses, canceled by an opposing successful Traitor. Mandatory after-use discard overrides winner retention. Explosion ordering and own-Traitor disposal are explicit timing/specificity inferences, consistent with the recorded Planetologist precedent. | [Runtime and sources](HARASS_WITHDRAW_RUNTIME.md) |
|
||||
| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) |
|
||||
| Ixian Technology on Richese lots | Replacement-card ownership, income and unsold disposition remain unresolved. A saved explicit per-lot decline permits an unchanged offer to proceed and leaves Technology available later. This development continuation does not implement the exchange or settle its custody. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline) |
|
||||
| Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) |
|
||||
@@ -41,6 +42,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
||||
|
||||
| Boundary | Still unresolved | Existing record |
|
||||
| --- | --- | --- |
|
||||
| Harass / Stone Burner | A user question has been sent: keep the combination gated or compare forces remaining after withdrawal. No answer is assumed. The Richese card-family configuration is guarded publicly; hidden Stone Burner custody never controls eligibility. Ambiguous physical allocations and other optional modules remain unfinished. | [Current boundaries](HARASS_WITHDRAW_RUNTIME.md#current-boundaries) |
|
||||
| Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) |
|
||||
| Ambassador/Terror arrival priority | Both optionally react to the same entry, but no publisher/designer priority was found. The order can change Fremen relocation versus Atomics losses. A user question has been sent: keep the overlap gated pending an official ruling, or use explicitly provisional storm order. No answer is assumed; Baliset remains a separate pre-entry prevention effect. | [Reproduction and source boundary](DEFERRED_MOVEMENT_ARRIVAL.md) |
|
||||
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and remaining battle timing stay guarded. Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Card } from './cards';
|
||||
import { isDefenseCard, isWeaponCard } from './battle-cards';
|
||||
import { ecazTreacheryDefinition } from './ecaz-cards';
|
||||
import {
|
||||
isPlanetologistBattleSpecialCard,
|
||||
validLeaderSkillBattleCardPair,
|
||||
} from './leader-skill-combat';
|
||||
|
||||
export type BattleCardSlot = 'weapon' | 'defense';
|
||||
export type BattlePlanInspectionField = BattleCardSlot | 'leader' | 'dial';
|
||||
export type BattleCategoryInspectionValue = string | number | null;
|
||||
|
||||
function canonicalEcazBattleSpecial(card: Card | undefined) {
|
||||
if (
|
||||
!card ||
|
||||
Object.keys(card).sort().join(',') !== 'effect,id,kind,name'
|
||||
)
|
||||
return false;
|
||||
const definition = ecazTreacheryDefinition(card);
|
||||
return (
|
||||
definition?.card.effect === 'reinforcements' ||
|
||||
definition?.card.effect === 'harassWithdraw'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-slot role eligibility only. The caller still applies paired-card rules
|
||||
* such as Chemistry/Weirding Way dependencies and validates the selected
|
||||
* Planetologist leader before setting planetologistWeapon.
|
||||
*/
|
||||
export function battleCardSlotEligible(
|
||||
slot: BattleCardSlot,
|
||||
card: Card | undefined,
|
||||
options: Readonly<{ planetologistWeapon?: boolean }> = {},
|
||||
): boolean {
|
||||
if (!card) return true;
|
||||
if (canonicalEcazBattleSpecial(card)) return true;
|
||||
if (slot === 'defense') return isDefenseCard(card);
|
||||
return (
|
||||
isWeaponCard(card) ||
|
||||
(!!options.planetologistWeapon && isPlanetologistBattleSpecialCard(card))
|
||||
);
|
||||
}
|
||||
|
||||
/** Slot-only specials do not supply the opposite category for alternate roles. */
|
||||
export function validBattleSlotPair(
|
||||
weapon?: Card,
|
||||
defense?: Card,
|
||||
planetologistWeapon = false,
|
||||
): boolean {
|
||||
if (!canonicalEcazBattleSpecial(weapon) && !canonicalEcazBattleSpecial(defense))
|
||||
return validLeaderSkillBattleCardPair(weapon, defense, planetologistWeapon);
|
||||
return (
|
||||
battleCardSlotEligible('weapon', weapon, { planetologistWeapon }) &&
|
||||
battleCardSlotEligible('defense', defense) &&
|
||||
(!weapon || weapon.id !== defense?.id) &&
|
||||
(weapon?.kind !== 'chemistry' || (!!defense && isDefenseCard(defense))) &&
|
||||
(defense?.kind !== 'weirdingWay' || (!!weapon && isWeaponCard(weapon)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prescience asks for the selected category rather than physical slot
|
||||
* occupancy. Exact Ecaz battle specials answer null without revealing their
|
||||
* identity. Existing Planetologist inspection behavior is a separate contract.
|
||||
*/
|
||||
export function battleCategoryInspectionValue(
|
||||
field: BattlePlanInspectionField,
|
||||
value: BattleCategoryInspectionValue | undefined,
|
||||
card?: Card,
|
||||
): BattleCategoryInspectionValue | undefined {
|
||||
if (value === undefined || field === 'leader' || field === 'dial' || value === null)
|
||||
return value;
|
||||
if (card && card.id !== value)
|
||||
throw new Error('The inspected card does not match the selected battle slot.');
|
||||
if (canonicalEcazBattleSpecial(card))
|
||||
return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Partial plan search leaves an unspecified candidate unconstrained. */
|
||||
export function fixedBattleInspectionMatches(
|
||||
field: BattlePlanInspectionField,
|
||||
fixed: BattleCategoryInspectionValue,
|
||||
candidate: BattleCategoryInspectionValue | undefined,
|
||||
card?: Card,
|
||||
): boolean {
|
||||
return (
|
||||
candidate === undefined ||
|
||||
battleCategoryInspectionValue(field, candidate, card) === fixed
|
||||
);
|
||||
}
|
||||
@@ -66,9 +66,9 @@ export const BATTLE_CARD_HELP: Partial<Record<Card['kind'], string>> = {
|
||||
shieldSnooper:
|
||||
'Both projectile and poison defense. This is a shield and triggers a lasgun explosion.',
|
||||
weirdingWay:
|
||||
'A projectile weapon by default. With another card in the weapon slot, play it as a projectile defense. It is not a shield and does not trigger a lasgun explosion.',
|
||||
'A projectile weapon by default. With another weapon or Worthless card in the weapon slot, play it as a projectile defense. It is not a shield and does not trigger a lasgun explosion.',
|
||||
chemistry:
|
||||
'A poison defense by default. With another card in the defense slot, play it as a poison weapon. It has only the role selected for this battle.',
|
||||
'A poison defense by default. With another defense or Worthless card in the defense slot, play it as a poison weapon. It has only the role selected for this battle.',
|
||||
};
|
||||
|
||||
/** Roles refer to the selected slot; alternate roles need the other slot occupied. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from './cards';
|
||||
import type { FactionId } from './catalog';
|
||||
import { battleWeaponsExplode, isStoneBurner } from './battle-cards';
|
||||
import { validBattleSlotPair } from './battle-card-slots';
|
||||
import {
|
||||
casualtyOptions,
|
||||
validCombatForces,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
type StoneBurnerComparison,
|
||||
} from './stone-burner';
|
||||
import { matchingTraitor } from './traitors';
|
||||
import { HARASS_WITHDRAW_CARD, isHarassWithdraw, quoteHarassWithdraw, type HarassWithdrawContext, type HarassWithdrawQuote } from './harass-withdraw';
|
||||
import { auditCount } from './choam-auditor';
|
||||
import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate';
|
||||
import { validateSpiceBankerSpend } from './spice-banker';
|
||||
@@ -72,6 +74,7 @@ export type ResolutionParticipant = {
|
||||
noFieldAtTerritory?: boolean;
|
||||
};
|
||||
export type ResolutionCombatant = ResolutionParticipant & {
|
||||
harassWithdraw?: HarassWithdrawContext;
|
||||
diplomatDefense?: Pick<DiplomatDefenseQuote, 'leader' | 'source' | 'kind'> & { card: string };
|
||||
spice: number;
|
||||
hand: readonly Card[];
|
||||
@@ -124,6 +127,7 @@ export type BattleSupportPayment = {
|
||||
freeByTraitor: boolean;
|
||||
};
|
||||
export type BattleResolutionQuote = {
|
||||
harassWithdraw?: (HarassWithdrawQuote & { player: string; card: typeof HARASS_WITHDRAW_CARD })[];
|
||||
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion';
|
||||
winner: string | null;
|
||||
attackerTraitor: boolean;
|
||||
@@ -361,6 +365,23 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
return copiedDiplomatDefense(quote, copy.card);
|
||||
};
|
||||
const effectiveAd = effectiveDefense(a, d, ad), effectiveDd = effectiveDefense(d, a, dd);
|
||||
const withdrawals = new Map<string, HarassWithdrawQuote>();
|
||||
for (const side of [a, d]) {
|
||||
const slots = [card(side, side.plan.weapon), card(side, side.plan.defense)];
|
||||
requireQuote(!slots.some(c => c?.id === 'ecaz-reinforcements'),
|
||||
'Reinforcements battle effects are still being implemented.');
|
||||
const harass = slots.some(isHarassWithdraw);
|
||||
requireQuote(!side.harassWithdraw || harass, 'The withdrawal context requires the physical Harass & Withdraw card.');
|
||||
if (harass) {
|
||||
requireQuote(validBattleSlotPair(slots[0], slots[1]),
|
||||
'The Harass & Withdraw plan has an invalid weapon and defense pair.');
|
||||
requireQuote(side.harassWithdraw && !input.homeworld && !isStoneBurner(aw) && !isStoneBurner(dw),
|
||||
'Harass & Withdraw needs its supported physical context; Homeworld and Stone Burner combinations remain unfinished.');
|
||||
requireQuote(JSON.stringify(side.harassWithdraw.forces) === JSON.stringify(side.forces),
|
||||
'The withdrawal context must match the current battle force roles.');
|
||||
withdrawals.set(side.id, quoteHarassWithdraw(side.harassWithdraw, side.plan.dial, side.plan.support));
|
||||
}
|
||||
}
|
||||
const tie =
|
||||
d.stronghold === 'habbanya_ridge_sietch' ? 'defender' : 'attacker';
|
||||
const stone =
|
||||
@@ -430,6 +451,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
(v) => v.called && !a.plan.kwisatz && v.beneficiary === d.id,
|
||||
);
|
||||
const payments: BattleSupportPayment[] = [];
|
||||
const harassWithdraw = [a, d].flatMap(side => {
|
||||
const withdrawal = withdrawals.get(side.id);
|
||||
// An opposing successful traitor cancels this effect, including mutual calls.
|
||||
return withdrawal && !(side === a ? dc : ac)
|
||||
? [{ ...withdrawal, player: side.id, card: HARASS_WITHDRAW_CARD }] : [];
|
||||
});
|
||||
const spiceBankerPayments = [a, d].filter(side => (side.plan.bankerSpice ?? 0) > 0).map(side => {
|
||||
const waivedByTraitor = side === a ? ac && !dc : dc && !ac;
|
||||
return { player: side.id, amount: waivedByTraitor ? 0 : side.plan.bankerSpice!, waivedByTraitor };
|
||||
@@ -636,7 +663,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
winner.faction === 'ixians'
|
||||
) {
|
||||
const options = casualtyOptions(
|
||||
winner.forces,
|
||||
harassWithdraw.find(entry => entry.player === winner.id)?.remaining ?? winner.forces,
|
||||
winner.plan.dial,
|
||||
winner.plan.support,
|
||||
);
|
||||
@@ -645,7 +672,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
'No valid casualty choice remains for this battle.',
|
||||
);
|
||||
casualties = {
|
||||
forces: { ...winner.forces },
|
||||
forces: { ...(harassWithdraw.find(entry => entry.player === winner.id)?.remaining ?? winner.forces) },
|
||||
dial: winner.plan.dial,
|
||||
support: winner.plan.support,
|
||||
options,
|
||||
@@ -714,8 +741,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
selectedLeader: side.plan.leader,
|
||||
card: card(side, selected),
|
||||
});
|
||||
// The explicit after-use instruction also applies to a successful own traitor
|
||||
// call, using the same specificity inference as Planetologist's discard.
|
||||
const mandatorySkillDiscard = (side: ResolutionCombatant, selected: string) =>
|
||||
mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected;
|
||||
mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected || isHarassWithdraw(card(side, selected));
|
||||
const moritani = input.participants.find((p) => p.faction === 'moritani');
|
||||
if (winner && moritani) {
|
||||
const loser = winner === a ? d : a,
|
||||
@@ -780,6 +809,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
...(harassWithdraw.length ? { harassWithdraw } : {}),
|
||||
result,
|
||||
winner: winner?.id ?? null,
|
||||
attackerTraitor: ac,
|
||||
|
||||
+33
-13
@@ -1,7 +1,9 @@
|
||||
import { discoveryBotActions } from './discovery-options';
|
||||
import { recruitsPlayAction } from './recruits';
|
||||
import type { Card } from './cards';
|
||||
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat';
|
||||
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus } from './leader-skill-combat';
|
||||
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
|
||||
import { quoteHarassWithdraw } from './harass-withdraw';
|
||||
import { leaderSkillStrongholdCount } from './leader-skill-battle-board';
|
||||
import { discoveryFlightBotActions } from './discovery-flight-options';
|
||||
import { discoveryEntryBotActions } from './discovery-entry-options';
|
||||
@@ -680,22 +682,26 @@ function plans(g: GameView): Action[] {
|
||||
const commitments = b.ownCommitments.filter(
|
||||
(element) => element.target === me.id,
|
||||
);
|
||||
const fixed = (field: string, value: string | number | null) =>
|
||||
const fixed = (field: BattlePlanInspectionField, value: string | number | null) =>
|
||||
commitments.every(
|
||||
(element) => element.field !== field || element.value === value,
|
||||
(element) => element.field !== field || fixedBattleInspectionMatches(field, element.value, value,
|
||||
me.hand?.find(card => card.id === value)),
|
||||
);
|
||||
const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [];
|
||||
const bankerPrototypeAvailable = spiceBankerModeSupported(g);
|
||||
const specialForLeader = (card: Card | undefined, leader: string | null) =>
|
||||
canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card});
|
||||
const prototypeCardAllowed = (card: Card) => card.id !== 'ecaz-reinforcements' &&
|
||||
(card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked));
|
||||
const weapons = [
|
||||
null,
|
||||
...(me.hand ?? []).filter(c => isWeaponCard(c) ||
|
||||
me.leaders.some(l => !l.dead && controlsLeader(me,l) && (!l.usedAt || l.usedAt === b.territory) && specialForLeader(c,l.id))).map(c => c.id),
|
||||
...(me.hand ?? []).filter(c => prototypeCardAllowed(c) && battleCardSlotEligible('weapon', c, {
|
||||
planetologistWeapon: me.leaders.some(l => !l.dead && controlsLeader(me,l) && (!l.usedAt || l.usedAt === b.territory) && specialForLeader(c,l.id)),
|
||||
})).map(c => c.id),
|
||||
].filter((id) => fixed('weapon', id));
|
||||
const defenses = [
|
||||
null,
|
||||
...(me.hand ?? []).filter(isDefenseCard).map((c) => c.id),
|
||||
...(me.hand ?? []).filter(c => prototypeCardAllowed(c) && battleCardSlotEligible('defense', c)).map((c) => c.id),
|
||||
].filter((id) => fixed('defense', id));
|
||||
const availableLeaders = me.leaders.filter(
|
||||
(l) =>
|
||||
@@ -771,10 +777,10 @@ function plans(g: GameView): Action[] {
|
||||
) + (kwisatz ? 2 : 0);
|
||||
const w = me.hand?.find((c) => c.id === weapon);
|
||||
const d = me.hand?.find((c) => c.id === defense);
|
||||
if (w && !isWeaponCard(w) && !specialForLeader(w,leader)) continue;
|
||||
if (!battleCardSlotEligible('weapon', w, {planetologistWeapon: specialForLeader(w, leader)})) continue;
|
||||
const stoneBattle =
|
||||
isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon));
|
||||
if ((!leader && (weapon || defense)) || !validLeaderSkillBattleCardPair(w, d, specialForLeader(w,leader)))
|
||||
if ((!leader && (weapon || defense)) || !validBattleSlotPair(w, d, specialForLeader(w,leader)))
|
||||
continue;
|
||||
if (voice) {
|
||||
const used =
|
||||
@@ -924,8 +930,17 @@ function plans(g: GameView): Action[] {
|
||||
))
|
||||
)
|
||||
continue;
|
||||
const effectiveWeapon = w && w.kind !== 'worthless' && !specialForLeader(w,leader) ? 6 : 0;
|
||||
const effectiveDefense = d && d.kind !== 'worthless' ? 4 : 0;
|
||||
let withdrawn = 0;
|
||||
const harass = weapon === 'ecaz-harass-withdraw' || defense === 'ecaz-harass-withdraw';
|
||||
if (harass) {
|
||||
if (!b.harassWithdraw) continue;
|
||||
try {
|
||||
const quote = quoteHarassWithdraw(b.harassWithdraw, Number(action.dial), Number(action.support ?? 0));
|
||||
withdrawn = quote.returned.normal + quote.returned.elite;
|
||||
} catch { continue; }
|
||||
}
|
||||
const effectiveWeapon = w && isWeaponCard(w) && w.kind !== 'worthless' ? 6 : 0;
|
||||
const effectiveDefense = d && isDefenseCard(d) && d.kind !== 'worthless' ? 4 : 0;
|
||||
const selfExplosion = battleWeaponsExplode(w, d);
|
||||
const correctDefense = inspected
|
||||
? weaponKills(enemyWeapon) && !weaponKills(enemyWeapon, d)
|
||||
@@ -993,7 +1008,12 @@ function plans(g: GameView): Action[] {
|
||||
options.push({
|
||||
action,
|
||||
score:
|
||||
score - (commitment?.cost ?? 0) * 0.2 - bankerSpice * 0.2,
|
||||
score - (commitment?.cost ?? 0) * 0.2 - bankerSpice * 0.2 +
|
||||
(harass && level > 0 && !exposedLeader
|
||||
? (ownSurvivingStrength + Number(action.dial) + bankerSpice + ownNativeBonus <
|
||||
enemySurvivingStrength - opponentPenalty + expectedDial + enemyNativeBonus
|
||||
? withdrawn * (1 + level * 0.5) : -10)
|
||||
: 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1009,7 +1029,7 @@ function plans(g: GameView): Action[] {
|
||||
),
|
||||
);
|
||||
if (b.compliantPlan && commitments.every(
|
||||
(element) => b.compliantPlan![element.field] === element.value,
|
||||
(element) => fixed(element.field, b.compliantPlan![element.field]),
|
||||
)) ranked.push({ type: 'battlePlan', ...b.compliantPlan });
|
||||
return ranked;
|
||||
}
|
||||
@@ -3476,7 +3496,7 @@ function policyActions(g: GameView): Action[] {
|
||||
return plans(g).map((p) => ({
|
||||
type: nexus ? 'nexusPrescienceAnswer' : 'prescienceAnswer',
|
||||
...(nexus ? { event: nexus.event } : {}),
|
||||
value: p[field],
|
||||
value: battleCategoryInspectionValue(field, candidatePlan(p)[field], me.hand?.find(card => card.id === p[field])),
|
||||
}));
|
||||
}
|
||||
if (b.revealed) {
|
||||
|
||||
@@ -111,8 +111,10 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
||||
gameplay: ecaz.gameplay,
|
||||
availability: ecaz.card.effect === 'recruits'
|
||||
? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
|
||||
: 'This battle effect is still being implemented.',
|
||||
topics: RULE_TOPICS.filter((topic) => topic.id === 'card-recruits'),
|
||||
: ecaz.card.effect === 'harassWithdraw'
|
||||
? 'Harass & Withdraw has development controls in either battle-card slot. Ambiguous physical allocations, Richese card combinations and additional optional modules remain guarded.'
|
||||
: 'This battle effect is still being implemented.',
|
||||
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits')),
|
||||
};
|
||||
const topicIds =
|
||||
card.kind === 'special'
|
||||
|
||||
+4
-4
@@ -152,12 +152,12 @@ export const ECAZ_TREACHERY_DEFINITIONS: readonly EcazTreacheryDefinition[] =
|
||||
verification: {
|
||||
inventory: 'verified',
|
||||
sourceRules: 'verified',
|
||||
runtime: 'not-implemented',
|
||||
runtime: 'prototype',
|
||||
combinedInteractions: 'incomplete',
|
||||
unresolved: [
|
||||
'Withdrawal ordering with Lasgun/Shield explosions and other instant outcomes.',
|
||||
'Physical dialed/undialed accounting for advanced half-strength and double-strength units, Ecaz support, zero commitments and multiple sectors.',
|
||||
'Whether a traitor-canceled special counts as used for mandatory disposal or winner retention.',
|
||||
'Stone Burner comparison before or after withdrawal; its public card-family configuration remains guarded.',
|
||||
'Ambiguous physical dialed/undialed allocations among regular/elite forces or multiple sectors, and Ecaz allied support.',
|
||||
'Additional optional modules, Homeworld battles and complete integrated acceptance.',
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
+139
-43
@@ -4,8 +4,6 @@ import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratU
|
||||
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
|
||||
import {
|
||||
canUsePlanetologistBattleSpecial,
|
||||
isPlanetologistBattleSpecialCard,
|
||||
validLeaderSkillBattleCardPair,
|
||||
type BattleLeaderSkill,
|
||||
} from './leader-skill-combat';
|
||||
import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
|
||||
@@ -18,6 +16,8 @@ import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMov
|
||||
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
|
||||
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
|
||||
import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
|
||||
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair } from './battle-card-slots';
|
||||
import { HARASS_WITHDRAW_CARD, HarassWithdrawError, isHarassWithdraw, quoteHarassWithdraw, type HarassWithdrawContext, type HarassWithdrawPreview } from './harass-withdraw';
|
||||
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
|
||||
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
|
||||
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
|
||||
@@ -477,9 +477,7 @@ import {
|
||||
} from './revival';
|
||||
import { traitorDeck, matchingTraitor, CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
isWeaponCard,
|
||||
isStoneBurner,
|
||||
isDefenseCard,
|
||||
isPortableSnooper,
|
||||
VOICE_KINDS,
|
||||
defaultVoiceMatch,
|
||||
@@ -2353,6 +2351,14 @@ function battleInspectionContext(g: Game): BattleInspectionContext {
|
||||
function nexusInspectionIntegrity(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b) return;
|
||||
// Native Prescience is binding even when this battle used no Nexus card.
|
||||
// Check all sealed plans so removing a slot-only special cannot evade it.
|
||||
for (const target of [b.attacker, b.defender]) {
|
||||
const plan = b.plans[target];
|
||||
if (plan) requireRule(committedPlanElements(b, target).every(element => plan[element.field] !== undefined &&
|
||||
inspectedPlanMatches(getPlayer(g, target), element.field, element.value, plan[element.field])),
|
||||
'The sealed plan contradicts its inspected commitment.');
|
||||
}
|
||||
const record = b.nexusInspection;
|
||||
requireRule(!!record === !!b.nexusInspectionUsed, 'The Nexus inspection presence record is missing.');
|
||||
if (!record) return;
|
||||
@@ -2370,11 +2376,6 @@ function nexusInspectionIntegrity(g: Game) {
|
||||
requireRule((record.stage === 'response') === (responses.length > 0),
|
||||
'The extra inspection has lost or reopened its original cancellation response.');
|
||||
for (const response of responses) validateNexusInspectionResponse(g, response!);
|
||||
for (const target of [b.attacker, b.defender]) {
|
||||
const plan = b.plans[target];
|
||||
if (plan) requireRule(committedPlanElements(b, target).every(element => plan[element.field] === element.value),
|
||||
'The sealed plan contradicts its inspected commitment.');
|
||||
}
|
||||
if (record.stage === 'answer' || record.stage === 'response')
|
||||
requireRule(!b.revealed && (b.preparation?.kind === 'nexusPrescienceAnswer' ||
|
||||
(b.preparation?.kind === 'prescienceAnswer' && b.prescience && !Object.hasOwn(b.prescience, 'value'))),
|
||||
@@ -2443,7 +2444,8 @@ function settleNexusInspectionResponse(g: Game, response: ResponseWindow, cancel
|
||||
finishInspectionAnswers(g);
|
||||
} else {
|
||||
b.nexusInspection = nexusRule(() => allowNexusInspection(battleInspectionContext(g), record));
|
||||
if (b.plans[record.target]) answerCurrentNexusInspection(g, b.plans[record.target][record.field]);
|
||||
if (b.plans[record.target]) answerCurrentNexusInspection(g,
|
||||
inspectedPlanValue(getPlayer(g, record.target), record.field, b.plans[record.target][record.field]));
|
||||
}
|
||||
}
|
||||
function playNexusAtreides(g: Game, p: Player, action: Action) {
|
||||
@@ -2469,16 +2471,20 @@ function playNexusAtreides(g: Game, p: Player, action: Action) {
|
||||
} else {
|
||||
b.preparation = { kind: 'nexusPrescienceAnswer', owner: target, beneficiary: p.id };
|
||||
if (offer.mode === 'cunning') g.response = { kind: 'nexusPrescience', owner: p.id, intent: b.event, passed: [] };
|
||||
else if (b.plans[target]) answerCurrentNexusInspection(g, b.plans[target][field]);
|
||||
else if (b.plans[target]) answerCurrentNexusInspection(g,
|
||||
inspectedPlanValue(getPlayer(g, target), field, b.plans[target][field]));
|
||||
}
|
||||
}
|
||||
function projectedNexusInsights(g: Game, id: string) {
|
||||
const record = g.battle?.nexusInspection;
|
||||
if (!record || ![record.owner, record.target].includes(id)) return [];
|
||||
return record.answers.map((value, index) => ({ field: record.field, value,
|
||||
return record.answers.map((answer, index) => {
|
||||
const value = inspectedPlanValue(getPlayer(g, record.target), record.field, answer);
|
||||
return { field: record.field, value,
|
||||
label: g.players.flatMap(p => [...p.leaders, ...p.hand]).concat(g.dukeVidal ? [g.dukeVidal.leader] : [])
|
||||
.find(item => item.id === value)?.name ?? String(value ?? 'None'),
|
||||
active: record.stage === 'answered' && index === record.answers.length - 1 }));
|
||||
active: record.stage === 'answered' && index === record.answers.length - 1 };
|
||||
});
|
||||
}
|
||||
function homeworldRule<T>(quote: () => T): T {
|
||||
try {
|
||||
@@ -8088,6 +8094,22 @@ function ecazTreacheryIntegrity(g: Game) {
|
||||
physical.filter((card) => card.id.startsWith('ecaz-')).length === 3,
|
||||
'The Ecaz Treachery Cards inventory contains an unknown physical card.',
|
||||
);
|
||||
for (const [player, plan] of Object.entries(g.battle?.plans ?? {})) {
|
||||
requireRule(![plan.weapon, plan.defense].includes('ecaz-reinforcements'),
|
||||
'Reinforcements battle effects are still being implemented.');
|
||||
requireRule(plan.leader !== HARASS_WITHDRAW_CARD && g.battle?.lateDefense?.[player] !== HARASS_WITHDRAW_CARD &&
|
||||
!(plan.weapon === HARASS_WITHDRAW_CARD && plan.defense === HARASS_WITHDRAW_CARD),
|
||||
'Harass & Withdraw occupies exactly one ordinary Battle Plan card slot.');
|
||||
if ([plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)) {
|
||||
const owner = getPlayer(g, player);
|
||||
requireRule(owner.hand.some(isHarassWithdraw),
|
||||
'The sealed Harass & Withdraw card must remain in its owner’s hand.');
|
||||
requireRule([plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) &&
|
||||
validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)),
|
||||
'The sealed Harass & Withdraw plan has an invalid weapon and defense pair.');
|
||||
currentHarassWithdrawQuote(g, owner, plan);
|
||||
}
|
||||
}
|
||||
if (g.recruits) {
|
||||
requireRule(
|
||||
Object.keys(g.recruits).sort().join(',') === 'card,player,turn' &&
|
||||
@@ -13420,6 +13442,59 @@ function victory(g: Game, quote: VictoryQuote = currentVictoryQuote(g)) {
|
||||
function cardOf(p: Player, id: string | null) {
|
||||
return p.hand.find((c) => c.id === id);
|
||||
}
|
||||
function inspectedPlanValue(p: Player, field: PlanField, value: string | number | null | undefined) {
|
||||
return battleCategoryInspectionValue(field, value,
|
||||
(field === 'weapon' || field === 'defense') && typeof value === 'string' ? cardOf(p, value) : undefined);
|
||||
}
|
||||
function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candidate: string | number | null | undefined) {
|
||||
return fixedBattleInspectionMatches(field, fixed as string | number | null, candidate,
|
||||
(field === 'weapon' || field === 'defense') && typeof candidate === 'string' ? cardOf(p, candidate) : undefined);
|
||||
}
|
||||
function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
|
||||
const b = g.battle!;
|
||||
const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
let blocked: string | null = null;
|
||||
if (!g.ecazTreachery) blocked = 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.';
|
||||
else if (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id))
|
||||
blocked = 'Use Harass & Withdraw in your own battle.';
|
||||
else if (g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled)
|
||||
blocked = 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.';
|
||||
else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined)
|
||||
blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.';
|
||||
else if ([p, other].some(side => {
|
||||
const ally = g.players.find(player => player.id === side.ally && player.ally === side.id);
|
||||
return ally && (side.faction === 'ecaz' || ally.faction === 'ecaz') &&
|
||||
Object.entries(ally.forces).some(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory);
|
||||
})) blocked = 'Harass & Withdraw with co-present Ecaz allies awaits combined-army battle integration.';
|
||||
else if (p.noField?.deployed?.location.territory === b.territory)
|
||||
blocked = 'Reveal your No-Field before using Harass & Withdraw.';
|
||||
const locations = Object.fromEntries(Object.entries(p.forces)
|
||||
.filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory)
|
||||
.map(([key, amount]) => [key, { normal: amount - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }]));
|
||||
return { blocked, forces: combatForces(g, p, b.territory, other), locations };
|
||||
}
|
||||
function currentHarassWithdrawQuote(g: Game, p: Player, plan: Pick<Plan, 'dial' | 'support'>) {
|
||||
try {
|
||||
const quote = quoteHarassWithdraw(harassWithdrawContext(g, p), plan.dial, plan.support);
|
||||
validateHarassReturnCounters(p, quote.returned);
|
||||
return quote;
|
||||
}
|
||||
catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function validateHarassReturnCounters(p: Player, returned: { normal: number; elite: number }) {
|
||||
requireRule(Number.isSafeInteger(p.reserves) && p.reserves >= 0 &&
|
||||
Number.isSafeInteger(p.reserves + returned.normal + returned.elite) &&
|
||||
(!p.elites || (Number.isSafeInteger(p.elites.reserves) && p.elites.reserves >= 0 &&
|
||||
p.elites.reserves <= p.reserves && Number.isSafeInteger(p.elites.reserves + returned.elite))),
|
||||
'Harass & Withdraw needs valid reserve and elite counters before any forces return.');
|
||||
}
|
||||
function harassWithdrawPreview(g: Game, p: Player): HarassWithdrawPreview | null {
|
||||
const b = g.battle;
|
||||
if (!b || ![b.attacker, b.defender].includes(p.id) || !p.hand.some(isHarassWithdraw)) return null;
|
||||
const context = harassWithdrawContext(g, p);
|
||||
return { ...context, card: HARASS_WITHDRAW_CARD,
|
||||
blocked: context.blocked ?? (b.revealed || b.plans[p.id] ? 'Your Battle Plan is already sealed.' : null) };
|
||||
}
|
||||
function validateResidualPoison(
|
||||
g: Game,
|
||||
p: Player,
|
||||
@@ -13834,12 +13909,12 @@ function validatePlan(
|
||||
w,
|
||||
);
|
||||
requireRule(
|
||||
!plan.weapon || (w && (isWeaponCard(w) || planetologistWeapon)),
|
||||
'Choose a weapon, worthless card, or eligible Planetologist Special.',
|
||||
!plan.weapon || (w && battleCardSlotEligible('weapon', w, { planetologistWeapon })),
|
||||
'Choose a weapon, worthless card, eligible Planetologist Special, or Harass & Withdraw.',
|
||||
);
|
||||
requireRule(
|
||||
!plan.defense || (d && isDefenseCard(d)),
|
||||
'Choose a defense or worthless card.',
|
||||
!plan.defense || (d && battleCardSlotEligible('defense', d)),
|
||||
'Choose a defense, worthless card, or Harass & Withdraw.',
|
||||
);
|
||||
requireRule(
|
||||
!plan.weapon || plan.weapon !== plan.defense,
|
||||
@@ -13850,9 +13925,12 @@ function validatePlan(
|
||||
'Without a leader, no battle cards can be played.',
|
||||
);
|
||||
requireRule(
|
||||
validLeaderSkillBattleCardPair(w, d, planetologistWeapon),
|
||||
validBattleSlotPair(w, d, planetologistWeapon),
|
||||
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
|
||||
);
|
||||
requireRule(![w, d].some(card => card?.id === 'ecaz-reinforcements'),
|
||||
'Reinforcements battle effects are still being implemented.');
|
||||
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
|
||||
if (isStoneBurner(w)) {
|
||||
const blocked = stonePlanBlock(g, p, dial, support);
|
||||
requireRule(
|
||||
@@ -13877,7 +13955,7 @@ function validatePlan(
|
||||
}
|
||||
for (const element of committedPlanElements(b, p.id))
|
||||
requireRule(
|
||||
plan[element.field] === element.value,
|
||||
inspectedPlanMatches(p, element.field, element.value, plan[element.field]),
|
||||
'Every element revealed by a battle inspection must remain unchanged.',
|
||||
);
|
||||
requireRule(
|
||||
@@ -13996,7 +14074,7 @@ function normalizeBattle(g: Game) {
|
||||
if (b.prescience && !('value' in b.prescience)) {
|
||||
const target = b.prescience.player === b.attacker ? b.defender : b.attacker;
|
||||
const priorPlan = b.plans[target];
|
||||
if (priorPlan) b.prescience.value = priorPlan[b.prescience.field];
|
||||
if (priorPlan) b.prescience.value = inspectedPlanValue(getPlayer(g, target), b.prescience.field, priorPlan[b.prescience.field]) as string | number | null;
|
||||
else
|
||||
b.preparation = {
|
||||
kind: 'prescienceAnswer',
|
||||
@@ -14222,11 +14300,9 @@ function findLegalBattlePlan(
|
||||
const commitments = committedPlanElements(b, p.id);
|
||||
const accepts = (plan: Partial<Plan>) =>
|
||||
commitments.every(element =>
|
||||
plan[element.field] === undefined || plan[element.field] === element.value) &&
|
||||
inspectedPlanMatches(p, element.field, element.value, plan[element.field])) &&
|
||||
respectsBattlePromises(promises, p.id, plan, p.hand) &&
|
||||
(!fixed ||
|
||||
plan[fixed.field] === undefined ||
|
||||
plan[fixed.field] === fixed.value);
|
||||
(!fixed || inspectedPlanMatches(p, fixed.field, fixed.value, plan[fixed.field]));
|
||||
if (b.plans[p.id]) {
|
||||
const sealed = { ...b.plans[p.id], kwisatz: !!b.plans[p.id].kwisatz };
|
||||
return accepts(sealed) ? sealed : null;
|
||||
@@ -14265,13 +14341,13 @@ function findLegalBattlePlan(
|
||||
...p.hand
|
||||
.filter(
|
||||
(card) =>
|
||||
isWeaponCard(card) || isPlanetologistBattleSpecialCard(card),
|
||||
battleCardSlotEligible('weapon', card, { planetologistWeapon: true }),
|
||||
)
|
||||
.map((card) => card.id),
|
||||
];
|
||||
const defenses = [
|
||||
null,
|
||||
...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id),
|
||||
...p.hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id),
|
||||
];
|
||||
for (const leader of leaderIds) {
|
||||
if (!accepts({ leader })) continue;
|
||||
@@ -14280,7 +14356,7 @@ function findLegalBattlePlan(
|
||||
for (const defense of defenses) {
|
||||
if (
|
||||
!accepts({ leader, weapon, defense }) ||
|
||||
!validLeaderSkillBattleCardPair(
|
||||
!validBattleSlotPair(
|
||||
cardOf(p, weapon),
|
||||
cardOf(p, defense),
|
||||
canUsePlanetologistWeapon(g, p, leader, cardOf(p, weapon)),
|
||||
@@ -14841,17 +14917,17 @@ function feasiblePrescience(
|
||||
...p.hand
|
||||
.filter(
|
||||
(card) =>
|
||||
isWeaponCard(card) || isPlanetologistBattleSpecialCard(card),
|
||||
battleCardSlotEligible('weapon', card, { planetologistWeapon: true }),
|
||||
)
|
||||
.map((card) => card.id),
|
||||
];
|
||||
const defenses: (string | null)[] = [
|
||||
null,
|
||||
...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id),
|
||||
...p.hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id),
|
||||
];
|
||||
for (const leader of field === 'leader' ? [value] : leaderIds)
|
||||
for (const weapon of field === 'weapon' ? [value] : weapons)
|
||||
for (const defense of field === 'defense' ? [value] : defenses) {
|
||||
for (const weapon of field === 'weapon' ? weapons.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : weapons)
|
||||
for (const defense of field === 'defense' ? defenses.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : defenses) {
|
||||
const maxSupport =
|
||||
g.advanced && field === 'dial'
|
||||
? Math.min(battleSupportBudget(g, p), (combatArmy(g, p.id, b.territory).normal + combatArmy(g, p.id, b.territory).elite))
|
||||
@@ -14865,7 +14941,7 @@ function feasiblePrescience(
|
||||
support,
|
||||
dial: field === 'dial' ? value : 0,
|
||||
});
|
||||
if (plan[field] === value) return true;
|
||||
if (inspectedPlanMatches(p, field, value, plan[field])) return true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
}
|
||||
@@ -15196,6 +15272,8 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
leaderSkills: battleLeaderSkills(g, p),
|
||||
occupiedStrongholds: leaderSkillStrongholdCount(g, p),
|
||||
forces: combatForces(g, p, b.territory, opponent),
|
||||
...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isHarassWithdraw)
|
||||
? { harassWithdraw: harassWithdrawContext(g, p) } : {}),
|
||||
stronghold: strongholdEffect(g, p.id),
|
||||
lateDefense: b.lateDefense?.[p.id],
|
||||
...(b.diplomatDefense?.stage === 'copied' && b.diplomatDefense.player === p.id
|
||||
@@ -15232,6 +15310,8 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
pendingAuditorPresent: !!g.pendingAuditor,
|
||||
pendingRetentionPresent: !!g.moritaniRetention,
|
||||
});
|
||||
for (const withdrawal of quote.harassWithdraw ?? [])
|
||||
validateHarassReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
|
||||
if (quote.sukGraduate) requireRule(
|
||||
!g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides',
|
||||
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
|
||||
@@ -15364,6 +15444,24 @@ function resolveBattle(g: Game) {
|
||||
{ faction: player.faction, name: 'Spice Banker payment' });
|
||||
}
|
||||
if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome };
|
||||
for (const [player, plan, canceled] of [[a, ap, dc], [d, dp, ac]] as const)
|
||||
if (canceled && [cardOf(player, plan.weapon), cardOf(player, plan.defense)].some(isHarassWithdraw))
|
||||
log(g, `${player.name}'s Harass & Withdraw was canceled by an opposing traitor. No forces returned through this card.`);
|
||||
for (const withdrawal of quote.harassWithdraw ?? []) {
|
||||
const player = getPlayer(g, withdrawal.player);
|
||||
for (const [key, group] of Object.entries(withdrawal.locations)) {
|
||||
player.forces[key] -= group.normal + group.elite;
|
||||
if (!player.forces[key]) delete player.forces[key];
|
||||
if (group.elite && player.elites) {
|
||||
player.elites.forces[key] -= group.elite;
|
||||
if (!player.elites.forces[key]) delete player.elites.forces[key];
|
||||
}
|
||||
}
|
||||
player.reserves += withdrawal.returned.normal + withdrawal.returned.elite;
|
||||
if (player.elites) player.elites.reserves += withdrawal.returned.elite;
|
||||
log(g, `${player.name} used Harass & Withdraw to return ${withdrawal.returned.normal + withdrawal.returned.elite} undialed physical forces (${withdrawal.returned.elite} elite) to reserves before battle losses. Their leader still resolves normally.`);
|
||||
}
|
||||
if (quote.harassWithdraw?.length) observeOccupation(g);
|
||||
const explosion = quote.explosion;
|
||||
if (ac && dc) {
|
||||
dead(al);
|
||||
@@ -24882,6 +24980,7 @@ export function viewGame(state: Game, id: string) {
|
||||
field: b.nexusInspection.field, stage: b.nexusInspection.stage,
|
||||
} : null,
|
||||
nexusInsights: projectedNexusInsights(g, id),
|
||||
harassWithdraw: harassWithdrawPreview(g, me),
|
||||
ownCommitments: committedPlanElements(b, id),
|
||||
prescience: b.prescience
|
||||
? {
|
||||
@@ -24894,18 +24993,15 @@ export function viewGame(state: Game, id: string) {
|
||||
insight:
|
||||
b.prescience &&
|
||||
'value' in b.prescience &&
|
||||
(b.prescience.player === id ||
|
||||
[b.attacker, b.defender].includes(id))
|
||||
? {
|
||||
field: b.prescience.field,
|
||||
value: b.prescience.value,
|
||||
label:
|
||||
g.players
|
||||
.flatMap((p) => [...p.leaders, ...p.hand])
|
||||
(b.prescience.player === id || [b.attacker, b.defender].includes(id))
|
||||
? (() => {
|
||||
const target = getPlayer(g, b.prescience!.player === b.attacker ? b.defender : b.attacker);
|
||||
const value = inspectedPlanValue(target, b.prescience!.field, b.prescience!.value);
|
||||
return { field: b.prescience!.field, value,
|
||||
label: g.players.flatMap(p => [...p.leaders, ...p.hand])
|
||||
.concat(g.dukeVidal ? [g.dukeVidal.leader] : [])
|
||||
.find((item) => item.id === b.prescience!.value)?.name ??
|
||||
String(b.prescience.value ?? 'None'),
|
||||
}
|
||||
.find(item => item.id === value)?.name ?? String(value ?? 'None') };
|
||||
})()
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
casualtyOptions,
|
||||
validCombatForces,
|
||||
type CombatForces,
|
||||
} from './combat';
|
||||
import type { Card } from './cards';
|
||||
import { ecazTreacheryDefinition } from './ecaz-cards';
|
||||
|
||||
export const HARASS_WITHDRAW_CARD = 'ecaz-harass-withdraw' as const;
|
||||
export type HarassWithdrawForces = Readonly<{ normal: number; elite: number }>;
|
||||
export type HarassWithdrawContext = Readonly<{
|
||||
blocked: string | null;
|
||||
forces: CombatForces;
|
||||
locations: Readonly<Record<string, HarassWithdrawForces>>;
|
||||
}>;
|
||||
export type HarassWithdrawPreview = HarassWithdrawContext &
|
||||
Readonly<{
|
||||
card: typeof HARASS_WITHDRAW_CARD;
|
||||
}>;
|
||||
export type HarassWithdrawQuote = {
|
||||
returned: { normal: number; elite: number };
|
||||
remaining: CombatForces;
|
||||
locations: Record<string, { normal: number; elite: number }>;
|
||||
};
|
||||
export class HarassWithdrawError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'HarassWithdrawError';
|
||||
}
|
||||
}
|
||||
export function isHarassWithdraw(card: Card | undefined): boolean {
|
||||
return (
|
||||
!!card &&
|
||||
card.id === HARASS_WITHDRAW_CARD &&
|
||||
Object.keys(card).sort().join(',') === 'effect,id,kind,name' &&
|
||||
ecazTreacheryDefinition(card)?.card.effect === 'harassWithdraw'
|
||||
);
|
||||
}
|
||||
function requireHarass(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new HarassWithdrawError(message);
|
||||
}
|
||||
/** Resolve only the user's physical counters. Neither opposing plans nor outcomes are inputs. */
|
||||
export function quoteHarassWithdraw(
|
||||
context: HarassWithdrawContext,
|
||||
dial: number,
|
||||
support: number,
|
||||
): HarassWithdrawQuote {
|
||||
requireHarass(
|
||||
!context.blocked,
|
||||
context.blocked ?? 'Harass & Withdraw is unavailable.',
|
||||
);
|
||||
const forces = context.forces;
|
||||
requireHarass(
|
||||
validCombatForces(forces) && !forces.temporaryElite,
|
||||
'Harass & Withdraw needs ordinary physical force roles.',
|
||||
);
|
||||
const locations = Object.entries(context.locations);
|
||||
requireHarass(
|
||||
locations.every(
|
||||
([key, group]) =>
|
||||
key.length > 0 &&
|
||||
group &&
|
||||
Object.keys(group).sort().join(',') === 'elite,normal' &&
|
||||
[group.normal, group.elite].every(
|
||||
(n) => Number.isSafeInteger(n) && n >= 0,
|
||||
),
|
||||
) &&
|
||||
locations.reduce((n, [, group]) => n + group.normal, 0) ===
|
||||
forces.normal &&
|
||||
locations.reduce((n, [, group]) => n + group.elite, 0) === forces.elite,
|
||||
'Harass & Withdraw must match the user’s physical forces in this battle.',
|
||||
);
|
||||
const choices = casualtyOptions(forces, dial, support);
|
||||
requireHarass(
|
||||
choices.length > 0,
|
||||
'Dial and spice must match a legal physical force commitment.',
|
||||
);
|
||||
const physical = [
|
||||
...new Map(
|
||||
choices.map((choice) => [`${choice.normal}:${choice.elite}`, choice]),
|
||||
).values(),
|
||||
];
|
||||
requireHarass(
|
||||
physical.length === 1,
|
||||
'Harass & Withdraw with an ambiguous regular/elite commitment is still being implemented. Choose a uniquely determined physical commitment.',
|
||||
);
|
||||
const committed = physical[0];
|
||||
const returned = {
|
||||
normal: forces.normal - committed.normal,
|
||||
elite: forces.elite - committed.elite,
|
||||
};
|
||||
const allocation: HarassWithdrawQuote['locations'] = {};
|
||||
for (const type of ['normal', 'elite'] as const) {
|
||||
const amount = returned[type],
|
||||
occupied = locations.filter(([, group]) => group[type] > 0);
|
||||
requireHarass(
|
||||
amount === 0 || amount === forces[type] || occupied.length === 1,
|
||||
'Harass & Withdraw with an ambiguous allocation among sectors is still being implemented. Choose a uniquely determined physical commitment.',
|
||||
);
|
||||
for (const [key, group] of occupied) {
|
||||
const count =
|
||||
amount === forces[type]
|
||||
? group[type]
|
||||
: occupied.length === 1
|
||||
? amount
|
||||
: 0;
|
||||
if (count) (allocation[key] ??= { normal: 0, elite: 0 })[type] = count;
|
||||
}
|
||||
}
|
||||
return {
|
||||
returned,
|
||||
remaining: { ...forces, normal: committed.normal, elite: committed.elite },
|
||||
locations: allocation,
|
||||
};
|
||||
}
|
||||
+28
-4
@@ -3677,21 +3677,45 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'During Revival, Recruits doubles every faction’s current free rate and raises the ordinary force limit to seven for this turn.',
|
||||
steps: [
|
||||
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; the other two battle effects remain unfinished.',
|
||||
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits and bounded Harass & Withdraw controls are connected. Reinforcements remains unfinished.',
|
||||
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
|
||||
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
|
||||
'Free-revival prevention still prevents free returns. A Fremen ally grant completed before Recruits doubles from three to six. A later grant, play after a paid ordinary return, unresolved transactions and a second use in the same turn remain guarded pending their rules decisions.',
|
||||
'The preview supports Basic or Advanced faction games without additional optional modules. Public activation, the other two card effects and complete combined-game acceptance remain unfinished.',
|
||||
'The preview supports Basic or Advanced faction games without additional optional modules. Public activation, Reinforcements and complete combined-game acceptance remain unfinished.',
|
||||
],
|
||||
related: ['revival', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Genuine independent three-card setup, physical Recruits play/discard, public turn effect, shared revival quotes, preserved use and guarded timing are connected. Reinforcements and Harass & Withdraw remain missing.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Genuine independent three-card setup, physical Recruits play/discard, public turn effect, shared revival quotes, preserved use and guarded timing are connected. Harass has its own bounded battle prototype; Reinforcements remains missing.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'The owned physical card has full inspection, a Revival action and explicit unavailable reasons. All seats see the effective public rates after play.', evidence: ['components/recruits.tsx', 'components/game-table.tsx', 'game/card-presentation.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the same legal owned offer for useful own or allied revival, then existing authoritative revival choices. Complete card strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'The card guide records examples, current prototype limits and the independent-cap inference; late-paid and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, AI and authenticated recovery checks cover the bounded preview. Sample and browser evidence is recorded separately; full variant acceptance remains open.', evidence: ['tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'card-harass-withdraw',
|
||||
title: 'Harass & Withdraw',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Return your undialed forces to reserves when your Battle Plan is revealed; your leader still faces normal battle risks.',
|
||||
steps: [
|
||||
'Include the physical card in either battle-card slot. It is neither a weapon nor a defense and cannot supply either category for Chemistry or Weirding Way’s alternate role. A single card cannot occupy both slots.',
|
||||
'When Atreides asks for that weapon or defense category, answer None without exposing Harass & Withdraw. The category is fixed, while the special card may occupy its physical slot. A literal Truthtrance promise that a slot is empty still means no physical card.',
|
||||
'Return your undialed ordinary and elite forces to reserves. Your leader can still be killed. An opponent’s successful Traitor call cancels withdrawal, including mutual calls. A Face Dancer cannot replace the forces already returned.',
|
||||
'Discard Harass after use, including a win by your own Traitor call. Reveal-before-explosion return and card-specific discard precedence are explicit implementation inferences; neither is a dedicated combined FAQ ruling.',
|
||||
'The battle controls show the exact return. Basic and Advanced commitments need a unique physical allocation. Ambiguous regular/elite or sector choices, Richese card combinations and additional optional modules are still being integrated. The printed card forbids use on your own Homeworld.',
|
||||
'With Ecaz and an ally together, only the card user’s undialed forces return. Combined army attribution remains unfinished. Public variant starts and complete rules acceptance remain gated.',
|
||||
],
|
||||
related: ['battle-cards', 'battle', 'card-recruits', 'ecaz-modules', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical slot commitment, category inspection, unique typed return before losses, opponent-Traitor cancellation, mandatory disposal and saved continuation are connected. Richese and optional-module combinations remain gated.', evidence: ['game/harass-withdraw.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Either slot, compatible pair selection, category-null commitment, exact private return guide and card inspection are usable. Ambiguous allocation controls remain unfinished.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles have a legal shared-quote path and preserve inspection secrecy. Retreat strategy and combined-game strength remain uncalibrated.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'The card guide distinguishes printed effects, explicit inference and unfinished physical allocation/Stone Burner combinations.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, AI and authenticated recovery checks cover the prototype; final sample and browser evidence remains bounded, with full combination acceptance open.', evidence: ['tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'expansion-faction-games',
|
||||
title: 'Expansion faction development games',
|
||||
@@ -3703,7 +3727,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.',
|
||||
'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.',
|
||||
'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.',
|
||||
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has a Recruits development preview; Reinforcements and Harass & Withdraw remain unfinished.',
|
||||
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits and bounded Harass & Withdraw development controls; Reinforcements remains unfinished.',
|
||||
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
|
||||
'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.',
|
||||
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent to its shipper when allowance encounters that arrival guard, so the shipper can choose another destination. Older No-Field, Smuggler, Nexus and Ambassador shipments are excluded from this recovery. Simultaneous arrival order remains unfinished.',
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseDeck, treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import {
|
||||
battleCardSlotEligible,
|
||||
battleCategoryInspectionValue,
|
||||
fixedBattleInspectionMatches,
|
||||
validBattleSlotPair,
|
||||
} from '../game/battle-card-slots';
|
||||
|
||||
const ecaz = Object.fromEntries(ecazTreacheryCards().map((card) => [card.effect, card]));
|
||||
const base = baseDeck();
|
||||
const projectile = base.find((card) => card.kind === 'projectile')!;
|
||||
const shield = base.find((card) => card.kind === 'shield')!;
|
||||
const worthless = base.find((card) => card.kind === 'worthless')!;
|
||||
const hajr = base.find((card) => card.effect === 'hajr')!;
|
||||
|
||||
void test('slot specials preserve single-card custody and alternate-role category requirements', () => {
|
||||
const cards = treacheryDeck(['ix']);
|
||||
const chemistry = cards.find((card) => card.kind === 'chemistry')!;
|
||||
const weirding = cards.find((card) => card.kind === 'weirdingWay')!;
|
||||
assert.equal(validBattleSlotPair(ecaz.harassWithdraw, shield), true);
|
||||
assert.equal(validBattleSlotPair(projectile, ecaz.harassWithdraw), true);
|
||||
assert.equal(validBattleSlotPair(ecaz.harassWithdraw, ecaz.harassWithdraw), false);
|
||||
assert.equal(validBattleSlotPair(ecaz.recruits, shield), false);
|
||||
assert.equal(validBattleSlotPair(ecaz.harassWithdraw, weirding), false);
|
||||
assert.equal(validBattleSlotPair(chemistry, ecaz.harassWithdraw), false);
|
||||
assert.equal(validBattleSlotPair(weirding, ecaz.harassWithdraw), true);
|
||||
assert.equal(validBattleSlotPair(ecaz.harassWithdraw, chemistry), true);
|
||||
assert.equal(validBattleSlotPair(chemistry, weirding), true);
|
||||
assert.equal(validBattleSlotPair(hajr, shield, true), true);
|
||||
assert.equal(validBattleSlotPair(hajr, weirding, true), false);
|
||||
});
|
||||
|
||||
void test('only the two exact canonical Ecaz battle specials occupy either slot', () => {
|
||||
for (const effect of ['reinforcements', 'harassWithdraw'] as const) {
|
||||
assert.equal(battleCardSlotEligible('weapon', ecaz[effect]), true);
|
||||
assert.equal(battleCardSlotEligible('defense', ecaz[effect]), true);
|
||||
}
|
||||
for (const forged of [
|
||||
ecaz.recruits,
|
||||
{ ...ecaz.reinforcements, name: 'Forged Reinforcements' },
|
||||
{ ...ecaz.harassWithdraw, extra: true },
|
||||
{ ...ecaz.reinforcements, id: 'similar-reinforcements' },
|
||||
]) {
|
||||
assert.equal(battleCardSlotEligible('weapon', forged), false);
|
||||
assert.equal(battleCardSlotEligible('defense', forged), false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordinary categories and externally validated Planetologist substitution remain unchanged', () => {
|
||||
assert.equal(battleCardSlotEligible('weapon', undefined), true);
|
||||
assert.equal(battleCardSlotEligible('defense', undefined), true);
|
||||
assert.equal(battleCardSlotEligible('weapon', projectile), true);
|
||||
assert.equal(battleCardSlotEligible('defense', projectile), false);
|
||||
assert.equal(battleCardSlotEligible('weapon', shield), false);
|
||||
assert.equal(battleCardSlotEligible('defense', shield), true);
|
||||
assert.equal(battleCardSlotEligible('weapon', worthless), true);
|
||||
assert.equal(battleCardSlotEligible('defense', worthless), true);
|
||||
assert.equal(battleCardSlotEligible('weapon', hajr), false);
|
||||
assert.equal(battleCardSlotEligible('weapon', hajr, { planetologistWeapon: true }), true);
|
||||
assert.equal(battleCardSlotEligible('defense', hajr, { planetologistWeapon: true }), false);
|
||||
});
|
||||
|
||||
void test('category inspection hides Ecaz slot occupants without changing existing Planetologist or ordinary answers', () => {
|
||||
assert.equal(
|
||||
battleCategoryInspectionValue('weapon', ecaz.reinforcements.id, ecaz.reinforcements),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
battleCategoryInspectionValue('defense', ecaz.harassWithdraw.id, ecaz.harassWithdraw),
|
||||
null,
|
||||
);
|
||||
assert.equal(battleCategoryInspectionValue('weapon', hajr.id, hajr), hajr.id);
|
||||
assert.equal(battleCategoryInspectionValue('defense', worthless.id, worthless), worthless.id);
|
||||
assert.equal(battleCategoryInspectionValue('weapon', projectile.id, projectile), projectile.id);
|
||||
assert.equal(battleCategoryInspectionValue('weapon', ecaz.recruits.id, ecaz.recruits), ecaz.recruits.id);
|
||||
});
|
||||
|
||||
void test('non-card fields, empty slots and unfinished partial values preserve their exact shape', () => {
|
||||
assert.equal(battleCategoryInspectionValue('dial', 2.5), 2.5);
|
||||
assert.equal(battleCategoryInspectionValue('leader', 'atreides-0'), 'atreides-0');
|
||||
assert.equal(battleCategoryInspectionValue('weapon', null), null);
|
||||
assert.equal(battleCategoryInspectionValue('defense', undefined), undefined);
|
||||
assert.throws(
|
||||
() => battleCategoryInspectionValue('weapon', projectile.id, shield),
|
||||
/does not match/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('fixed category comparisons keep partial search open and compare resolved category values', () => {
|
||||
assert.equal(fixedBattleInspectionMatches('weapon', null, undefined), true);
|
||||
assert.equal(
|
||||
fixedBattleInspectionMatches('weapon', null, ecaz.reinforcements.id, ecaz.reinforcements),
|
||||
true,
|
||||
);
|
||||
assert.equal(fixedBattleInspectionMatches('weapon', null, projectile.id, projectile), false);
|
||||
assert.equal(
|
||||
fixedBattleInspectionMatches('defense', worthless.id, worthless.id, worthless),
|
||||
true,
|
||||
);
|
||||
assert.equal(fixedBattleInspectionMatches('dial', 3.5, 3.5), true);
|
||||
assert.equal(fixedBattleInspectionMatches('leader', 'atreides-0', null), false);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { quoteHarassWithdraw } from '../game/harass-withdraw';
|
||||
import { harassWithdrawGame, harassCustody, takeHarassCard } from './fixture-harass-withdraw';
|
||||
|
||||
function projection(game: Game, profile: Difficulty) {
|
||||
const view = viewGame(game, 'a');
|
||||
view.players.find(player => player.id === 'a')!.bot = profile;
|
||||
return view;
|
||||
}
|
||||
const usesHarass = (action: Action) =>
|
||||
action.weapon === 'ecaz-harass-withdraw' || action.defense === 'ecaz-harass-withdraw';
|
||||
|
||||
void test('each AI profile can commit Harass in either slot through genuine Basic and Advanced battle setup', () => {
|
||||
for (const advanced of [false, true]) for (const profile of DIFFICULTIES) {
|
||||
const game = harassWithdrawGame({ advanced });
|
||||
const view = projection(game, profile), before = structuredClone(view);
|
||||
const candidates = botActions(view).filter(action => action.type === 'battlePlan' && usesHarass(action));
|
||||
assert.deepEqual(view, before);
|
||||
assert.ok(candidates.length, `${profile}/${advanced} omitted Harass`);
|
||||
for (const slot of ['weapon', 'defense'] as const) {
|
||||
const action = candidates.find(action => action[slot] === 'ecaz-harass-withdraw');
|
||||
assert.ok(action, `${profile}/${advanced} omitted ${slot}`);
|
||||
quoteHarassWithdraw(view.battle!.harassWithdraw!, Number(action.dial), Number(action.support ?? 0));
|
||||
const sealed = applyAction(game, 'a', action);
|
||||
assert.equal(sealed.battle!.plans.a[slot], 'ecaz-harass-withdraw');
|
||||
harassCustody(sealed);
|
||||
}
|
||||
assert.ok(candidates.every(action => action.weapon !== 'ecaz-reinforcements' && action.defense !== 'ecaz-reinforcements'));
|
||||
}
|
||||
});
|
||||
|
||||
void test('all profiles answer category inspections without revealing Harass and can keep it in a committed empty-category slot', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
let game = harassWithdrawGame({ prepare: false });
|
||||
for (let step = 0; game.response && step < 10; step++) {
|
||||
const id = game.players.find(player => !game.response!.passed.includes(player.id))!.id;
|
||||
game = applyAction(game, id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.battle!.preparation?.kind, 'prescience');
|
||||
game = applyAction(game, 'd', { type: 'prescience', field: 'weapon' });
|
||||
const answers = botActions(projection(game, profile));
|
||||
assert.ok(answers.length);
|
||||
assert.ok(answers.every(action => action.type === 'prescienceAnswer' && action.value === null));
|
||||
game = applyAction(game, 'a', answers[0]);
|
||||
const view = projection(game, profile);
|
||||
const action = botActions(view).find(action => action.type === 'battlePlan' && action.weapon === 'ecaz-harass-withdraw');
|
||||
assert.ok(action, `${profile} erased the inspected slot occupant`);
|
||||
const sealed = applyAction(game, 'a', action);
|
||||
assert.equal(sealed.battle!.prescience!.value, null);
|
||||
assert.equal(viewGame(sealed, 'd').battle!.insight!.value, null);
|
||||
assert.equal(viewGame(sealed, 't').battle!.harassWithdraw, null);
|
||||
harassCustody(sealed);
|
||||
}
|
||||
});
|
||||
|
||||
void test('hidden opponent card swaps leave the Harass planner input and actions identical', () => {
|
||||
const game = harassWithdrawGame();
|
||||
takeHarassCard(game, 'd', baseDeck()[0].id);
|
||||
takeHarassCard(game, 't', baseDeck()[1].id);
|
||||
const changed = structuredClone(game);
|
||||
[changed.players[1].hand[0], changed.players[2].hand[0]] = [changed.players[2].hand[0], changed.players[1].hand[0]];
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const before = projection(game, profile), after = projection(changed, profile);
|
||||
assert.deepEqual(after, before);
|
||||
assert.deepEqual(botActions(after), botActions(before));
|
||||
}
|
||||
harassCustody(changed);
|
||||
});
|
||||
|
||||
void test('the planner excludes a publicly blocked Harass module without reading opposing holdings', () => {
|
||||
const game = harassWithdrawGame();
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const view = projection(game, profile);
|
||||
view.battle!.harassWithdraw = { ...view.battle!.harassWithdraw!, blocked: 'This configured card combination awaits a ruling.' };
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.length);
|
||||
assert.ok(actions.every(action => !usesHarass(action)));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import { baseDeck, ixBattleCards } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { battlePlanCardValue, battlePlanCardOptions, bindBattlePlanCommitments, BattlePreparation } from '../components/battle-preparation';
|
||||
import { harassWithdrawControlState, HarassWithdrawGuide } from '../components/harass-withdraw';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
const harass = ecazTreacheryCards().find(card => card.effect === 'harassWithdraw')!;
|
||||
const projectile = baseDeck().find(card => card.kind === 'projectile')!;
|
||||
|
||||
// Rendering fixture only; engine and recovery tests earn these projections.
|
||||
function fixture(): GameView {
|
||||
const g = createGame('ECAZBATTLEUI', newPlayer('a', 'Atreides', 'atreides'));
|
||||
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(g, { status: 'playing', phase: 6, turn: 2, storm: 18, active: 'a', order: ['a', 'h'] });
|
||||
g.deck = baseDeck();
|
||||
for (const player of g.players) {
|
||||
player.hand = [];
|
||||
player.forces = { 'arrakeen:10': 4 };
|
||||
player.reserves = 16;
|
||||
player.spice = 10;
|
||||
player.traitors = [];
|
||||
}
|
||||
g.battle = { event: 'battle-ecaz-ui', territory: 'arrakeen', attacker: 'a', defender: 'h',
|
||||
prepared: true, plans: {}, revealed: false, traitorCalls: {} };
|
||||
const view = viewGame(g, 'h');
|
||||
view.players[1].hand = [projectile, ...ecazTreacheryCards()];
|
||||
view.battle!.harassWithdraw = {
|
||||
card: 'ecaz-harass-withdraw', blocked: null,
|
||||
forces: { normal: 4, elite: 0, eliteStrength: 1, freeSupport: true },
|
||||
locations: { 'arrakeen:10': { normal: 4, elite: 0 } },
|
||||
};
|
||||
view.battle!.ownCommitments = [{ source: 'native', beneficiary: 'a', target: 'h', field: 'weapon', value: null }];
|
||||
return view;
|
||||
}
|
||||
|
||||
void test('a no-category inspection preserves a selected Harass card while binding other committed elements', () => {
|
||||
const view = fixture();
|
||||
view.battle!.ownCommitments.push({ source: 'nexus', beneficiary: 'a', target: 'h', field: 'dial', value: 2 });
|
||||
const action = { type: 'battlePlan', dial: 0, leader: 'harkonnen-0', weapon: harass.id, defense: null };
|
||||
assert.equal(battlePlanCardValue(view, 'weapon', harass.id), harass.id);
|
||||
assert.deepEqual(bindBattlePlanCommitments(view, action), { ...action, dial: 2 });
|
||||
assert.equal(action.dial, 0);
|
||||
assert.equal(bindBattlePlanCommitments(view, { ...action, weapon: projectile.id }).weapon, null);
|
||||
assert.equal(bindBattlePlanCommitments(view, { ...action, weapon: 'unknown-card' }).weapon, null);
|
||||
view.players[1].hand = [{ ...harass, name: 'Forged card' }];
|
||||
assert.equal(bindBattlePlanCommitments(view, action).weapon, null);
|
||||
});
|
||||
|
||||
void test('named card inspections still lock their exact physical card, including existing Planetologist behavior', () => {
|
||||
const view = fixture();
|
||||
const atomics = baseDeck().find(card => card.effect === 'atomics')!;
|
||||
view.players[1].hand!.push(atomics);
|
||||
for (const card of [projectile, atomics]) {
|
||||
view.battle!.ownCommitments[0].value = card.id;
|
||||
assert.equal(battlePlanCardValue(view, 'weapon', harass.id), card.id);
|
||||
assert.equal(bindBattlePlanCommitments(view, { type: 'battlePlan', weapon: harass.id }).weapon, card.id);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the table keeps a no-category slot selectable and offers Harass without unfinished Reinforcements', () => {
|
||||
const view = fixture();
|
||||
const html = renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false }));
|
||||
const select = html.match(/<select[^>]*id="battle-weapon"[^>]*>[\s\S]*?<\/select>/)?.[0];
|
||||
assert.ok(select);
|
||||
assert.doesNotMatch(select, /disabled|ecaz-reinforcements|ecaz-recruits|value="treachery-/);
|
||||
assert.match(select, /value="ecaz-harass-withdraw"/);
|
||||
assert.match(html, /Harass and Withdraw battle guidance/);
|
||||
});
|
||||
|
||||
void test('the category answer control explains None without exposing a special as a named answer', () => {
|
||||
const view = fixture();
|
||||
view.battle!.preparation = { kind: 'prescienceAnswer', owner: 'h', beneficiary: 'a' };
|
||||
view.battle!.prescience = { player: 'a', field: 'weapon', answered: false };
|
||||
const html = renderToStaticMarkup(createElement(BattlePreparation, { game: view, send: async () => {}, busy: false }));
|
||||
assert.match(html, /Choose None/);
|
||||
assert.doesNotMatch(html, /<option[^>]*value="ecaz-/);
|
||||
view.battle!.harassWithdraw = { ...view.battle!.harassWithdraw!, blocked: 'This card combination awaits a ruling.' };
|
||||
const blocked = renderToStaticMarkup(createElement(BattlePreparation, { game: view, send: async () => {}, busy: false }));
|
||||
assert.doesNotMatch(blocked, /Choose None/);
|
||||
});
|
||||
|
||||
void test('choosing Harass in Defense first excludes a duplicate physical card and Chemistry from Weapon', () => {
|
||||
const view = fixture();
|
||||
view.battle!.ownCommitments = [];
|
||||
const chemistry = ixBattleCards().find(card => card.kind === 'chemistry')!;
|
||||
view.players[1].hand!.push(chemistry);
|
||||
const choices = battlePlanCardOptions(view, 'weapon', 'harkonnen-0', '', harass.id);
|
||||
assert.ok(choices.some(card => card.id === projectile.id));
|
||||
assert.ok(!choices.some(card => card.id === harass.id || card.id === chemistry.id));
|
||||
assert.ok(battlePlanCardOptions(view, 'weapon', 'harkonnen-0', '', chemistry.id).some(card => card.id === harass.id));
|
||||
});
|
||||
|
||||
void test('the guide uses the physical return quote and gives an actionable reason for unsupported commitments', () => {
|
||||
const preview = fixture().battle!.harassWithdraw!;
|
||||
const state = harassWithdrawControlState(preview, true, 1, 0);
|
||||
assert.deepEqual(state.returned, { normal: 3, elite: 0 });
|
||||
const html = renderToStaticMarkup(createElement(HarassWithdrawGuide, { preview, state }));
|
||||
assert.match(html, /Return 3 ordinary and 0 elite/);
|
||||
assert.match(html, /successful traitor call cancels/);
|
||||
assert.equal(harassWithdrawControlState(preview, false, 1, 0).blocked, null);
|
||||
assert.ok(harassWithdrawControlState(preview, true, 1.5, 0).blocked);
|
||||
const blocked = { ...preview, blocked: 'Finish the unsupported combined module first.' };
|
||||
assert.equal(harassWithdrawControlState(blocked, true, 1, 0).blocked, blocked.blocked);
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { viewGame, type Action, type Game } from "../game/engine";
|
||||
import type { RoomsClock } from "../db/rooms";
|
||||
import { unitStore } from "./fixture-nexus-room-store";
|
||||
import { harassCustody, harassWithdrawGame } from "./fixture-harass-withdraw";
|
||||
|
||||
const clock: RoomsClock = { now: () => 83000, sleep: async () => {} };
|
||||
const rows = (sqlite: DatabaseSync) => ({
|
||||
rooms: sqlite.prepare("SELECT * FROM rooms ORDER BY code").all(),
|
||||
seats: sqlite.prepare("SELECT * FROM seats ORDER BY player_id").all(),
|
||||
});
|
||||
|
||||
async function fixture(t: test.TestContext, advanced: boolean) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom(
|
||||
"Ecaz battle recovery",
|
||||
"emperor",
|
||||
advanced,
|
||||
["ix"],
|
||||
);
|
||||
const code = made.view.code;
|
||||
const tokens = [
|
||||
made.token,
|
||||
(await store.rooms.joinRoom(code, "Atreides", "atreides")).token!,
|
||||
(await store.rooms.joinRoom(code, "Tleilaxu", "tleilaxu")).token!,
|
||||
];
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
|
||||
const game = harassWithdrawGame({
|
||||
advanced,
|
||||
ids,
|
||||
factions: ["emperor", "atreides", "tleilaxu"],
|
||||
prepare: false,
|
||||
});
|
||||
game.code = code;
|
||||
game.version = (await store.rooms.readRoom(code)).version;
|
||||
harassCustody(game);
|
||||
store.sqlite
|
||||
.prepare("UPDATE rooms SET state=?, version=? WHERE code=?")
|
||||
.run(JSON.stringify(game), game.version, code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, code, tokens, auths, ids };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
|
||||
async function act(f: Fixture, player: string, action: Action) {
|
||||
const rooms = f.restart();
|
||||
const index = f.ids.indexOf(player);
|
||||
assert.ok(index >= 0);
|
||||
const auth = await rooms.authenticate(f.code, f.tokens[index]);
|
||||
const before = await rooms.readRoom(f.code);
|
||||
await rooms.act(f.code, auth, before.version, action, clock);
|
||||
const after = await rooms.readRoom(f.code);
|
||||
harassCustody(after);
|
||||
return after;
|
||||
}
|
||||
|
||||
async function restored(f: Fixture) {
|
||||
const game = await f.restart().readRoom(f.code);
|
||||
const before = rows(f.sqlite);
|
||||
for (const [index, token] of f.tokens.entries()) {
|
||||
const rooms = f.restart();
|
||||
const auth = await rooms.authenticate(f.code, token);
|
||||
const view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(game, f.ids[index]));
|
||||
assert.deepEqual(
|
||||
view,
|
||||
viewGame(JSON.parse(JSON.stringify(game)) as Game, f.ids[index]),
|
||||
);
|
||||
for (const rival of view.players.filter((p) => p.id !== auth.playerId))
|
||||
for (const field of ["hand", "traitors", "spice", "faceDancers"])
|
||||
assert.equal(field in rival, false);
|
||||
}
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
harassCustody(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
async function declineFullPlanOffer(f: Fixture) {
|
||||
const game = await f.restart().readRoom(f.code);
|
||||
if (game.decision?.kind === "fullPlanOffer")
|
||||
await act(f, game.decision.player, { type: "decision", decline: true });
|
||||
}
|
||||
|
||||
void test("sealed Harass plan and category-null Prescience survive authenticated private restoration", async (t) => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = await fixture(t, advanced);
|
||||
await act(f, f.ids[1], { type: "prescience", field: "weapon" });
|
||||
await act(f, f.ids[0], { type: "prescienceAnswer", value: null });
|
||||
await declineFullPlanOffer(f);
|
||||
const sealed = await act(f, f.ids[0], {
|
||||
type: "battlePlan",
|
||||
dial: 1,
|
||||
support: 0,
|
||||
leader: "emperor-0",
|
||||
weapon: "ecaz-harass-withdraw",
|
||||
defense: null,
|
||||
});
|
||||
assert.equal(sealed.battle?.plans[f.ids[0]].weapon, "ecaz-harass-withdraw");
|
||||
assert.equal(sealed.battle?.prescience?.value, null);
|
||||
|
||||
const restoredGame = await restored(f);
|
||||
const atreides = viewGame(restoredGame, f.ids[1]).battle!;
|
||||
assert.deepEqual(atreides.insight, {
|
||||
field: "weapon",
|
||||
value: null,
|
||||
label: "None",
|
||||
});
|
||||
assert.deepEqual(atreides.plans, {});
|
||||
const observer = viewGame(restoredGame, f.ids[2]).battle!;
|
||||
assert.equal(observer.insight, null);
|
||||
assert.deepEqual(observer.plans, {});
|
||||
assert.deepEqual(observer.cards, []);
|
||||
assert.equal(
|
||||
JSON.stringify(observer).includes("ecaz-harass-withdraw"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test("competing final traitor declines resolve one withdrawal and one discard after restart", async (t) => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = await fixture(t, advanced);
|
||||
const seatRows = rows(f.sqlite).seats;
|
||||
await act(f, f.ids[1], { type: "prescience", field: "weapon" });
|
||||
await act(f, f.ids[0], { type: "prescienceAnswer", value: null });
|
||||
await declineFullPlanOffer(f);
|
||||
await act(f, f.ids[0], {
|
||||
type: "battlePlan",
|
||||
dial: 1,
|
||||
support: advanced ? 1 : 0,
|
||||
leader: "emperor-0",
|
||||
weapon: "ecaz-harass-withdraw",
|
||||
defense: null,
|
||||
});
|
||||
await act(f, f.ids[1], {
|
||||
type: "battlePlan",
|
||||
dial: 1,
|
||||
support: advanced ? 1 : 0,
|
||||
leader: "atreides-0",
|
||||
weapon: null,
|
||||
defense: null,
|
||||
});
|
||||
await act(f, f.ids[0], { type: "traitorCall", call: false });
|
||||
|
||||
const rooms = f.restart();
|
||||
const auth = await rooms.authenticate(f.code, f.tokens[1]);
|
||||
const before = await rooms.readRoom(f.code);
|
||||
const writeStart = f.writes.length;
|
||||
let entered = 0;
|
||||
let release!: () => void;
|
||||
const both = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++entered === 2) release();
|
||||
await both;
|
||||
};
|
||||
const attempts = await Promise.allSettled(
|
||||
[0, 1].map(() =>
|
||||
rooms.act(
|
||||
f.code,
|
||||
auth,
|
||||
before.version,
|
||||
{ type: "traitorCall", call: false },
|
||||
clock,
|
||||
),
|
||||
),
|
||||
);
|
||||
f.hooks.beforeWrite = undefined;
|
||||
assert.equal(
|
||||
attempts.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.writes
|
||||
.slice(writeStart)
|
||||
.map((write) => write.changes)
|
||||
.sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
|
||||
const resolved = await restored(f);
|
||||
assert.equal(resolved.version, before.version + 1);
|
||||
assert.equal(resolved.battle, null);
|
||||
assert.equal(resolved.decision?.kind, "faceDance");
|
||||
assert.equal(resolved.players[0].reserves, 19);
|
||||
assert.equal(resolved.players[0].tanks, 1);
|
||||
assert.deepEqual(resolved.players[0].forces, {});
|
||||
assert.equal(
|
||||
resolved.discard.filter((card) => card.id === "ecaz-harass-withdraw")
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
resolved.log.filter((entry) =>
|
||||
entry.text.includes("used Harass & Withdraw to return 4"),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
|
||||
await act(f, f.ids[2], { type: "decision", reveal: false });
|
||||
const terminal = await restored(f);
|
||||
assert.equal(terminal.battle, null);
|
||||
assert.equal(terminal.decision, null);
|
||||
assert.equal(terminal.response, null);
|
||||
assert.equal(terminal.pendingTreacheryDiscard, null);
|
||||
assert.equal(terminal.phase, 7);
|
||||
assert.equal(
|
||||
terminal.discard.filter((card) => card.id === "ecaz-harass-withdraw")
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite).seats, seatRows);
|
||||
}
|
||||
});
|
||||
@@ -123,7 +123,7 @@ void test('verified inventory and source rules remain distinct from unimplemente
|
||||
assert.equal(definition.verification.sourceRules, 'verified');
|
||||
assert.equal(
|
||||
definition.verification.runtime,
|
||||
definition.card.effect === 'recruits' ? 'prototype' : 'not-implemented',
|
||||
definition.card.effect === 'reinforcements' ? 'not-implemented' : 'prototype',
|
||||
);
|
||||
assert.equal(definition.verification.combinedInteractions, 'incomplete');
|
||||
assert.ok(definition.verification.unresolved.length > 0);
|
||||
@@ -154,9 +154,10 @@ void test('every canonical Ecaz face receives its full original gameplay guide a
|
||||
assert.equal(serialized.includes(note), false);
|
||||
assert.equal(serialized.includes('sourceRules'), false);
|
||||
assert.equal(serialized.includes('not-implemented'), false);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), ['card-recruits']);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits']);
|
||||
assert.match(presentation.availability ?? '', card.effect === 'recruits'
|
||||
? /development controls during Revival/ : /battle effect is still being implemented/);
|
||||
? /development controls during Revival/ : card.effect === 'harassWithdraw'
|
||||
? /development controls in either battle-card slot/ : /battle effect is still being implemented/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeEcazTreacheryGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import { faction, type FactionId } from '../game/catalog';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
|
||||
export function takeHarassCard(g: Game, owner: string, cardId: string): Card {
|
||||
const target = g.players.find((p) => p.id === owner)!;
|
||||
for (const zone of [g.deck, g.discard, ...g.players.map((p) => p.hand)]) {
|
||||
const index = zone.findIndex((c) => c.id === cardId);
|
||||
if (index < 0) continue;
|
||||
const [card] = zone.splice(index, 1);
|
||||
target.hand.push(card);
|
||||
return card;
|
||||
}
|
||||
throw new Error(`Missing physical fixture card ${cardId}`);
|
||||
}
|
||||
export function finishHarassPreparation(game: Game): Game {
|
||||
for (let count = 0; count < 60; count++) {
|
||||
if (
|
||||
game.battle?.preLeader &&
|
||||
!game.battle.preLeader.closed &&
|
||||
!game.response &&
|
||||
!game.decision
|
||||
) {
|
||||
const player = [game.battle.attacker, game.battle.defender].find(
|
||||
(id) => !game.battle!.preLeader!.ready.includes(id),
|
||||
)!;
|
||||
game = applyAction(game, player, {
|
||||
type: 'battlePreparationReady',
|
||||
event: game.battle.preLeader.event,
|
||||
});
|
||||
} else if (game.response) {
|
||||
const player = game.players.find(
|
||||
(p) => !game.response!.passed.includes(p.id),
|
||||
)!;
|
||||
game = applyAction(game, player.id, { type: 'passResponse' });
|
||||
} else if (game.battle?.preparation) {
|
||||
game = applyAction(game, game.battle.preparation.owner, {
|
||||
type: 'declineBattlePower',
|
||||
});
|
||||
} else if (game.decision?.kind === 'fullPlanOffer') {
|
||||
game = applyAction(game, game.decision.player, {
|
||||
type: 'decision',
|
||||
decline: true,
|
||||
});
|
||||
} else return game;
|
||||
}
|
||||
throw new Error('Battle preparation did not complete.');
|
||||
}
|
||||
/** Genuine variant setup; hands and Arrakeen losses/positions are explicitly staged with conserved pieces. */
|
||||
export function harassWithdrawGame({
|
||||
advanced = false,
|
||||
factions = ['emperor', 'atreides', 'tleilaxu'] as readonly FactionId[],
|
||||
ids = ['a', 'd', 't'] as readonly string[],
|
||||
normal = 5,
|
||||
elite = 0,
|
||||
prepare = true,
|
||||
territory = 'arrakeen',
|
||||
sector = 10,
|
||||
} = {}): Game {
|
||||
assert.equal(factions.length, ids.length);
|
||||
const expansions = [
|
||||
...new Set(
|
||||
factions.map((id) => faction(id).expansion).filter((id) => id !== 'base'),
|
||||
),
|
||||
] as ('ix' | 'choam' | 'ecaz')[];
|
||||
let g = createGame(
|
||||
'HARASSQA',
|
||||
newPlayer(ids[0], factions[0], factions[0]),
|
||||
advanced,
|
||||
expansions,
|
||||
);
|
||||
factions
|
||||
.slice(1)
|
||||
.forEach((id, index) => joinGame(g, newPlayer(ids[index + 1], id, id)));
|
||||
for (const player of g.players)
|
||||
g = applyAction(g, player.id, { type: 'ready' });
|
||||
g = initializeEcazTreacheryGameForAudit(g);
|
||||
for (let step = 0; g.status === 'setup' && step < 80; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of g.players) {
|
||||
const view = viewGame(g, player.id);
|
||||
view.players.find((p) => p.id === player.id)!.bot = 'Medium';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, player.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const [index, player] of g.players.entries()) {
|
||||
g.deck.push(...player.hand);
|
||||
player.hand = [];
|
||||
const amount = index === 0 ? normal + elite : index === 1 ? 5 : 0;
|
||||
player.forces = amount ? { [`${territory}:${sector}`]: amount } : {};
|
||||
player.reserves = 20 - amount;
|
||||
player.tanks = 0;
|
||||
player.spice = 30;
|
||||
player.battleLosses = 0;
|
||||
if (player.elites) {
|
||||
const total =
|
||||
player.faction === 'emperor' ? 5 : player.faction === 'ixians' ? 7 : 3;
|
||||
const selected = index === 0 ? elite : 0;
|
||||
assert.ok(selected <= total);
|
||||
player.elites = {
|
||||
reserves: total - selected,
|
||||
tanks: 0,
|
||||
forces: selected ? { [`${territory}:${sector}`]: selected } : {},
|
||||
revived: 0,
|
||||
};
|
||||
} else assert.ok(index !== 0 || !elite);
|
||||
}
|
||||
takeHarassCard(g, ids[0], 'ecaz-harass-withdraw');
|
||||
Object.assign(g, {
|
||||
turn: 2,
|
||||
phase: 6,
|
||||
storm: 18,
|
||||
order: [...ids],
|
||||
active: ids[0],
|
||||
ready: [],
|
||||
decision: null,
|
||||
response: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
g = applyAction(g, ids[0], {
|
||||
type: 'chooseBattle',
|
||||
territory,
|
||||
target: ids[1],
|
||||
});
|
||||
return prepare ? finishHarassPreparation(g) : g;
|
||||
}
|
||||
export function harassCustody(g: Game) {
|
||||
const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
|
||||
assert.deepEqual(
|
||||
cards.map((c) => c.id).sort(),
|
||||
[...treacheryDeck(g.expansions), ...ecazTreacheryCards()]
|
||||
.map((c) => c.id)
|
||||
.sort(),
|
||||
);
|
||||
assert.deepEqual(
|
||||
[
|
||||
...(g.traitorReserve ?? []),
|
||||
...g.players.flatMap((p) => [
|
||||
...p.traitors,
|
||||
...(p.faceDancers ?? []).map((c) => c.leader),
|
||||
]),
|
||||
].sort(),
|
||||
traitorDeck(g.players, g.expansions.includes('ix')).sort(),
|
||||
);
|
||||
for (const p of g.players) {
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
);
|
||||
assert.ok(
|
||||
[p.reserves, p.tanks, ...Object.values(p.forces)].every(
|
||||
(n) => Number.isSafeInteger(n) && n >= 0,
|
||||
),
|
||||
);
|
||||
if (p.elites) {
|
||||
assert.equal(
|
||||
p.elites.reserves +
|
||||
p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
||||
p.faction === 'emperor' ? 5 : p.faction === 'ixians' ? 7 : 3,
|
||||
);
|
||||
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
||||
for (const [key, amount] of Object.entries(p.elites.forces))
|
||||
assert.ok(amount <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { quoteHarassWithdraw } from '../game/harass-withdraw';
|
||||
import {
|
||||
quoteBattleResolution,
|
||||
type BattleResolutionInput,
|
||||
} from '../game/battle-resolution-quote';
|
||||
import {
|
||||
finishHarassPreparation,
|
||||
harassCustody,
|
||||
harassWithdrawGame,
|
||||
takeHarassCard,
|
||||
} from './fixture-harass-withdraw';
|
||||
|
||||
const ownLeader = (g: Game) =>
|
||||
[...g.players[0].leaders].sort((a, b) => b.strength - a.strength)[0].id;
|
||||
const otherLeader = (g: Game) =>
|
||||
[...g.players[1].leaders].sort((a, b) => a.strength - b.strength)[0].id;
|
||||
function ownPlan(g: Game, extra: Partial<Action> = {}): Action {
|
||||
return {
|
||||
type: 'battlePlan',
|
||||
dial: 2,
|
||||
support: g.advanced ? 2 : 0,
|
||||
leader: ownLeader(g),
|
||||
defense: 'ecaz-harass-withdraw',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
function seal(
|
||||
g: Game,
|
||||
own: Partial<Action> = {},
|
||||
other: Partial<Action> = {},
|
||||
): Game {
|
||||
g = applyAction(g, g.players[0].id, ownPlan(g, own));
|
||||
return applyAction(g, g.players[1].id, {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
support: 0,
|
||||
leader: otherLeader(g),
|
||||
...other,
|
||||
});
|
||||
}
|
||||
function resolve(g: Game, own = false, other = false): Game {
|
||||
g = applyAction(g, g.players[0].id, { type: 'traitorCall', call: own });
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(
|
||||
viewGame(JSON.parse(JSON.stringify(g)), p.id),
|
||||
viewGame(g, p.id),
|
||||
);
|
||||
const before = JSON.stringify(g);
|
||||
const done = applyAction(g, g.players[1].id, {
|
||||
type: 'traitorCall',
|
||||
call: other,
|
||||
});
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
harassCustody(done);
|
||||
for (const p of done.players)
|
||||
assert.deepEqual(
|
||||
viewGame(JSON.parse(JSON.stringify(done)), p.id),
|
||||
viewGame(done, p.id),
|
||||
);
|
||||
return done;
|
||||
}
|
||||
function reject(g: Game, player: string, action: Action, reason: RegExp) {
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(() => applyAction(g, player, action), reason);
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
function setIdentity(
|
||||
g: Game,
|
||||
target: { get(): string; set(value: string): void },
|
||||
identity: string,
|
||||
) {
|
||||
const entries = [
|
||||
...(g.traitorReserve ?? []).map((_, i) => ({
|
||||
get: () => g.traitorReserve![i],
|
||||
set: (value: string) => {
|
||||
g.traitorReserve![i] = value;
|
||||
},
|
||||
})),
|
||||
...g.players.flatMap((p) => [
|
||||
...p.traitors.map((_, i) => ({
|
||||
get: () => p.traitors[i],
|
||||
set: (value: string) => {
|
||||
p.traitors[i] = value;
|
||||
},
|
||||
})),
|
||||
...(p.faceDancers ?? []).map((_, i) => ({
|
||||
get: () => p.faceDancers![i].leader,
|
||||
set: (value: string) => {
|
||||
p.faceDancers![i].leader = value;
|
||||
},
|
||||
})),
|
||||
]),
|
||||
];
|
||||
const source = entries.find((entry) => entry.get() === identity)!;
|
||||
assert.ok(source);
|
||||
const old = target.get();
|
||||
source.set(old);
|
||||
target.set(identity);
|
||||
}
|
||||
function heldTraitor(g: Game, player: number, identity: string) {
|
||||
const p = g.players[player];
|
||||
setIdentity(
|
||||
g,
|
||||
{
|
||||
get: () => p.traitors[0],
|
||||
set: (value) => {
|
||||
p.traitors[0] = value;
|
||||
},
|
||||
},
|
||||
identity,
|
||||
);
|
||||
}
|
||||
|
||||
void test('Harass & Withdraw returns only undialed forces for Basic/Advanced winners and losers in either slot', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const slot of ['weapon', 'defense'])
|
||||
for (const winning of [false, true]) {
|
||||
let g = harassWithdrawGame({ advanced });
|
||||
const start = g.players[0].reserves;
|
||||
g = seal(
|
||||
g,
|
||||
slot === 'weapon'
|
||||
? { weapon: 'ecaz-harass-withdraw', defense: null }
|
||||
: {},
|
||||
winning
|
||||
? {}
|
||||
: {
|
||||
dial: 5,
|
||||
support: advanced ? 5 : 0,
|
||||
leader: g.players[1].leaders[0].id,
|
||||
},
|
||||
);
|
||||
const done = resolve(g);
|
||||
assert.equal(done.lastBattleContext?.winner, winning ? 'a' : 'd');
|
||||
assert.equal(done.players[0].reserves, start + 3);
|
||||
assert.equal(done.players[0].tanks, 2);
|
||||
assert.deepEqual(done.players[0].forces, {});
|
||||
assert.equal(
|
||||
done.discard.filter((c) => c.id === 'ecaz-harass-withdraw').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
done.log.filter((entry) =>
|
||||
entry.text.includes('used Harass & Withdraw'),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
done.players[0].leaders.find((l) => l.id === ownLeader(g))!.dead,
|
||||
false,
|
||||
);
|
||||
reject(
|
||||
done,
|
||||
'd',
|
||||
{ type: 'traitorCall', call: false },
|
||||
/battle|traitor|pending|response|Waiting|Resolve/i,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('zero dial and unique half-strength elite allocation preserve exact reserve and Tanks subpools', () => {
|
||||
let g = harassWithdrawGame();
|
||||
let done = resolve(seal(g, { dial: 0 }));
|
||||
assert.equal(done.players[0].reserves, 20);
|
||||
assert.equal(done.players[0].tanks, 0);
|
||||
g = harassWithdrawGame({ advanced: true, normal: 1, elite: 2 });
|
||||
const context = viewGame(g, 'a').battle!.harassWithdraw!;
|
||||
const quote = quoteHarassWithdraw(context, 1, 0);
|
||||
assert.deepEqual(quote.returned, { normal: 1, elite: 1 });
|
||||
done = resolve(seal(g, { dial: 1, support: 0 }));
|
||||
assert.equal(done.players[0].reserves, 19);
|
||||
assert.equal(done.players[0].tanks, 1);
|
||||
assert.equal(done.players[0].elites!.reserves, 4);
|
||||
assert.equal(done.players[0].elites!.tanks, 1);
|
||||
assert.deepEqual(done.players[0].elites!.forces, {});
|
||||
});
|
||||
|
||||
void test('own successful traitor retains dialed forces but the used Harass card is a mandatory discard', () => {
|
||||
let g = harassWithdrawGame({ advanced: true });
|
||||
heldTraitor(g, 0, otherLeader(g));
|
||||
harassCustody(g);
|
||||
g = seal(g);
|
||||
const done = resolve(g, true, false);
|
||||
assert.equal(done.lastBattleContext?.result, 'traitor');
|
||||
assert.equal(done.players[0].reserves, 18);
|
||||
assert.equal(done.players[0].tanks, 0);
|
||||
assert.deepEqual(done.players[0].forces, { 'arrakeen:10': 2 });
|
||||
assert.equal(
|
||||
done.discard.filter((c) => c.id === 'ecaz-harass-withdraw').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
done.players[0].spice,
|
||||
30 + g.players[1].leaders.find((l) => l.id === otherLeader(g))!.strength,
|
||||
);
|
||||
});
|
||||
|
||||
void test('opposing and mutual traitor calls cancel withdrawal before forces return', () => {
|
||||
for (const mutual of [false, true]) {
|
||||
let g = harassWithdrawGame();
|
||||
heldTraitor(g, 1, ownLeader(g));
|
||||
if (mutual) heldTraitor(g, 0, otherLeader(g));
|
||||
g = seal(g);
|
||||
const done = resolve(g, mutual, true);
|
||||
assert.equal(done.players[0].reserves, 15);
|
||||
assert.equal(done.players[0].tanks, 5);
|
||||
assert.equal(
|
||||
done.log.filter((entry) =>
|
||||
entry.text.includes('Harass & Withdraw was canceled'),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
done.log.filter((entry) => entry.text.includes('used Harass & Withdraw'))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
done.players[0].leaders.find((l) => l.id === ownLeader(g))!.dead,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
done.discard.filter((c) => c.id === 'ecaz-harass-withdraw').length,
|
||||
1,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('weapon deaths and Lasgun/Shield explosion occur after undialed returns', () => {
|
||||
let g = harassWithdrawGame();
|
||||
const weapon = g.deck.find((c) => c.kind === 'projectile')!;
|
||||
takeHarassCard(g, 'd', weapon.id);
|
||||
let done = resolve(seal(g, {}, { weapon: weapon.id }));
|
||||
assert.equal(done.players[0].reserves, 18);
|
||||
assert.equal(done.players[0].tanks, 2);
|
||||
assert.equal(
|
||||
done.players[0].leaders.find((l) => l.id === ownLeader(g))!.dead,
|
||||
true,
|
||||
);
|
||||
g = harassWithdrawGame({ advanced: true });
|
||||
const shield = g.deck.find((c) => c.kind === 'shield')!,
|
||||
lasgun = g.deck.find((c) => c.kind === 'lasgun')!;
|
||||
takeHarassCard(g, 'a', shield.id);
|
||||
takeHarassCard(g, 'd', lasgun.id);
|
||||
g.players[2].forces = { 'arrakeen:10': 3 };
|
||||
g.players[2].reserves -= 3;
|
||||
done = resolve(
|
||||
seal(
|
||||
g,
|
||||
{ weapon: 'ecaz-harass-withdraw', defense: shield.id },
|
||||
{ weapon: lasgun.id },
|
||||
),
|
||||
);
|
||||
assert.equal(done.lastBattleContext?.result, 'explosion');
|
||||
assert.equal(done.players[0].reserves, 18);
|
||||
assert.equal(done.players[0].tanks, 2);
|
||||
assert.equal(done.players[1].tanks, 5);
|
||||
assert.equal(done.players[2].tanks, 3);
|
||||
assert.equal(done.players[2].reserves, 17);
|
||||
assert.deepEqual(done.players[2].forces, {});
|
||||
assert.equal(
|
||||
done.players[0].leaders.find((l) => l.id === ownLeader(g))!.dead,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
done.discard.filter((c) => c.id === 'ecaz-harass-withdraw').length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
void test('a later Face Dancer can replace only the dialed survivors, never returned reserves', () => {
|
||||
let g = harassWithdrawGame();
|
||||
heldTraitor(g, 0, otherLeader(g));
|
||||
const t = g.players[2];
|
||||
setIdentity(
|
||||
g,
|
||||
{
|
||||
get: () => t.faceDancers![0].leader,
|
||||
set: (value) => {
|
||||
t.faceDancers![0].leader = value;
|
||||
},
|
||||
},
|
||||
ownLeader(g),
|
||||
);
|
||||
g = resolve(seal(g), true, false);
|
||||
assert.equal(g.decision?.kind, 'faceDance');
|
||||
reject(
|
||||
g,
|
||||
't',
|
||||
{ type: 'decision', reveal: true, sources: { reserves: 3 }, sector: 10 },
|
||||
/more forces than the winner/,
|
||||
);
|
||||
g = applyAction(JSON.parse(JSON.stringify(g)), 't', {
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
sources: { reserves: 2 },
|
||||
sector: 10,
|
||||
});
|
||||
assert.equal(g.players[0].reserves, 20);
|
||||
assert.equal(g.players[0].tanks, 0);
|
||||
assert.equal(g.players[2].forces['arrakeen:10'], 2);
|
||||
assert.equal(g.players[2].reserves, 18);
|
||||
harassCustody(g);
|
||||
});
|
||||
|
||||
void test('Harkonnen capture follows withdrawal and mandatory discard without recapturing returned counters', () => {
|
||||
let g = harassWithdrawGame({
|
||||
advanced: true,
|
||||
factions: ['emperor', 'harkonnen', 'guild'],
|
||||
});
|
||||
const leader = [...g.players[0].leaders].sort(
|
||||
(a, b) => a.strength - b.strength,
|
||||
)[0].id;
|
||||
const winnerLeader = [...g.players[1].leaders].sort(
|
||||
(a, b) => b.strength - a.strength,
|
||||
)[0].id;
|
||||
g = resolve(
|
||||
seal(g, { leader }, { dial: 5, support: 5, leader: winnerLeader }),
|
||||
);
|
||||
assert.equal(g.lastBattleContext?.winner, 'd');
|
||||
assert.equal(g.players[0].leaders.find((l) => l.id === leader)!.dead, false);
|
||||
assert.equal(g.players[0].reserves, 18);
|
||||
assert.equal(g.players[0].tanks, 2);
|
||||
assert.equal(
|
||||
g.discard.filter((c) => c.id === 'ecaz-harass-withdraw').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(g.decision?.kind, 'captureOffer');
|
||||
g = applyAction(JSON.parse(JSON.stringify(g)), 'd', {
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
});
|
||||
while (g.response) {
|
||||
const p = g.players.find((p) => !g.response!.passed.includes(p.id))!;
|
||||
g = applyAction(g, p.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(g.decision?.kind, 'capturedLeader');
|
||||
assert.equal(
|
||||
g.players[0].leaders.filter((l) => l.capturedBy === 'd').length,
|
||||
1,
|
||||
);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(
|
||||
viewGame(JSON.parse(JSON.stringify(g)), p.id),
|
||||
viewGame(g, p.id),
|
||||
);
|
||||
g = applyAction(g, 'd', { type: 'decision', mode: 'keep' });
|
||||
assert.equal(g.players[0].reserves, 18);
|
||||
assert.equal(g.players[0].tanks, 2);
|
||||
assert.equal(
|
||||
g.discard.filter((c) => c.id === 'ecaz-harass-withdraw').length,
|
||||
1,
|
||||
);
|
||||
harassCustody(g);
|
||||
});
|
||||
|
||||
void test('Moritani ally retention excludes the used Harass card while preserving an ordinary played card', () => {
|
||||
let g = harassWithdrawGame({ factions: ['emperor', 'atreides', 'moritani'] });
|
||||
g.players[0].ally = 't';
|
||||
g.players[2].ally = 'a';
|
||||
const weapon = takeHarassCard(
|
||||
g,
|
||||
'a',
|
||||
g.deck.find((c) => c.kind === 'projectile')!.id,
|
||||
);
|
||||
const shield = takeHarassCard(
|
||||
g,
|
||||
'd',
|
||||
g.deck.find((c) => c.kind === 'shield')!.id,
|
||||
);
|
||||
g = resolve(
|
||||
seal(
|
||||
g,
|
||||
{ weapon: weapon.id },
|
||||
{
|
||||
dial: 5,
|
||||
leader: [...g.players[1].leaders].sort(
|
||||
(a, b) => b.strength - a.strength,
|
||||
)[0].id,
|
||||
defense: shield.id,
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.equal(g.lastBattleContext?.winner, 'd');
|
||||
assert.equal(g.players[0].reserves, 18);
|
||||
assert.equal(g.players[0].tanks, 2);
|
||||
// The winner may still choose its ordinary discard before the ally benefit.
|
||||
if (g.decision?.kind === 'battleCards')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', discard: [] });
|
||||
assert.equal(g.decision?.kind, 'moritaniRetention');
|
||||
assert.deepEqual(g.moritaniRetention!.eligible, [weapon.id]);
|
||||
reject(
|
||||
g,
|
||||
'a',
|
||||
{ type: 'decision', keep: 'ecaz-harass-withdraw' },
|
||||
/eligible|retain|Choose/i,
|
||||
);
|
||||
g = applyAction(JSON.parse(JSON.stringify(g)), 'a', {
|
||||
type: 'decision',
|
||||
keep: weapon.id,
|
||||
});
|
||||
while (g.response) {
|
||||
const p = g.players.find((p) => !g.response!.passed.includes(p.id))!;
|
||||
g = applyAction(g, p.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.ok(g.players[0].hand.some((c) => c.id === weapon.id));
|
||||
assert.equal(
|
||||
g.discard.filter((c) => c.id === 'ecaz-harass-withdraw').length,
|
||||
1,
|
||||
);
|
||||
harassCustody(g);
|
||||
});
|
||||
|
||||
void test('co-present reciprocal Ecaz allies are publicly guarded before sealing either combat side', () => {
|
||||
for (const side of [0, 1]) {
|
||||
const g = harassWithdrawGame({ factions: ['emperor', 'atreides', 'ecaz'] });
|
||||
g.players[side].ally = 't';
|
||||
g.players[2].ally = g.players[side].id;
|
||||
g.players[2].forces = { 'arrakeen:10': 3 };
|
||||
g.players[2].reserves = 17;
|
||||
assert.match(
|
||||
viewGame(g, 'a').battle!.harassWithdraw!.blocked!,
|
||||
/combined-army/,
|
||||
);
|
||||
reject(g, 'a', ownPlan(g), /combined-army/);
|
||||
// The guard depends on the visible coalition, not its cards or leader choice.
|
||||
const hidden = structuredClone(g);
|
||||
takeHarassCard(
|
||||
hidden,
|
||||
'd',
|
||||
hidden.deck.find((c) => c.kind === 'poison')!.id,
|
||||
);
|
||||
assert.deepEqual(
|
||||
viewGame(hidden, 'a').battle!.harassWithdraw,
|
||||
viewGame(g, 'a').battle!.harassWithdraw,
|
||||
);
|
||||
g.players[2].forces = {};
|
||||
g.players[2].reserves = 20;
|
||||
assert.equal(viewGame(g, 'a').battle!.harassWithdraw!.blocked, null);
|
||||
assert.doesNotThrow(() => applyAction(g, 'a', ownPlan(g)));
|
||||
harassCustody(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ambiguous unit mixes and sector allocations reject before sealing without hidden opponent dependence', () => {
|
||||
let g = harassWithdrawGame({ advanced: true, normal: 3, elite: 1 });
|
||||
reject(
|
||||
g,
|
||||
'a',
|
||||
ownPlan(g, { dial: 2, support: 1 }),
|
||||
/ambiguous regular\/elite/,
|
||||
);
|
||||
g = harassWithdrawGame({ territory: 'imperial_basin', sector: 9 });
|
||||
g.players[0].forces = { 'imperial_basin:9': 2, 'imperial_basin:10': 3 };
|
||||
const preview = viewGame(g, 'a').battle!.harassWithdraw!;
|
||||
assert.throws(
|
||||
() => quoteHarassWithdraw(preview, 2, 0),
|
||||
/ambiguous allocation among sectors/,
|
||||
);
|
||||
reject(g, 'a', ownPlan(g), /ambiguous allocation among sectors/);
|
||||
const hidden = structuredClone(g);
|
||||
takeHarassCard(
|
||||
hidden,
|
||||
'd',
|
||||
hidden.deck.find((c) => c.kind === 'projectile')!.id,
|
||||
);
|
||||
assert.deepEqual(viewGame(hidden, 'a').battle!.harassWithdraw, preview);
|
||||
assert.doesNotThrow(() => applyAction(g, 'a', ownPlan(g, { dial: 0 })));
|
||||
assert.doesNotThrow(() => applyAction(g, 'a', ownPlan(g, { dial: 5 })));
|
||||
});
|
||||
|
||||
void test('Prescience binds no category while retaining the exact private Harass slot and ordinary reveal', () => {
|
||||
let g = harassWithdrawGame({ prepare: false });
|
||||
const named = takeHarassCard(
|
||||
g,
|
||||
'a',
|
||||
g.deck.find((c) => c.kind === 'projectile')!.id,
|
||||
);
|
||||
assert.equal(g.battle?.preparation?.kind, 'prescience');
|
||||
g = applyAction(g, 'd', { type: 'prescience', field: 'weapon' });
|
||||
while (g.response) {
|
||||
const next = g.players.find((p) => !g.response!.passed.includes(p.id))!;
|
||||
g = applyAction(g, next.id, { type: 'passResponse' });
|
||||
}
|
||||
reject(
|
||||
g,
|
||||
'a',
|
||||
{ type: 'prescienceAnswer', value: 'ecaz-harass-withdraw' },
|
||||
/legal battle plan/,
|
||||
);
|
||||
g = applyAction(g, 'a', { type: 'prescienceAnswer', value: null });
|
||||
g = finishHarassPreparation(g);
|
||||
g = applyAction(
|
||||
g,
|
||||
'a',
|
||||
ownPlan(g, { weapon: 'ecaz-harass-withdraw', defense: null }),
|
||||
);
|
||||
for (const player of ['a', 'd'])
|
||||
assert.equal(viewGame(g, player).battle!.insight!.value, null);
|
||||
const observer = viewGame(g, 't');
|
||||
assert.equal(observer.battle!.insight, null);
|
||||
assert.equal(observer.battle!.harassWithdraw, null);
|
||||
assert.deepEqual(observer.battle!.plans, {});
|
||||
assert.deepEqual(observer.battle!.cards, []);
|
||||
const roundtrip = JSON.parse(JSON.stringify(g));
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(roundtrip, p.id), viewGame(g, p.id));
|
||||
assert.equal(g.battle!.nexusInspection, undefined);
|
||||
for (const change of ['replace', 'move', 'remove-field']) {
|
||||
const corrupt: Game = JSON.parse(JSON.stringify(g));
|
||||
corrupt.battle!.plans.a.weapon = named.id;
|
||||
corrupt.battle!.plans.a.defense =
|
||||
change === 'move' ? 'ecaz-harass-withdraw' : null;
|
||||
if (change === 'remove-field')
|
||||
Reflect.deleteProperty(corrupt.battle!.plans.a, 'weapon');
|
||||
const before = JSON.stringify(corrupt);
|
||||
for (const p of corrupt.players)
|
||||
assert.throws(() => viewGame(corrupt, p.id), /inspected commitment/);
|
||||
assert.equal(JSON.stringify(corrupt), before);
|
||||
reject(
|
||||
corrupt,
|
||||
'a',
|
||||
{ type: 'setAutopilot', difficulty: 'Easy' },
|
||||
/inspected commitment/,
|
||||
);
|
||||
reject(
|
||||
corrupt,
|
||||
'd',
|
||||
{ type: 'battlePlan', dial: 0, leader: otherLeader(g) },
|
||||
/inspected commitment/,
|
||||
);
|
||||
}
|
||||
g = applyAction(roundtrip, 'd', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: otherLeader(g),
|
||||
});
|
||||
assert.equal(viewGame(g, 't').battle!.plans.a.weapon, 'ecaz-harass-withdraw');
|
||||
assert.ok(
|
||||
viewGame(g, 't').battle!.cards.some((c) => c.id === 'ecaz-harass-withdraw'),
|
||||
);
|
||||
resolve(g);
|
||||
});
|
||||
|
||||
void test('saved Harass pairs cannot supply an actual defense for Chemistry or weapon for Weirding Way', () => {
|
||||
for (const invalid of ['ix-chemistry', 'ix-weirding-way']) {
|
||||
let g = harassWithdrawGame();
|
||||
takeHarassCard(g, 'a', invalid);
|
||||
const context = viewGame(g, 'a').battle!.harassWithdraw!;
|
||||
g = seal(
|
||||
g,
|
||||
invalid === 'ix-chemistry'
|
||||
? {}
|
||||
: { weapon: 'ecaz-harass-withdraw', defense: null },
|
||||
);
|
||||
const side = (index: number) => {
|
||||
const p = g.players[index],
|
||||
plan = g.battle!.plans[p.id];
|
||||
return {
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
spice: p.spice,
|
||||
hand: p.hand,
|
||||
plan,
|
||||
leader: p.leaders.find((l) => l.id === plan.leader),
|
||||
forces:
|
||||
index === 0
|
||||
? context.forces
|
||||
: {
|
||||
normal: 5,
|
||||
elite: 0,
|
||||
eliteStrength: 1 as const,
|
||||
freeSupport: true,
|
||||
},
|
||||
...(index === 0 ? { harassWithdraw: context } : {}),
|
||||
};
|
||||
};
|
||||
const input: BattleResolutionInput = {
|
||||
advanced: false,
|
||||
turn: g.turn,
|
||||
territory: g.battle!.territory,
|
||||
attacker: side(0),
|
||||
defender: side(1),
|
||||
voters: g.players
|
||||
.slice(0, 2)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
beneficiary: p.id,
|
||||
called: false,
|
||||
traitors: p.traitors,
|
||||
})),
|
||||
participants: g.players.map((p) => ({ id: p.id, faction: p.faction })),
|
||||
physicalCards: [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
],
|
||||
pendingAuditorPresent: false,
|
||||
pendingRetentionPresent: false,
|
||||
};
|
||||
assert.doesNotThrow(() => quoteBattleResolution(input));
|
||||
g.battle!.plans.a[invalid === 'ix-chemistry' ? 'weapon' : 'defense'] =
|
||||
invalid;
|
||||
const before = JSON.stringify(g),
|
||||
quoteBefore = JSON.stringify(input);
|
||||
assert.throws(
|
||||
() => quoteBattleResolution(input),
|
||||
/invalid weapon and defense pair/,
|
||||
);
|
||||
assert.equal(JSON.stringify(input), quoteBefore);
|
||||
for (const p of g.players)
|
||||
assert.throws(() => viewGame(g, p.id), /invalid weapon and defense pair/);
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
reject(
|
||||
g,
|
||||
'a',
|
||||
{ type: 'setAutopilot', difficulty: 'Easy' },
|
||||
/invalid weapon and defense pair/,
|
||||
);
|
||||
reject(
|
||||
g,
|
||||
'd',
|
||||
{ type: 'traitorCall', call: false },
|
||||
/invalid weapon and defense pair/,
|
||||
);
|
||||
harassCustody(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic and Advanced Ixian half-dial withdrawal preserves all seven physical cyborgs', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
let g = harassWithdrawGame({
|
||||
advanced,
|
||||
factions: ['ixians', 'atreides', 'tleilaxu'],
|
||||
normal: 2,
|
||||
elite: 2,
|
||||
});
|
||||
harassCustody(g);
|
||||
const quoted = quoteHarassWithdraw(
|
||||
viewGame(g, 'a').battle!.harassWithdraw!,
|
||||
0.5,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(quoted.returned, { normal: 1, elite: 2 });
|
||||
g = resolve(seal(g, { dial: 0.5, support: 0 }));
|
||||
assert.equal(g.lastBattleContext?.winner, 'a');
|
||||
assert.equal(g.players[0].reserves, 19);
|
||||
assert.equal(g.players[0].tanks, 1);
|
||||
assert.equal(g.players[0].elites!.reserves, 7);
|
||||
assert.equal(g.players[0].elites!.tanks, 0);
|
||||
assert.deepEqual(g.players[0].forces, {});
|
||||
assert.equal(
|
||||
g.discard.filter((c) => c.id === 'ecaz-harass-withdraw').length,
|
||||
1,
|
||||
);
|
||||
harassCustody(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Reinforcements and public Richese/optional-module contexts remain guarded', () => {
|
||||
let g = harassWithdrawGame();
|
||||
takeHarassCard(g, 'a', 'ecaz-reinforcements');
|
||||
reject(
|
||||
g,
|
||||
'a',
|
||||
ownPlan(g, { defense: 'ecaz-reinforcements' }),
|
||||
/Reinforcements.*still/,
|
||||
);
|
||||
const richese = harassWithdrawGame({
|
||||
factions: ['emperor', 'atreides', 'richese'],
|
||||
});
|
||||
assert.match(
|
||||
viewGame(richese, 'a').battle!.harassWithdraw!.blocked!,
|
||||
/Richese card family/,
|
||||
);
|
||||
reject(richese, 'a', ownPlan(richese), /Richese card family/);
|
||||
g = harassWithdrawGame({ factions: ['emperor', 'atreides', 'choam'] });
|
||||
assert.equal(viewGame(g, 'a').battle!.harassWithdraw!.blocked, null);
|
||||
const forged = structuredClone(g);
|
||||
forged.nexusCards = { cards: null, phase: null };
|
||||
reject(forged, 'a', ownPlan(forged), /optional modules|Nexus/i);
|
||||
});
|
||||
|
||||
void test('malformed sealed withdrawal allocations reject private reads and both ordinary/autopilot actions', () => {
|
||||
let g = harassWithdrawGame({ advanced: true, normal: 3, elite: 1 });
|
||||
g = applyAction(g, 'a', ownPlan(g, { dial: 0, support: 0 }));
|
||||
g.battle!.plans.a.dial = 2;
|
||||
g.battle!.plans.a.support = 1;
|
||||
for (const p of g.players)
|
||||
assert.throws(() => viewGame(g, p.id), /ambiguous regular\/elite/);
|
||||
reject(
|
||||
g,
|
||||
'a',
|
||||
{ type: 'setAutopilot', difficulty: 'Easy' },
|
||||
/ambiguous regular\/elite/,
|
||||
);
|
||||
reject(
|
||||
g,
|
||||
'd',
|
||||
{ type: 'battlePlan', dial: 0, leader: otherLeader(g) },
|
||||
/ambiguous regular\/elite/,
|
||||
);
|
||||
g = harassWithdrawGame();
|
||||
g.players[0].reserves = Number.MAX_SAFE_INTEGER;
|
||||
reject(g, 'a', ownPlan(g), /reserve and elite counters/);
|
||||
g = harassWithdrawGame();
|
||||
g = applyAction(g, 'a', ownPlan(g));
|
||||
g.battle!.plans.a.leader = 'ecaz-harass-withdraw';
|
||||
g.battle!.plans.a.defense = null;
|
||||
for (const p of g.players)
|
||||
assert.throws(
|
||||
() => viewGame(g, p.id),
|
||||
/exactly one ordinary Battle Plan card slot/,
|
||||
);
|
||||
});
|
||||
@@ -155,11 +155,16 @@ void test('every simultaneous own commitment overrides plan submission, includin
|
||||
assert.deepEqual(battlePlanCommitments(view), {});
|
||||
});
|
||||
|
||||
void test('the actual battle table locks each native-plus-Nexus field pair to the same values used by submission', () => {
|
||||
void test('the actual table binds native-plus-Nexus values while category-None selectors exclude ordinary cards', () => {
|
||||
for (const fields of [['dial', 'defense'], ['leader', 'weapon']] as const) {
|
||||
const view = fixture('h');
|
||||
view.nexusAtreides = null;
|
||||
view.nexusCards = null;
|
||||
// An empty selector must follow the inspected category, not merely an empty hand.
|
||||
view.players.find((p) => p.id === 'h')!.hand = [
|
||||
baseDeck().find((card) => card.kind === 'projectile')!,
|
||||
baseDeck().find((card) => card.kind === 'shield')!,
|
||||
];
|
||||
const values = { dial: 0, defense: null, leader: view.players.find((p) => p.id === 'h')!.leaders[0].id, weapon: null };
|
||||
view.battle!.ownCommitments = fields.map((field, index) => ({ source: index ? 'nexus' : 'native', beneficiary: 'a', target: 'h', field, value: values[field] }));
|
||||
const html = tableHtml(view);
|
||||
@@ -167,12 +172,15 @@ void test('the actual battle table locks each native-plus-Nexus field pair to th
|
||||
const id = field === 'dial' ? 'forces-dialed' : `battle-${field}`;
|
||||
const control = html.match(new RegExp(`<[^>]+id="${id}"[^>]*>`))?.[0];
|
||||
assert.ok(control, `${field} control is visible`);
|
||||
assert.match(control, /disabled=""|aria-disabled="true"/);
|
||||
const categoryNone = (field === 'weapon' || field === 'defense') && values[field] === null;
|
||||
if (categoryNone) assert.doesNotMatch(control, /disabled=""|aria-disabled="true"/);
|
||||
else assert.match(control, /disabled=""|aria-disabled="true"/);
|
||||
if (field === 'dial') assert.match(control, /value="0"/);
|
||||
else {
|
||||
const select = html.match(new RegExp(`<select[^>]+id="${id}"[^>]*>[\\s\\S]*?</select>`))?.[0];
|
||||
assert.ok(select);
|
||||
assert.match(select, new RegExp(`<option[^>]+value="${values[field] ?? ''}"[^>]+selected=""`));
|
||||
if (categoryNone) assert.equal((select.match(/<option\b/g) ?? []).length, 1, 'only None is legal without a supported slot-only special');
|
||||
}
|
||||
}
|
||||
for (const field of ['leader', 'weapon', 'defense'] as const) if (!(fields as readonly string[]).includes(field)) {
|
||||
|
||||
@@ -169,8 +169,9 @@ void test('base, Ix and Ecaz guides remain separate from Richese replacement-car
|
||||
assert.equal(presentation.category, 'Special treachery');
|
||||
assert.ok(presentation.gameplay!.length >= 4);
|
||||
assert.match(presentation.availability ?? '', card.effect === 'recruits'
|
||||
? /development controls during Revival/ : /battle effect is still being implemented/);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), ['card-recruits']);
|
||||
? /development controls during Revival/ : card.effect === 'harassWithdraw'
|
||||
? /development controls in either battle-card slot/ : /battle effect is still being implemented/);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits']);
|
||||
}
|
||||
assert.equal(treacheryDeck().length, 33);
|
||||
assert.equal(treacheryDeck(['ix']).length, 47);
|
||||
|
||||
Reference in new issue
Block a user