Support native Ix factions in Emperor opening gifts
Verify and publish checkpoint container / container (push) Successful in 10m18s
Verify and publish checkpoint container / container (push) Successful in 10m18s
This commit is contained in:
1 parent
d4abc6bb83
commit
171bc3a636
7 files changed
+194
-11
No files matched your search
+13
-2
@@ -17,14 +17,25 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic Ixian and Tleilaxu opening gifts — 29 October. Prototyped / Partial.**
|
||||
The Basic Ix-deck phase-opening gift now supports both native expansion factions,
|
||||
including either as the Emperor's ally. Public Basic setup with staged mutual
|
||||
alliances and conserved Amal/Karama custody covers both recipients, allowance,
|
||||
cancellation and subsequent Amal; rendered table controls and minimal bot
|
||||
responses also pass. The related four-file batch passes 18 cases; type checking,
|
||||
lint and the production build pass. Other expansion families, optional modules,
|
||||
competing commitments and the bot timing race remain open.
|
||||
[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-emperor-gift-during-ix-phase-openings--29-october-2026).
|
||||
|
||||
**Emperor gift during Basic Ix phase openings — 29 October. Prototyped /
|
||||
Partial.** A clean classic-faction Ix-deck opening now allows an Emperor to
|
||||
share spice with a mutual ally even after passing; allowance or Karama
|
||||
cancellation resumes the original opening and passes. Real Amal applies to the
|
||||
updated wallets, and a Mentat opening retains its deferred initialization.
|
||||
Thirty-seven related cases, type checking, lint and the production build pass.
|
||||
Expansion factions, optional modules and other interrupted parents remain open.
|
||||
As with Extortion, bot seats may finish the opening before a human Emperor acts;
|
||||
That original checkpoint excluded expansion factions; the later Ixian and
|
||||
Tleilaxu extension above now covers those two. Other interrupted parents remain
|
||||
open. As with Extortion, bot seats may finish the opening before a human Emperor acts;
|
||||
deployed acceptance also remains open.
|
||||
[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-emperor-gift-during-ix-phase-openings--29-october-2026).
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
## Basic Emperor gift during Ix phase openings — 29 October 2026
|
||||
|
||||
In a clean Basic game with the Ix deck and classic factions, the Emperor may
|
||||
share spice with a mutual ally during an Amal phase opening, including after
|
||||
In a clean Basic game with the Ix deck, classic factions and native Ixians or
|
||||
Tleilaxu, the Emperor may share spice with a mutual ally during an Amal phase
|
||||
opening, including after
|
||||
passing. The opening and its exact pass history pause while the gift's Karama
|
||||
response resolves; allowance transfers the spice immediately and cancellation
|
||||
transfers nothing. The same opening then resumes, and a subsequent Amal applies
|
||||
@@ -12,11 +13,15 @@ to the updated balances and resets passes normally.
|
||||
The [publisher FAQ, p. 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
||||
permits direct allied gifts for any reason and immediate collection. Applying
|
||||
that power during the Basic opening is a provisional online timing
|
||||
interpretation. The initial scope excludes expansion factions, optional modules,
|
||||
interpretation. The scope excludes other expansion families, optional modules,
|
||||
competing continuations and outstanding commitments requiring a combined
|
||||
planner. Advanced and other interruption windows remain unfinished.
|
||||
[Runtime cases](../tests/emperor-phase-opening-gift.test.ts) and
|
||||
[rendered controls](../tests/emperor-timing-gift-controls.test.tsx).
|
||||
planner. Bot seats may finish the opening before a human Emperor can act.
|
||||
Advanced and other interruption windows remain unfinished. Runtime evidence
|
||||
covers [classic](../tests/emperor-phase-opening-gift.test.ts) and
|
||||
[native Ix faction](../tests/emperor-ix-native-opening-gift.test.ts) games;
|
||||
the native cases start with public setup and stage the reciprocal alliance and
|
||||
physical card custody. Rendered controls cover [classic](../tests/emperor-timing-gift-controls.test.tsx)
|
||||
and [native Ix factions](../tests/emperor-ix-native-opening-gift-controls.test.tsx).
|
||||
|
||||
## Emperor gift during Moritani Extortion — 29 October 2026
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
The publisher FAQ permits immediate direct gifts to an ally. Allowing a gift
|
||||
inside an Amal opening is a provisional Basic online timing interpretation;
|
||||
the original pass history resumes after allowance or cancellation.
|
||||
the original pass history resumes after allowance or cancellation. The clean
|
||||
Ix-deck profile includes native Ixians and Tleilaxu alongside classic factions.
|
||||
[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-emperor-gift-during-ix-phase-openings--29-october-2026).
|
||||
|
||||
## Emperor gift during Moritani Extortion — 29 October 2026
|
||||
|
||||
+1
-1
@@ -29411,7 +29411,7 @@ function emperorPhaseOpeningGiftSignature(g: Game, opening: {passed: string[]; i
|
||||
/** Initial Basic Ix opening support, with no independent competing windows. */
|
||||
function emperorPhaseOpeningGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
|
||||
if (g.advanced || g.status !== 'playing' || g.expansions.length !== 1 || g.expansions[0] !== 'ix' ||
|
||||
g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) ||
|
||||
g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen', 'ixians', 'tleilaxu'].includes(seat.faction)) ||
|
||||
g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards ||
|
||||
g.discoveryEnabled || g.sandtrout || g.coalitionState || g.semutaPreview || g.semutaRecruitsPreview ||
|
||||
!g.phaseOpening || !g.amalWindowOpen ||
|
||||
|
||||
@@ -87,7 +87,7 @@ export const FACTION_RULES: Record<
|
||||
'Receive payments for other players’ auction purchases. Your own purchases pay the bank; a Karama purchase pays no spice. A cancellation sends one auction payment to the bank.',
|
||||
'Give spice directly to your ally. After the response window it is immediately spendable, unlike a deferred bribe between non-allies.',
|
||||
'In supported Moritani games, you may also give spice while your ally is choosing whether to pay Extortion. The original choice resumes after the gift’s Karama response.',
|
||||
'In supported Basic Ix-deck games, you may give an ally spice during the Amal phase opening, even after you have passed. The original opening resumes after the gift response.',
|
||||
'In supported Basic Ix-deck games with classic factions, Ixians or Tleilaxu, you may give an ally spice during the Amal phase opening, even after you have passed. The original opening resumes after the gift response.',
|
||||
'During Revival, pay two spice per extra allied force, up to three extra forces per turn. These are separate from the ally’s normal three-force allowance and may be purchased before or after ordinary revivals.',
|
||||
'Spice gifts and extra revival uses have Karama response windows. Canceling a use spends neither the Emperor’s spice nor the extra-revival allowance.',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
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();
|
||||
|
||||
/** A native public Basic Ix start and physical setup. Alliance is staged
|
||||
* because the first Storm opening precedes the first Nexus. */
|
||||
function opening(ally: 'ixians' | 'tleilaxu'): Game {
|
||||
let game = createGame('IXGIFTUI', newPlayer('emperor', 'Emperor', 'emperor'), false, ['ix']);
|
||||
joinGame(game, newPlayer('ixians', 'Ixians', 'ixians'));
|
||||
joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
|
||||
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
|
||||
game = applyAction(game, 'emperor', {type:'start'});
|
||||
for (let step = 0; game.status === 'setup' && step < 100; step++) {
|
||||
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.phase, 0);
|
||||
assert.ok(game.phaseOpening);
|
||||
game.players.find(player => player.id === 'emperor')!.ally = ally;
|
||||
game.players.find(player => player.id === ally)!.ally = 'emperor';
|
||||
return game;
|
||||
}
|
||||
const table = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(GameTable, {game, busy, send:async () => {}, onExit() {}}));
|
||||
|
||||
for (const ally of ['ixians', 'tleilaxu'] as const) {
|
||||
void test(`native ${ally} ally opening shows an enabled owner-only Emperor gift control`, () => {
|
||||
let game = opening(ally);
|
||||
const other = ally === 'ixians' ? 'tleilaxu' : 'ixians';
|
||||
game = applyAction(game, 'emperor', {type:'ready'});
|
||||
const ownView = viewGame(game, 'emperor');
|
||||
assert.deepEqual(ownView.emperorPhaseOpeningGift, {recipient:ally, maximum:10});
|
||||
assert.equal(viewGame(game, ally).emperorPhaseOpeningGift, null);
|
||||
assert.equal(viewGame(game, other).emperorPhaseOpeningGift, null);
|
||||
const own = table(ownView);
|
||||
assert.match(own, /Before Storm/);
|
||||
assert.match(own, new RegExp(`Share spice with ${ally === 'ixians' ? 'Ixians' : 'Tleilaxu'}`));
|
||||
assert.match(own, /Phase opening resumes after the Karama response to your gift/);
|
||||
assert.match(own, /type="number"[^>]*min="1"[^>]*max="10"/);
|
||||
assert.match(own, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
|
||||
assert.match(table(ownView, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
|
||||
assert.doesNotMatch(table(viewGame(game, ally)), /Phase opening resumes after the Karama response to your gift/);
|
||||
assert.doesNotMatch(table(viewGame(game, other)), /Phase opening resumes after the Karama response to your gift/);
|
||||
|
||||
// SSR checks the enabled control; dispatch the advertised action through the
|
||||
// same native engine separately, without claiming a browser click occurred.
|
||||
const openingBefore = structuredClone(game.phaseOpening);
|
||||
const next = applyAction(game, 'emperor', {type:'emperorGift', amount:1});
|
||||
assert.ok(next.response || next.phaseOpening);
|
||||
if (!next.response) assert.deepEqual(next.phaseOpening, openingBefore);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { truthTurnAmalInventory as inventory } from './fixture-truth-turn-amal';
|
||||
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const copy = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
|
||||
/** Public Basic setup seats both native Ix factions. Only the reciprocal
|
||||
* alliance and the physical Amal/Karama custody are staged after setup. */
|
||||
function fixture(recipient: 'ixians' | 'tleilaxu') {
|
||||
let game = createGame('EMPIXOPEN', newPlayer('emperor', 'Emperor', 'emperor'), false, ['ix']);
|
||||
for (const id of ['ixians', 'tleilaxu', 'atreides'] as const) joinGame(game, newPlayer(id, id, id));
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
game = act(game, 'emperor', {type:'start'});
|
||||
for (let i = 0; game.status === 'setup' && i < 100; i++) {
|
||||
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(game.phaseOpening);
|
||||
const original = inventory(game);
|
||||
seat(game, 'emperor').ally = recipient; seat(game, recipient).ally = 'emperor';
|
||||
// Move existing cards, including any originally dealt copy, into the deck;
|
||||
// then give the unpassed rival one real copy of each required card.
|
||||
for (const player of game.players) {
|
||||
const held = player.hand.filter(card => card.effect === 'amal' || card.effect === 'karama');
|
||||
player.hand = player.hand.filter(card => card.effect !== 'amal' && card.effect !== 'karama');
|
||||
game.deck.push(...held);
|
||||
}
|
||||
function transfer(effect: 'amal' | 'karama') {
|
||||
const index = game.deck.findIndex(card => card.effect === effect); assert.ok(index >= 0);
|
||||
const [card] = game.deck.splice(index, 1); seat(game, 'atreides').hand.push(card); return card.id;
|
||||
}
|
||||
const amal = transfer('amal'), counter = transfer('karama');
|
||||
assert.deepEqual(inventory(game), original);
|
||||
game = act(game, 'emperor', {type:'ready'});
|
||||
game = act(game, recipient, {type:'ready'});
|
||||
assert.deepEqual(game.phaseOpening?.passed, ['emperor', recipient]);
|
||||
return {game, original, amal, counter};
|
||||
}
|
||||
function passResponses(game: Game) {
|
||||
for (let i = 0; game.response && i < 16; i++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response, null); return game;
|
||||
}
|
||||
for (const recipient of ['ixians', 'tleilaxu'] as const) {
|
||||
for (const cancel of [false, true]) {
|
||||
void test(`Basic Ix native ${recipient} opening gift ${cancel ? 'cancellation' : 'allowance'} preserves passes and later Amal`, () => {
|
||||
const f = fixture(recipient), opening = structuredClone(f.game.phaseOpening);
|
||||
const beforeOwner = seat(f.game, 'emperor').spice, beforeAlly = seat(f.game, recipient).spice;
|
||||
const phase = f.game.phase, turn = f.game.turn;
|
||||
assert.deepEqual(viewGame(f.game, 'emperor').emperorPhaseOpeningGift, {recipient, maximum:beforeOwner});
|
||||
assert.equal(viewGame(f.game, recipient).emperorPhaseOpeningGift, null);
|
||||
assert.equal(viewGame(f.game, 'atreides').emperorPhaseOpeningGift, null);
|
||||
let game = act(f.game, 'emperor', {type:'emperorGift', amount:3});
|
||||
assert.equal(game.response?.kind, 'emperorGift'); assert.equal(game.phaseOpening, null);
|
||||
assert.equal(viewGame(game, 'atreides').response?.amount, undefined);
|
||||
game = cancel ? act(game, 'atreides', {type:'card', card:f.counter, mode:'cancel'}) : passResponses(game);
|
||||
assert.equal(game.response, null); assert.deepEqual(game.phaseOpening, opening);
|
||||
assert.equal(game.phase, phase); assert.equal(game.turn, turn);
|
||||
assert.equal(seat(game, 'emperor').spice, beforeOwner - (cancel ? 0 : 3));
|
||||
assert.equal(seat(game, recipient).spice, beforeAlly + (cancel ? 0 : 3));
|
||||
assert.equal(game.discard.filter(card => card.id === f.counter).length, Number(cancel));
|
||||
const wallets = game.players.map(player => player.spice);
|
||||
game = act(game, 'atreides', {type:'card', card:f.amal});
|
||||
assert.deepEqual(game.players.map(player => player.spice), wallets.map(amount => Math.floor(amount / 2)));
|
||||
assert.deepEqual(game.phaseOpening?.passed, []);
|
||||
assert.equal(game.discard.filter(card => card.id === f.amal).length, 1);
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
assert.equal(game.phaseOpening, null); assert.equal(game.phase, phase); assert.equal(game.turn, turn);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
}
|
||||
}
|
||||
void test('all minimal bots can respond to a native Ix opening gift', () => {
|
||||
const f = fixture('ixians'), opening = structuredClone(f.game.phaseOpening);
|
||||
const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = copy(pending);
|
||||
const view = viewGame(game, 'atreides'); view.players.find(player => player.id === 'atreides')!.bot = difficulty;
|
||||
const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card');
|
||||
assert.ok(action); game = act(game, 'atreides', action); game = passResponses(game);
|
||||
assert.deepEqual(game.phaseOpening, opening); assert.equal(game.phase, f.game.phase);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user