Support Emperor special Karama truth commitments
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 18:12:20 +11:00
1 parent b448144c52
commit add8e1d51a
14 files changed
+345 -20

No files matched your search

@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { windowCardOpportunity } from '../game/window-card-play';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthRevival as act, createTruthRevivalFixture, declareTruthRevival,
settleTruthRevival as settle, truthRevivalInventory as inventory,
truthRevivalPlayer as player } from './fixture-truth-revival';
/** Native Advanced Revival after Emperor shipped and lost three forces in the
* original Storm; physical Truthtrance and Karama come from the real deal. */
function fixture() {
const f = createTruthRevivalFixture({ advanced: true, targetKarama: true });
assert.ok(f.auctionKarama);
const source = JSON.stringify(['karamaEmperorSpecial', f.game.turn, f.target]);
assert.equal(windowCardOpportunity(f.game, 'karamaEmperorSpecial')?.source, source);
return { ...f, source, karamaCard: f.auctionKarama };
}
function question(f: ReturnType<typeof fixture>, target = f.target) {
return { kind: 'turnActions', target, turn: f.game.turn, mixed: { kind: 'action', action: {
kind: 'windowCardPlay', effect: 'karamaEmperorSpecial', source: f.source,
} } };
}
function ask(f: ReturnType<typeof fixture>, state = f.game) {
const declared = declareTruthRevival(f, state);
return act(declared.game, f.asker, { type: 'truthAsk', question: question(f) });
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
void test('Advanced Emperor special Karama Yes uses a physical card for a real free force return', () => {
const f = fixture(), asked = ask(f);
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
assert.equal(viewGame(asked, f.opponent).truthTurnActionAnswers, null);
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card');
assert.equal(guide.action.mode, 'special');
assert.equal(guide.action.card, f.karamaCard);
for (const difficulty of DIFFICULTIES) {
const bot = viewGame(game, f.target);
bot.players.find(seat => seat.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(bot)[0], guide.action);
}
const before = structuredClone(game), priorTanks = player(game, f.target).tanks;
const priorReserves = player(game, f.target).reserves, priorSpice = player(game, f.target).spice;
// A voluntary ordinary return using every tank counter cannot eliminate the
// only native special-return witness for the accepted Yes.
reject(game, f.target, { type: 'revive', amount: 3, elite: 1 });
game = act(game, f.target, guide.action);
assert.deepEqual(before.turnActionPromises?.[0].windowCards?.karamaEmperorSpecial?.played, false);
assert.equal(player(game, f.target).specialKaramaUsed, true);
assert.equal(game.turnActionPromises!.find(row => row.player === f.target)!.windowCards!.karamaEmperorSpecial!.played, true);
assert.equal(game.turnActionPromises!.find(row => row.player === f.target)!.fulfilled, true);
const returned = priorTanks - player(game, f.target).tanks;
assert.ok(returned >= 1 && returned <= 3);
assert.equal(player(game, f.target).reserves, priorReserves + returned);
assert.equal(player(game, f.target).spice, priorSpice);
assert.equal(game.discard.filter(card => card.id === f.karamaCard).length, 1);
assert.equal(viewGame(game, f.asker).players.find(seat => seat.id === f.target)!.hand, undefined);
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []);
game = settle(game);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('Advanced Emperor special Karama No blocks special use without spending the card', () => {
const f = fixture(), asked = ask(f);
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('no'));
const game = act(asked, f.target, { type: 'truthAnswer', answer: 'no' });
reject(game, f.target, { type: 'card', card: f.karamaCard, mode: 'special', amount: 1, elite: 0 });
assert.ok(player(game, f.target).hand.some(card => card.id === f.karamaCard));
assert.notEqual(player(game, f.target).specialKaramaUsed, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('Advanced Emperor can use special Karama to revive a dead leader after Yes', () => {
const f = fixture(), staged = structuredClone(f.game), emperor = player(staged, f.target);
// This separate branch stages one existing Emperor leader's death, without
// creating a leader or changing any card or force custody.
const leader = emperor.leaders[0]; assert.ok(leader && !leader.dead);
leader.dead = true; leader.deaths = 1; delete leader.usedAt;
const asked = ask(f, staged);
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, { type: 'card', card: f.karamaCard, mode: 'special', leader: leader.id });
assert.equal(player(game, f.target).leaders.find(row => row.id === leader.id)!.dead, false);
assert.equal(game.turnActionPromises!.find(row => row.player === f.target)!.windowCards!.karamaEmperorSpecial!.played, true);
assert.equal(game.turnActionPromises!.find(row => row.player === f.target)!.fulfilled, true);
game = settle(game);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('the Emperor special source rejects other respondents and forged turns', () => {
const f = fixture(), game = declareTruthRevival(f).game;
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.opponent) });
const forged = question(f);
forged.mixed.action.source = JSON.stringify(['karamaEmperorSpecial', game.turn + 1, f.target]);
reject(game, f.asker, { type: 'truthAsk', question: forged });
});
+33
View File
@@ -2162,3 +2162,36 @@ void test('Harkonnen special Karama guide forwards only the exact live printed-c
target.handCount = 0; press(button); assert.equal(sent.length, 1);
target.handCount = 1; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
});
void test('Emperor special Karama authoring uses only the public Revival opportunity', () => {
const game = projected(4);
game.advanced = true;
const emperor = game.players.find(player => player.id === 'p')!;
emperor.faction = 'emperor'; emperor.specialKaramaUsed = false;
const source = JSON.stringify(['karamaEmperorSpecial', game.turn, 'p']);
const initial = windowClaim('karamaEmperorSpecial', source), e = editor(initial, false, game);
Object.defineProperty(emperor, 'hand', { get() { throw Error('Question authoring read private hand'); }, configurable: true });
assert.equal(e.error(), null);
assert.match(e.html(), /Use Emperor’s special Karama during Revival/);
assert.match(e.html(), /Turn 2 · Emperor special Karama during Revival/);
assert.ok(turnActionInputError(initial, game, 'r'));
emperor.specialKaramaUsed = true; assert.ok(e.error());
emperor.specialKaramaUsed = false; game.phase = 5; assert.ok(e.error());
});
void test('Emperor special Karama guide forwards only a current legal force packet', () => {
const source = JSON.stringify(['karamaEmperorSpecial', 2, 'p']);
const game = projected(4, { type:'card', mode:'special', card:'controlled-printed-karama', amount:2, elite:1 });
game.advanced = true;
const owner = game.players.find(player => player.id === 'p')!;
owner.faction = 'emperor'; owner.specialKaramaUsed = false; owner.tanks = 3;
owner.elites = { reserves: 3, tanks: 1, forces: {}, revived: 0 };
game.revival.prevented = false; game.revival.pending = false; game.revival.sardaukarFreeBlock = null;
game.turnActionPromises[0].mixed = windowClaim('karamaEmperorSpecial', source);
const sent: Action[] = [], props = { game, busy:false, act(action:Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false);
assert.match(renderToStaticMarkup(button), /Emperor’s special Karama/);
press(button); assert.deepEqual(sent, [game.turnActionGuide!.action]);
owner.tanks = 1; press(button); assert.equal(sent.length, 1);
owner.tanks = 3; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
});
+36 -1
View File
@@ -62,7 +62,8 @@ const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expressi
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaHarkonnenSpecial'
? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaEmperorSpecial'
? JSON.stringify(['karamaEmperorSpecial', 1, 'respondent']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
effect === 'amal' ? 'phase-opening:1:2' : effect === 'thumper' ? 'first-spice-reveal:1' :
effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
@@ -1898,3 +1899,37 @@ void test('Harkonnen special No and source binding retain exact responder, turn
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
});
void test('Emperor special Karama window records only the original owner and phase after the answer', () => {
const source = windowSource('karamaEmperorSpecial');
const question = windowed(group('and', windowCard('karamaEmperorSpecial'), pay()));
const signed = { kind: 'windowCardPlay', effect: 'karamaEmperorSpecial', source, answer: true };
assert.deepEqual(branches(question), [[signed, signedPay()]]);
const otherOwner = recordTurnActionWindowCardPlay([question], 'other', 1, 'karamaEmperorSpecial', source);
assert.equal(otherOwner[0], question);
const actual = recordTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaEmperorSpecial', source);
assert.deepEqual(branches(actual[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(actual, 'respondent', 1, payment())[0].fulfilled, true);
assert.equal(question.windowCards!.karamaEmperorSpecial!.played, false);
validateTurnActionPromises(actual, seats);
const closed = closeTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaEmperorSpecial', source);
assert.equal(closed[0].windowCards!.karamaEmperorSpecial!.closed, true);
assert.deepEqual(branches(closed[0]), []);
});
void test('Emperor special No and source binding retain exact responder, turn and old receipts', () => {
const source = windowSource('karamaEmperorSpecial');
const negative = windowed(windowCard('karamaEmperorSpecial'), false);
assert.throws(() => recordTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaEmperorSpecial', source), TurnActionError);
const passed = closeTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaEmperorSpecial', source);
assert.equal(passed[0].fulfilled, true);
for (const bad of ['', 'storm:1', JSON.stringify(['karamaEmperorSpecial', 0, 'respondent']),
JSON.stringify(['karamaEmperorSpecial', 1, '']), JSON.stringify(['karamaEmperorSpecial', 1, 'respondent', 'extra'])])
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'karamaEmperorSpecial', source: bad }), TurnActionError);
for (const bad of [JSON.stringify(['karamaEmperorSpecial', 2, 'respondent']),
JSON.stringify(['karamaEmperorSpecial', 1, 'other'])])
assert.throws(() => turnActionValue(windowed(windowCard('karamaEmperorSpecial', bad))), TurnActionError);
const old = windowed(windowCard('karamaAuction'));
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
});
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseKaramaEmperorSpecialSource as parse, windowCardNativeEffect,
windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
const source = (): WindowCardGame => ({ status: 'playing', advanced: true, phase: 4, turn: 2,
shieldWallDestroyed: false, players: [{ id: 'h', faction: 'emperor', specialKaramaUsed: false },
{ id: 'a', faction: 'atreides', specialKaramaUsed: false }] });
const event = JSON.stringify(['karamaEmperorSpecial', 2, 'h']);
void test('Emperor special window is public, turn-bound and independent of private custody', () => {
const game = source(), before = structuredClone(game);
assert.deepEqual(windowCardOpportunity(game, 'karamaEmperorSpecial'),
{ effect: 'karamaEmperorSpecial', source: event, turn: 2 });
assert.deepEqual(parse(event), { turn: 2, owner: 'h' });
assert.equal(windowCardNativeEffect('karamaEmperorSpecial'), 'karama');
assert.equal(windowCardOpportunity({ ...game, response: { kind: 'voice' }, decision: { kind: 'other' } },
'karamaEmperorSpecial')?.source, event);
assert.equal(windowCardOpportunity(game, 'karamaEmperorSpecial', event)?.source, event);
assert.deepEqual(game, before);
});
void test('special opportunity ends with use, turn or phase; malformed sources reject', () => {
const game = source();
for (const changes of [{ status: 'lobby' }, { advanced: false }, { phase: 3 }, { turn: 0 },
{ players: [{ id: 'h', faction: 'emperor', specialKaramaUsed: true }] },
{ players: [{ id: 'h', faction: 'atreides' }] },
{ players: [{ id: 'h', faction: 'emperor' }, { id: 'h', faction: 'atreides' }] },
{ players: [{ id: 'h', faction: 'emperor' }, { id: 'h2', faction: 'emperor' }] }])
assert.equal(windowCardOpportunity({ ...game, ...changes } as WindowCardGame, 'karamaEmperorSpecial'), null);
for (const value of ['', '[]', '["karamaEmperorSpecial",2," h"]',
'["karamaEmperorSpecial", 2,"h"]', '["karamaEmperorSpecial",0,"h"]',
'["karamaEmperorSpecial",2,"h","extra"]']) assert.equal(parse(value), null);
assert.equal(windowCardOpportunity(game, 'karamaEmperorSpecial', JSON.stringify(['karamaEmperorSpecial', 2, 'a'])), null);
assert.equal(windowCardOpportunity({ ...game, turn: 3 }, 'karamaEmperorSpecial', event), null);
assert.ok(windowCardOpportunity({ ...game, players: [{ id: 'h', faction: 'emperor' }] }, 'karamaEmperorSpecial'));
});
+2 -2
View File
@@ -23,8 +23,8 @@ function shipments(): WindowCardGame {
}
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'thumper', 'amal']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'thumper', 'amal']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
});
void test('Amal names each original phase opening with only its public turn and phase identity', () => {