Support Ecaz renewal of temporary Duke tenure
This commit is contained in:
1 parent
4e55ab9e86
commit
69bbb09dcf
12 files changed
+314
-21
No files matched your search
@@ -41,8 +41,9 @@ export function EcazDukeAcquisitionChoices({
|
||||
<p className="m-0 text-sm leading-6" aria-label="Provisional shared Duke tenure">
|
||||
Provisional ordinary acquisition: the chosen recipient keeps Duke Vidal
|
||||
until battle use, including when unused beyond this turn, unless another
|
||||
rule takes him away. The separate Duke loan after a new alliance lasts
|
||||
for the current turn. Only Ecaz may revive him.
|
||||
rule takes him away. A recipient holding him under a different temporary
|
||||
tenure can renew that control with the Ambassador. The separate Duke
|
||||
loan after a new alliance lasts for the current turn. Only Ecaz may revive him.
|
||||
</p>
|
||||
{recipients.map((recipient) => {
|
||||
const blocked = recipient.blocked;
|
||||
|
||||
@@ -20,6 +20,14 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Ecaz same-recipient Duke tenure — 27 October. Prototyped / Partial.** A fresh
|
||||
eligible Ambassador can provisionally convert its current recipient's other
|
||||
temporary Duke tenure to the ordinary until-battle tenure, preserving the
|
||||
one physical disc and prior usage. An identical ordinary tenure stays
|
||||
redundant; Advanced Harkonnen same-holder cases remain uniformly guarded.
|
||||
Full combinations and deployed acceptance remain open.
|
||||
[Scope](ECAZ_DUKE_ACQUISITION.md#same-recipient-temporary-tenure--27-october-2026).
|
||||
|
||||
**Ixian stronghold route collection choices — 27 October. Prototyped / Partial.**
|
||||
Players can provisionally choose the amount collected from each visited
|
||||
sector, within the current physical-pile and Ixian-force cap. The original
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Ecaz Ambassador: direct Duke acquisition
|
||||
|
||||
## Same-recipient temporary tenure — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** At an actual eligible Ecaz Ambassador entry, Ecaz may
|
||||
provisionally select the current recipient of a living Duke under another
|
||||
temporary tenure, changing that same disc to the Ambassador's ordinary
|
||||
until-battle tenure. Existing battle use and death history remain; no leader,
|
||||
resource or extra use is created. A recipient already holding the ordinary
|
||||
Ambassador tenure cannot trigger a redundant renewal. Same-recipient choices
|
||||
in Advanced Harkonnen tables remain uniformly unavailable so their offer does
|
||||
not disclose an otherwise concealed capture. Other exceptional custody and
|
||||
occupied-Ecaz priority retain their existing restrictions.
|
||||
|
||||
Publisher E3 printed p. 7 describes the Ambassador grant until battle; printed
|
||||
p. 8 permits sharing an Ambassador benefit with an ally. Applying that grant
|
||||
to the same holder's different temporary tenure is a provisional composition,
|
||||
not a worked publisher example. The [focused runtime test](../tests/ecaz-duke-reacquisition.test.ts)
|
||||
uses an actual placed token, alliance and paid entry, with explicitly staged
|
||||
existing-loan custody. It follows the original arrival and a later turn; wider
|
||||
combinations and final/deployed acceptance remain open.
|
||||
|
||||
Integrated 7 September 2026; existing-ally extension 26 October 2026. Ecaz can choose **Acquire Duke Vidal** for itself or its current reciprocal ally when an eligible entrant reaches its reusable Ambassador in supported development games. The server transfers the existing shared disc, returns the token to supply and resumes the entrant’s remaining actions. There is no additional confirmation or new generic Ambassador cancellation response.
|
||||
|
||||
The direct acquisition and tenure follow the publisher’s [Ecaz & Moritani rules, pp.7–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf). Fresh indexed publisher retrieval rechecked eligibility outside Tanks, captured or ghola custody. The prior [Duke source audit](DUKE_VIDAL_RULES.md) and [remaining effects readiness review](ECAZ_REMAINING_EFFECTS_READINESS.md) record the separate unresolved combinations. The current direct choice supports self or the existing reciprocal ally as recorded below; this does not certify full Ecaz or a complete expansion game.
|
||||
@@ -17,7 +37,7 @@ the newly formed alliance's optional Basic/automatic Advanced turn-long loan.
|
||||
Reuse source `ecaz` for the same direct power and physical disc; changing the
|
||||
recipient needs no new saved source kind. Qualification is rechecked at this
|
||||
actual token entry. A living Ecaz-held or set-aside disc can go to the ally;
|
||||
choosing its existing controller still rejects a no-op. Death history and
|
||||
the later same-recipient follow-up above extends temporary tenure. Death history and
|
||||
`usedAt` remain, and no roster or Traitor gains a duplicate. Captive, concealed,
|
||||
Ghola, dead, duplicated-disc and existing table/profile exclusions remain.
|
||||
Occupied-Ecaz priority is checked before acquisition; only Ecaz may revive Duke.
|
||||
@@ -117,7 +137,7 @@ The reusable token does not belong to the five-token random cohort. Its trigger
|
||||
|
||||
The existing leader lifecycle supplies Duke to the chosen recipient’s battle-plan options and consumes temporary custody after battle use. Death retains the same disc in Tanks. Unused source-Ecaz control persists across turns, while a later qualifying Moritani acquisition still takes the same disc. Existing Advanced-Harkonnen and exceptional custody limits remain in force; this feature does not order the unresolved capture/return combination.
|
||||
|
||||
Same-recipient acquisition remains a rejected no-op before token consumption; the self case retains its historical development boundary rather than asserting a printed prohibition. Existing-ally acquisition is now connected as above, and Basic/Advanced new-alliance choices retain their distinct earlier prototypes. Unsupported or unavailable commands preserve the placed token; declining the opportunity also preserves Duke and resumes the entrant.
|
||||
The 27 October same-recipient follow-up above supersedes the earlier blanket no-op restriction. Existing-ally acquisition and Basic/Advanced new-alliance choices retain their distinct earlier prototypes. Unsupported or unavailable commands preserve the placed token; declining the opportunity also preserves Duke and resumes the entrant.
|
||||
|
||||
## Controls, privacy and AI
|
||||
|
||||
@@ -131,7 +151,7 @@ The complete Ambassador gameplay explanation remains readable in the token inspe
|
||||
|
||||
| Facet | Evidence | Remaining |
|
||||
| --- | --- | --- |
|
||||
| Implementation | Shared self/ally quote, atomic real entry, token return, original movement/worm suffix, same-disc battle/turn lifecycle | Same-recipient no-op policy and wider revival/capture/occupation combinations |
|
||||
| Implementation | Shared self/ally quote, atomic real entry, token return, original movement/worm suffix, same-disc battle/turn lifecycle | Advanced Harkonnen same-holder and wider revival/capture/occupation combinations |
|
||||
| Player controls | Per-recipient actions/reasons, persistent tenure guidance, complete token guide, Duke portrait, private-owner descriptor, compact inventory | Browser/mobile acceptance of the ally choices and broader faction journeys |
|
||||
| AI | All four minimal policies use projected legal recipients in Basic/Advanced; related bot regressions pass | Strength calibration and full expansion games |
|
||||
| Documentation | Internal Ecaz/Duke topics, five-facet checklist, Ambassador guide and shared-disc support metadata updated | Complete expansion reference and component inventory |
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Ecaz same-recipient Duke tenure
|
||||
|
||||
A fresh eligible Ecaz Ambassador grant can provisionally convert a current
|
||||
recipient's other temporary Duke tenure to the ordinary until-battle tenure,
|
||||
without creating a disc or resetting its battle history. Already-ordinary
|
||||
tenure remains redundant; Advanced Harkonnen same-holder cases are guarded.
|
||||
Full combinations and deployed acceptance remain open.
|
||||
[Scope](ECAZ_DUKE_ACQUISITION.md#same-recipient-temporary-tenure--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Ixian stronghold route collection choices
|
||||
|
||||
The Hidden Mobile Stronghold's route editor supports provisionally selected
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Ecaz Ambassador same-recipient Duke tenure — 27 October 2026
|
||||
|
||||
Provisionally let a fresh eligible Ambassador grant change the current
|
||||
recipient's other temporary tenure to ordinary until-battle Ecaz tenure,
|
||||
preserving the one physical disc and its prior use. An existing identical
|
||||
ordinary tenure remains redundant. Advanced Harkonnen same-holder cases stay
|
||||
uniformly blocked to avoid exposing concealed capture. The printed grant and
|
||||
ally-sharing rules do not give a worked example of this composition.
|
||||
[Scope](ECAZ_DUKE_ACQUISITION.md#same-recipient-temporary-tenure--27-october-2026).
|
||||
|
||||
## Ixian stronghold route spice choices — 27 October 2026
|
||||
|
||||
Provisionally let Ixians choose a whole amount from zero through the existing
|
||||
|
||||
@@ -11,7 +11,7 @@ export type EcazDukeContext = Omit<Partial<FactionModuleProfile>, 'players'> & {
|
||||
players: readonly { id: string; faction: FactionId; ally?: string | null }[];
|
||||
dukeVidal?: DukeState;
|
||||
};
|
||||
type Failure = 'context' | 'custody' | 'table' | 'alreadyControlled';
|
||||
type Failure = 'context' | 'custody' | 'table' | 'alreadyControlled' | 'sameHolderTable';
|
||||
export class EcazDukeAcquisitionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -32,9 +32,11 @@ const whole = (n: unknown): n is number =>
|
||||
Number.isSafeInteger(n) && (n as number) >= 0;
|
||||
const tableBlock =
|
||||
'Duke Vidal acquisition with Advanced Harkonnen is still being implemented.';
|
||||
const sameHolderTableBlock =
|
||||
'Same-holder Duke acquisition with Advanced Harkonnen is not available in this preview.';
|
||||
const alreadyBlock =
|
||||
'Acquiring Duke Vidal when Ecaz already controls him is still being implemented.';
|
||||
const allyAlreadyBlock = 'The chosen beneficiary already controls Duke Vidal.';
|
||||
'Ecaz already controls Duke Vidal through its Ambassador.';
|
||||
const allyAlreadyBlock = 'The chosen beneficiary already controls Duke Vidal through the Ambassador.';
|
||||
const unavailable = 'Duke Vidal is unavailable for this acquisition.';
|
||||
|
||||
/** Ordinary Ambassador acquisition for Ecaz or its current reciprocal ally.
|
||||
@@ -91,15 +93,21 @@ export function quoteEcazDukeAcquisition(
|
||||
whole(duke.leader.deaths),
|
||||
'The shared Duke Vidal disc has invalid physical identity or history.',
|
||||
);
|
||||
// Harkonnen may privately capture Duke without changing his public prior
|
||||
// controller. Keep same-holder availability independent of that hidden
|
||||
// custody until a public-safe capture continuation is implemented.
|
||||
requireDuke(duke.controller !== recipientId || !g.advanced ||
|
||||
!g.players.some(player => player.faction === 'harkonnen'),
|
||||
sameHolderTableBlock, 'sameHolderTable');
|
||||
// A direct Ambassador grant is already in force for this recipient. Other
|
||||
// sources may be converted to this until-battle tenure without moving or
|
||||
// recreating the disc. Check public tenure before private custody details.
|
||||
requireDuke(duke.controller !== recipientId || duke.source !== 'ecaz',
|
||||
recipientId === ownerId ? alreadyBlock : allyAlreadyBlock, 'alreadyControlled');
|
||||
requireDuke(
|
||||
!duke.leader.concealed,
|
||||
'Duke Vidal cannot be acquired from unresolved concealed custody.',
|
||||
);
|
||||
// This explicit unfinished case is public controller information. Check it
|
||||
// before private capture/ghola details so its projected reason cannot vary
|
||||
// with an undisclosed custody marker.
|
||||
requireDuke(duke.controller !== recipientId,
|
||||
recipientId === ownerId ? alreadyBlock : allyAlreadyBlock, 'alreadyControlled');
|
||||
requireDuke(
|
||||
duke.controller === null
|
||||
? duke.acquiredTurn === null && duke.source === null
|
||||
@@ -131,6 +139,7 @@ export function ecazDukeAcquisitionBlock(
|
||||
} catch (error) {
|
||||
if (error instanceof EcazDukeAcquisitionError) {
|
||||
if (error.reason === 'table') return tableBlock;
|
||||
if (error.reason === 'sameHolderTable') return sameHolderTableBlock;
|
||||
if (error.reason === 'alreadyControlled')
|
||||
return recipientId === ownerId ? alreadyBlock : allyAlreadyBlock;
|
||||
}
|
||||
|
||||
+1
-1
@@ -25122,7 +25122,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
g.dukeVidal = acquiredDuke;
|
||||
log(
|
||||
g,
|
||||
`${beneficiary.name} acquired Duke Prad Vidal${previous ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. ${beneficiary.name} keeps the shared Duke until battle use or another rule takes him away; the entrant’s remaining actions resume.`,
|
||||
`${beneficiary.name} ${previous === beneficiary.id ? 'renewed control of' : 'acquired'} Duke Prad Vidal${previous && previous !== beneficiary.id ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. ${beneficiary.name} keeps the shared Duke until battle use or another rule takes him away; the entrant’s remaining actions resume.`,
|
||||
{ faction: owner.faction, name: 'Ecaz Ambassador — Duke Vidal' },
|
||||
);
|
||||
finishAmbassador(g);
|
||||
|
||||
+1
-1
@@ -4054,7 +4054,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Duke Vidal is separate from both factions’ five ordinary leaders. His Ecaz identity does not mean Ecaz currently controls him. Inspect the shared disc to see its current controller, whether it is set aside, or whether it is in the Tanks.',
|
||||
'At the end of Shipping and Movement, Moritani gains Vidal if it faces battles in at least two strongholds, excluding battles involving Ecaz, and Vidal is not in the Tanks. The printed rule takes him from the current controller. Moritani has him for one battle; it may acquire him again on a later turn by meeting the condition again. At turn end, set him aside unless he is in the Tanks or captured.',
|
||||
'Karama can prevent Moritani from acquiring Vidal. That cancellation does not remove him once Moritani already controls him.',
|
||||
'Ecaz may acquire Vidal through a triggered Ambassador for itself or its current reciprocal ally outside Tanks/captured/ghola custody. Ordinary acquisition lasts until battle use, including unused control across turns; this shared tenure is provisional. The alternative forms a consensual alliance while both factions are unallied. Basic can then lend Duke optionally; the supplied Advanced physical p30 adds an available Duke to the new ally automatically and sets an unused disc aside at turn end.',
|
||||
'Ecaz may acquire Vidal through a triggered Ambassador for itself or its current reciprocal ally outside Tanks/captured/ghola custody. Ordinary acquisition lasts until battle use, including unused control across turns; this shared tenure is provisional. A current recipient under a different temporary tenure may provisionally renew control through the Ambassador; Advanced Harkonnen same-holder cases remain guarded. The alternative forms a consensual alliance while both factions are unallied. Basic can then lend Duke optionally; the supplied Advanced physical p30 adds an available Duke to the new ally automatically and sets an unused disc aside at turn end.',
|
||||
'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.',
|
||||
'Ordinary Ecaz returns now use an explicit provisional native cohort. Five actual Tanked discs including Duke, or the existing unavailable-native-roster exception, opens it; completed native IDs preserve the remainder and later openings. A late first death can join a later cohort. Independent Duke deaths do not set or hold its ordinary number, and a real Duke resurrection preserves an already satisfied opening. Original native prices and one ordinary leader slot remain.',
|
||||
'An unallied native Ecaz holder may spend its actual Ecaz Nexus at a quiet boundary in any phase to take Duke for the current turn, including original captured custody or the set-aside disc in Tanks. Finish committed interactions first. One battle or unused turn expiry sets him aside. Nexus liberation permanently clears captivity; it does not return him to a former captor. Actual Ecaz Ghola returns him alive and set aside before a separate Cunning play.',
|
||||
|
||||
@@ -192,11 +192,11 @@ void test('pure source/canonical identity/custody checks reject safely, with one
|
||||
EcazDukeAcquisitionError,
|
||||
);
|
||||
});
|
||||
void test('same-owner and Advanced Harkonnen cases remain explicitly unfinished without private reason variation', () => {
|
||||
void test('redundant same-owner and Advanced Harkonnen cases keep public reasons independent of private custody', () => {
|
||||
const own = fixture();
|
||||
own.dukeVidal = acquireDuke(own.dukeVidal!, 'ec', 1, 'ecaz');
|
||||
const reason = ecazDukeAcquisitionBlock(own, 'ec');
|
||||
assert.match(reason!, /still being implemented/);
|
||||
assert.match(reason!, /already controls Duke Vidal through its Ambassador/);
|
||||
own.dukeVidal.leader.capturedBy = 'in';
|
||||
assert.equal(ecazDukeAcquisitionBlock(own, 'ec'), reason);
|
||||
const hark = fixture('harkonnen', true);
|
||||
|
||||
@@ -63,20 +63,20 @@ for (const advanced of [false, true]) {
|
||||
void test(`${band} Ecaz-held Duke may transfer to its ally while same-recipient acquisition remains a no-op refusal`, () => {
|
||||
const g = position(advanced); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ec', 1, 'ecaz');
|
||||
const oldReason = block(g, 'ec');
|
||||
assert.equal(oldReason, 'Acquiring Duke Vidal when Ecaz already controls him is still being implemented.');
|
||||
assert.equal(oldReason, 'Ecaz already controls Duke Vidal through its Ambassador.');
|
||||
assert.throws(() => quote(g, 'ec'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'alreadyControlled');
|
||||
const shared = quote(g, 'ec', 'al'); assert.equal(shared.controller, 'al'); assert.equal(shared.source, 'ecaz');
|
||||
g.dukeVidal = shared; const before = structuredClone(g);
|
||||
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
|
||||
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal through the Ambassador.');
|
||||
assert.throws(() => quote(g, 'ec', 'al'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'alreadyControlled');
|
||||
assert.deepEqual(g, before);
|
||||
// Public same-controller descriptors do not vary with a private capture or
|
||||
// Ghola marker. They confer no permission to transfer that custody.
|
||||
g.dukeVidal.leader.capturedBy = 'g';
|
||||
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
|
||||
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal through the Ambassador.');
|
||||
rejected(g, 'ec', 'ec');
|
||||
delete g.dukeVidal.leader.capturedBy; g.dukeVidal.leader.gholaBy = 'g';
|
||||
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
|
||||
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal through the Ambassador.');
|
||||
rejected(g, 'ec', 'ec');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { acquireDuke, DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { EcazDukeAcquisitionChoices } from '../components/ecaz-duke-acquisition-choices';
|
||||
import { actEcazDukeAlly as act, createEcazDukeAllyFixture as fixture,
|
||||
ecazDukeAllyInventory as inventory, ecazDukeAllyPhysical as physical,
|
||||
finishEcazDukeAlly as finish } from './fixture-ecaz-duke-ally';
|
||||
|
||||
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 { EcazEntry } = await import('../components/ecaz-entry');
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
type ButtonProps={ 'data-testid'?:string; disabled?:boolean; onClick?:() => void; children?:ReactNode };
|
||||
function button(node: ReactNode, id:string): ReactElement<ButtonProps> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) { const found=button(child,id); if (found) return found; }
|
||||
return null;
|
||||
}
|
||||
if (!isValidElement<ButtonProps>(node)) return null;
|
||||
if (node.props['data-testid'] === id) return node;
|
||||
return button(node.props.children,id);
|
||||
}
|
||||
/** The fixture earns the alliance, Ecaz Ambassador placement, normal shared
|
||||
* Duke, paid entry and pending offer through real actions. This single,
|
||||
* explicitly controlled supplement changes the original shared disc's
|
||||
* existing control to the already-allied recipient under an ally-loan tenure.
|
||||
* It does not install a leader, card, force, payment or pending event. */
|
||||
function loan(advanced:boolean) {
|
||||
const f=fixture({advanced}), game=structuredClone(f.game);
|
||||
game.dukeVidal=acquireDuke(game.dukeVidal!,f.ally,game.turn,'ally');
|
||||
assert.equal(game.dukeVidal.controller,f.ally); assert.equal(game.dukeVidal.source,'ally');
|
||||
assert.deepEqual(inventory(game),f.originalInventory);
|
||||
return {f,game};
|
||||
}
|
||||
function checkCompleted(f: ReturnType<typeof fixture>, before:Game, game:Game, recipient=f.ally) {
|
||||
assert.deepEqual([game.dukeVidal!.controller,game.dukeVidal!.source,game.dukeVidal!.acquiredTurn],
|
||||
[recipient,'ecaz',before.turn]);
|
||||
assert.deepEqual(game.dukeVidal!.leader,before.dukeVidal!.leader);
|
||||
assert.deepEqual(physical(game),physical(before));
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone,'supply');
|
||||
assert.ok(game.players.every(seat => !seat.leaders.some(leader => leader.id === DUKE_VIDAL_ID)));
|
||||
assert.deepEqual(inventory(game),f.originalInventory);
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) {
|
||||
const mode=advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${mode} actual Ecaz table renders and submits same-recipient Duke renewal`, () => {
|
||||
const {f,game:staged}=loan(advanced), before=structuredClone(staged);
|
||||
const view=viewGame(staged,f.owner), entry=view.ambassadorEntry; assert.ok(entry);
|
||||
assert.equal(entry.effect,'ecaz'); assert.equal(entry.stage,'offer');
|
||||
assert.equal(entry.beneficiaries.find(row => row.player === f.ally)?.blocked,null);
|
||||
const entryHtml=renderToStaticMarkup(createElement(EcazEntry,{game:view,busy:false,act() {}}));
|
||||
const tableHtml=renderToStaticMarkup(createElement(GameTable,{game:view,busy:false,send:async () => {},onExit() {}}));
|
||||
for (const html of [entryHtml,tableHtml]) {
|
||||
assert.match(html,/Acquire Duke Vidal for/);
|
||||
assert.match(html,/renew that control/);
|
||||
assert.ok(html.includes(`data-testid="ecaz-duke-acquisition-${f.ally}"`));
|
||||
}
|
||||
const sent:Action[]=[];
|
||||
const control=EcazDukeAcquisitionChoices({game:view,busy:false,act(action) {sent.push(action);}});
|
||||
const choice=button(control,`ecaz-duke-acquisition-${f.ally}`); assert.ok(choice);
|
||||
assert.equal(choice.props.disabled,false); assert.ok(choice.props.onClick);
|
||||
choice.props.onClick();
|
||||
assert.deepEqual(sent,[{type:'decision',event:f.event,trigger:true,beneficiary:f.ally,choice:'duke'}]);
|
||||
let game=act(staged,f.owner,sent[0]); game=finish(f,game);
|
||||
assert.deepEqual(staged,before,'The actual callback does not mutate its input');
|
||||
checkCompleted(f,before,game);
|
||||
// Server-rendered controls and this leaf callback are exercised; a browser
|
||||
// hydration/navigation or external deployed session is not represented.
|
||||
});
|
||||
|
||||
void test(`${mode} busy renewal controls do not submit and all four bots choose a legal native acquisition`, () => {
|
||||
const {f,game:staged}=loan(advanced), original=structuredClone(staged);
|
||||
const view=viewGame(staged,f.owner), sent:Action[]=[];
|
||||
const control=EcazDukeAcquisitionChoices({game:view,busy:true,act(action) {sent.push(action);}});
|
||||
const choice=button(control,`ecaz-duke-acquisition-${f.ally}`); assert.ok(choice);
|
||||
assert.equal(choice.props.disabled,true); choice.props.onClick?.(); assert.deepEqual(sent,[]);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot=viewGame(staged,f.owner);
|
||||
bot.players.find(row => row.id === f.owner)!.bot=difficulty;
|
||||
const action=botActions(bot)[0]; assert.ok(action,difficulty);
|
||||
assert.equal(action.type,'decision'); assert.equal(action.event,f.event);
|
||||
assert.equal(action.trigger,true); assert.equal(action.choice,'duke');
|
||||
assert.equal(typeof action.beneficiary,'string');
|
||||
const recipient=String(action.beneficiary);
|
||||
assert.ok(bot.ambassadorEntry?.beneficiaries.some(row => row.player === recipient && !row.blocked),
|
||||
'The bot selects a server-projected legal recipient without imposing a strategic preference');
|
||||
let game=act(structuredClone(staged),f.owner,action); game=finish(f,game);
|
||||
checkCompleted(f,original,game,recipient);
|
||||
}
|
||||
assert.deepEqual(staged,original);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { acquireDuke, createDukeVidal, expireDuke, DUKE_VIDAL_ID, type DukeSource } from '../game/duke-vidal';
|
||||
import { ecazDukeAcquisitionBlock, quoteEcazDukeAcquisition } from '../game/ecaz-duke-acquisition';
|
||||
import { actEcazDukeAlly, advanceEcazDukeAlly, battleEcazDukeAlly, createEcazDukeAllyFixture,
|
||||
ecazDukeAllyClean, ecazDukeAllyInventory, ecazDukeAllyPhysical, finishEcazDukeAlly } from './fixture-ecaz-duke-ally';
|
||||
|
||||
/** A controlled public quote position, not a claimed natural acquisition. */
|
||||
function position(source: DukeSource, recipient: 'ec' | 'mor' = 'ec'): Game {
|
||||
const game = createGame('DUKEREACQUIRE', newPlayer('ec', 'Ecaz', 'ecaz'), false, ['ecaz']);
|
||||
game.players.push(newPlayer('mor', 'Moritani', 'moritani'), newPlayer('other', 'Emperor', 'emperor'));
|
||||
Object.assign(game, { status: 'playing', turn: 2, phase: 5, active: 'other' });
|
||||
game.players[0].ally = 'mor'; game.players[1].ally = 'ec';
|
||||
game.dukeVidal = acquireDuke(createDukeVidal(), recipient, 2, source);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const [source, recipient] of [
|
||||
['ally', 'mor'], ['moritani', 'mor'], ['ecazNexus', 'ec'],
|
||||
] as const) {
|
||||
void test(`same-recipient ${source} tenure can become ordinary Ecaz Ambassador tenure`, () => {
|
||||
const game = position(source, recipient);
|
||||
game.dukeVidal!.leader.deaths = 2;
|
||||
game.dukeVidal!.leader.usedAt = 'arrakeen';
|
||||
const before = structuredClone(game), original = structuredClone(game.dukeVidal!);
|
||||
assert.equal(ecazDukeAcquisitionBlock(game, 'ec', recipient), null);
|
||||
const quote = quoteEcazDukeAcquisition(game, 'ec', recipient);
|
||||
assert.deepEqual(game, before, 'A detached quotation never changes the live disc');
|
||||
assert.deepEqual(quote, { ...original, source: 'ecaz', acquiredTurn: game.turn });
|
||||
assert.deepEqual(quote.leader, original.leader, 'Physical identity, prior use and deaths stay unchanged');
|
||||
assert.deepEqual(expireDuke(quote, game.turn), quote, 'Unused ordinary acquisition is retained after the turn');
|
||||
assert.notEqual(expireDuke(original, game.turn).controller, recipient, 'The original temporary tenure would expire');
|
||||
quote.leader.deaths = 99;
|
||||
assert.deepEqual(game, before, 'The quoted leader is detached from the source');
|
||||
});
|
||||
}
|
||||
|
||||
void test('redundant ordinary tenure and invalid same-recipient custody remain unavailable', () => {
|
||||
const game = position('ecaz');
|
||||
const original = structuredClone(game);
|
||||
assert.ok(ecazDukeAcquisitionBlock(game, 'ec', 'ec'));
|
||||
assert.throws(() => quoteEcazDukeAcquisition(game, 'ec', 'ec'));
|
||||
assert.deepEqual(game, original);
|
||||
for (const mutate of [
|
||||
(g: Game) => { g.dukeVidal!.leader.dead = true; },
|
||||
(g: Game) => { g.dukeVidal!.leader.capturedBy = 'other'; },
|
||||
(g: Game) => { g.dukeVidal!.leader.gholaBy = 'other'; },
|
||||
(g: Game) => { g.dukeVidal!.leader.id = 'wrong-disc'; },
|
||||
]) {
|
||||
const bad = position('ally', 'mor'); mutate(bad);
|
||||
const before = structuredClone(bad);
|
||||
assert.ok(ecazDukeAcquisitionBlock(bad, 'ec', 'mor'));
|
||||
assert.throws(() => quoteEcazDukeAcquisition(bad, 'ec', 'mor'));
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('same-controller exceptional custody cannot change the public redundant-control reason', () => {
|
||||
const game = position('ecaz'), reason = ecazDukeAcquisitionBlock(game, 'ec', 'ec');
|
||||
assert.ok(reason);
|
||||
for (const mutate of [
|
||||
(g: Game) => { g.dukeVidal!.leader.capturedBy = 'other'; },
|
||||
(g: Game) => { g.dukeVidal!.leader.gholaBy = 'other'; },
|
||||
]) {
|
||||
const hidden = structuredClone(game); mutate(hidden);
|
||||
assert.equal(ecazDukeAcquisitionBlock(hidden, 'ec', 'ec'), reason);
|
||||
assert.throws(() => quoteEcazDukeAcquisition(hidden, 'ec', 'ec'));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen does not disclose a concealed same-controller capture via the reacquisition offer', () => {
|
||||
const game = position('ecazNexus');
|
||||
game.advanced = true;
|
||||
game.players[1].faction = 'harkonnen';
|
||||
// Existing independent-Discovery profile admits Advanced Harkonnen. These
|
||||
// two controlled projections differ only in hidden Duke capture custody.
|
||||
game.players[0].ally = null; game.players[1].ally = null;
|
||||
game.ecazTreachery = true; game.discoveryEnabled = true;
|
||||
const hidden = structuredClone(game);
|
||||
hidden.dukeVidal!.leader.capturedBy = 'mor';
|
||||
hidden.dukeVidal!.leader.concealed = { captor: 'mor', controller: 'ec', dead: false, deaths: 0 };
|
||||
hidden.dukeVidal!.captureVisibility = { controller: 'ec', acquiredTurn: 2, source: 'ecazNexus' };
|
||||
const publicReason = ecazDukeAcquisitionBlock(game, 'ec', 'ec');
|
||||
assert.ok(publicReason, 'The capture-sensitive offer remains uniformly unavailable');
|
||||
assert.equal(ecazDukeAcquisitionBlock(hidden, 'ec', 'ec'), publicReason);
|
||||
assert.throws(() => quoteEcazDukeAcquisition(game, 'ec', 'ec'));
|
||||
assert.throws(() => quoteEcazDukeAcquisition(hidden, 'ec', 'ec'));
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} real Ambassador entry can regrant a controlled ally loan without replaying arrival`, () => {
|
||||
const f = createEcazDukeAllyFixture({ advanced });
|
||||
const staged = structuredClone(f.game);
|
||||
// Controlled custody supplement: the fixture earned the unique shared disc,
|
||||
// alliance, token placement and paid entry through real actions. Reassign
|
||||
// that same disc to the existing ally with the temporary loan source; no
|
||||
// additional leader, card, force or resource is created.
|
||||
staged.dukeVidal = acquireDuke(staged.dukeVidal!, f.ally, staged.turn, 'ally');
|
||||
const original = structuredClone(staged), physical = ecazDukeAllyPhysical(staged);
|
||||
const offer = viewGame(staged, f.owner).ambassadorEntry;
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.beneficiaries!.find(row => row.player === f.ally)!.blocked, null);
|
||||
const command: Action = { type: 'decision', event: f.event, trigger: true, beneficiary: f.ally, choice: 'duke' };
|
||||
let game = actEcazDukeAlly(staged, f.owner, command);
|
||||
game = finishEcazDukeAlly(f, game);
|
||||
assert.deepEqual(staged, original, 'The accepted action does not mutate its input');
|
||||
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
|
||||
[f.ally, 'ecaz', original.turn]);
|
||||
assert.deepEqual(game.dukeVidal!.leader, original.dukeVidal!.leader);
|
||||
assert.deepEqual(ecazDukeAllyPhysical(game), physical);
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'supply');
|
||||
assert.deepEqual(ecazDukeAllyInventory(game), f.originalInventory);
|
||||
assert.ok(game.players.every(seat => !seat.leaders.some(leader => leader.id === DUKE_VIDAL_ID)));
|
||||
assert.throws(() => actEcazDukeAlly(game, f.owner, command), 'The same token event cannot be replayed');
|
||||
|
||||
const fought = battleEcazDukeAlly(f, game, [], { useDuke: false });
|
||||
const next = advanceEcazDukeAlly(fought.game,
|
||||
state => state.turn === 3 && state.phase === 1 && ecazDukeAllyClean(state));
|
||||
assert.deepEqual([next.dukeVidal!.controller, next.dukeVidal!.source, next.dukeVidal!.acquiredTurn],
|
||||
[f.ally, 'ecaz', original.turn], 'Unused renewed tenure survives the actual Mentat boundary');
|
||||
assert.deepEqual(next.dukeVidal!.leader, original.dukeVidal!.leader);
|
||||
assert.deepEqual(ecazDukeAllyInventory(next), f.originalInventory);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user