Support Atreides special Karama truth commitments
Verify and publish checkpoint container / container (push) Successful in 10m46s
Verify and publish checkpoint container / container (push) Successful in 10m46s
This commit is contained in:
1 parent
add8e1d51a
commit
5079ab556f
16 files changed
+408
-19
No files matched your search
@@ -0,0 +1,102 @@
|
||||
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 { createAtreidesFullPlanWindowFixture, actAtreidesFullPlanWindow as act,
|
||||
atreidesFullPlanWindowPlayer as player, atreidesFullPlanWindowInventory as inventory,
|
||||
finishAtreidesFullPlanWindow as finish } from './fixture-atreides-full-plan-window';
|
||||
|
||||
/** The underlying battle and Karama are earned by native actions. Stage one
|
||||
* original Truthtrance into Guild's hand by exchanging it with an undealt card;
|
||||
* this does not claim the question card was earned through play. */
|
||||
function fixture() {
|
||||
const f = createAtreidesFullPlanWindowFixture(), game = structuredClone(f.game);
|
||||
const asker = player(game, f.guild);
|
||||
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const original = asker.hand.findIndex(card => card.effect !== 'truthtrance'); assert.ok(original >= 0);
|
||||
[game.deck[index], asker.hand[original]] = [asker.hand[original], game.deck[index]];
|
||||
const truth = asker.hand[original].id;
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
const window = windowCardOpportunity(game, 'karamaAtreidesSpecial'); assert.ok(window);
|
||||
return { ...f, game, asker: asker.id, truth, source: window.source };
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function declare(f: ReturnType<typeof fixture>, source = f.game) {
|
||||
let game = act(source, f.asker, { type: 'card', card: f.truth });
|
||||
for (let i = 0; game.truthtrance?.stage === 'priority' && i < game.players.length; i++) {
|
||||
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask'); return game;
|
||||
}
|
||||
function question(f: ReturnType<typeof fixture>, target = f.owner) {
|
||||
return { kind: 'turnActions', target, turn: f.game.turn, mixed: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect: 'karamaAtreidesSpecial', source: f.source,
|
||||
} } };
|
||||
}
|
||||
function ask(f: ReturnType<typeof fixture>, source = f.game) {
|
||||
return act(declare(f, source), f.asker, { type: 'truthAsk', question: question(f) });
|
||||
}
|
||||
|
||||
void test('Advanced Atreides special Yes uses the original physical card and target-first plan', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(asked, f.opponent).truthTurnActionAnswers, null);
|
||||
let game = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const guide = viewGame(game, f.owner).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.mode, 'special');
|
||||
assert.equal(guide.action.card, f.karama);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.owner); bot.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], guide.action);
|
||||
}
|
||||
game = act(game, f.owner, f.inspect);
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.fulfilled, true);
|
||||
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.windowCards!.karamaAtreidesSpecial!.played, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(viewGame(game, f.asker).battle?.fullPlanInsight, null);
|
||||
reject(game, f.owner, f.ownPlan);
|
||||
game = act(game, f.opponent, f.opponentPlan);
|
||||
assert.ok(viewGame(game, f.owner).battle?.fullPlanInsight);
|
||||
assert.equal(viewGame(game, f.asker).battle?.fullPlanInsight, null);
|
||||
game = finish(game, f);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('Advanced Atreides special No blocks that role but preserves the held card', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
const game = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
reject(game, f.owner, f.inspect);
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === f.karama));
|
||||
assert.notEqual(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('a sealed target stays private and does not alter the availability of the same commitment', () => {
|
||||
const f = fixture();
|
||||
const sealed = act(f.game, f.opponent, f.opponentPlan);
|
||||
assert.equal(viewGame(sealed, f.asker).battle?.plans[f.opponent], undefined);
|
||||
const asked = ask(f, sealed);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
let game = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const before = structuredClone(game.battle!.plans);
|
||||
game = act(game, f.owner, f.inspect);
|
||||
assert.deepEqual(game.battle!.plans, before);
|
||||
assert.ok(viewGame(game, f.owner).battle?.fullPlanInsight);
|
||||
assert.equal(viewGame(game, f.asker).battle?.fullPlanInsight, null);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('Atreides source rejects a different respondent or changed battle event', () => {
|
||||
const f = fixture(), game = declare(f);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.opponent) });
|
||||
const forged = question(f);
|
||||
forged.mixed.action.source = JSON.stringify(['karamaAtreidesSpecial', game.turn, f.owner, 'other-battle', f.territory]);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: forged });
|
||||
});
|
||||
@@ -101,3 +101,29 @@ void test('the Emperor special source rejects other respondents and forged turns
|
||||
forged.mixed.action.source = JSON.stringify(['karamaEmperorSpecial', game.turn + 1, f.target]);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: forged });
|
||||
});
|
||||
|
||||
void test('Emperor special guide uses its own promise event after an earlier unrelated whole-turn answer', () => {
|
||||
const f = fixture();
|
||||
// Both original Truthtrances were dealt to the asker by the native fixture.
|
||||
let game = declareTruthRevival(f, f.game, { copies: 2 }).game;
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.target, turn: game.turn,
|
||||
mixed: { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } },
|
||||
} });
|
||||
assert.ok(viewGame(game, f.target).truthTurnActionAnswers?.includes('no'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: question(f) });
|
||||
assert.ok(viewGame(game, f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const rows = game.turnActionPromises!.filter(row => row.player === f.target && !row.fulfilled && !row.released);
|
||||
assert.equal(rows.length, 2);
|
||||
const special = rows.find(row => row.windowCards?.karamaEmperorSpecial); assert.ok(special);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.event, special.event);
|
||||
assert.equal(guide.action.turnActionPromiseEvent, special.event);
|
||||
assert.equal(guide.action.mode, 'special');
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.ok(game.turnActionPromises!.find(row => row.event === special.event)?.fulfilled);
|
||||
assert.deepEqual(inventory(settle(game)), f.originalCards);
|
||||
});
|
||||
@@ -153,3 +153,48 @@ void test('special-Karama OR Hajr preserves either alternative and No forbids th
|
||||
assert.ok(player(no, f.owner).hand.some(card => card.id === f.karama));
|
||||
assert.deepEqual(inventory(no), f.cards);
|
||||
});
|
||||
|
||||
void test('Harkonnen special guide uses its own promise event after an earlier unrelated whole-turn answer', () => {
|
||||
const f = fixture(), game = structuredClone(f.game);
|
||||
// Move the second original Truthtrance from Harkonnen to the asker; no card
|
||||
// is created or removed and Harkonnen retains the real Karama.
|
||||
const owner = player(game, f.owner), asker = player(game, f.asker);
|
||||
const index = owner.hand.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const second = owner.hand.splice(index, 1)[0]; asker.hand.push(second);
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
let current = act(game, f.asker, { type: 'card', card: f.truth, cards: [second.id] });
|
||||
while (current.truthtrance?.stage === 'priority') {
|
||||
const seat = current.players.find(p => !current.truthtrance!.passed.includes(p.id)); assert.ok(seat);
|
||||
current = act(current, seat.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(current.truthtrance?.stage, 'ask');
|
||||
current = act(current, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.owner, turn: current.turn,
|
||||
mixed: { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } },
|
||||
} });
|
||||
assert.ok(viewGame(current, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
current = act(current, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(current.truthtrance?.stage, 'ask');
|
||||
current = act(current, f.asker, { type: 'truthAsk', question: question(f) });
|
||||
assert.ok(viewGame(current, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
current = act(current, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const rows = current.turnActionPromises!.filter(row => row.player === f.owner && !row.fulfilled && !row.released);
|
||||
assert.equal(rows.length, 2);
|
||||
const specialRow = rows.find(row => row.windowCards?.karamaHarkonnenSpecial); assert.ok(specialRow);
|
||||
const guide = viewGame(current, f.owner).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.event, specialRow.event);
|
||||
assert.equal(guide.action.turnActionPromiseEvent, specialRow.event);
|
||||
assert.equal(guide.action.mode, 'special');
|
||||
current = act(current, f.owner, guide.action);
|
||||
assert.ok(current.turnActionPromises!.find(row => row.event === specialRow.event)?.fulfilled);
|
||||
if (current.decision?.kind === 'handExchange') {
|
||||
const returned = player(current, f.owner).hand[0]; assert.ok(returned);
|
||||
current = act(current, f.owner, { type: 'decision', returnCards: [returned.id] });
|
||||
} else {
|
||||
// After spending the only held card, the native exchange automatically
|
||||
// returns the one unseen card because it is the entire remaining hand.
|
||||
assert.equal(current.pendingExchange, null);
|
||||
assert.ok(current.log.some(entry => entry.text.includes('returned 1 cards') && entry.text.includes('automatically')));
|
||||
}
|
||||
assert.deepEqual(inventory(current), f.cards);
|
||||
});
|
||||
@@ -2195,3 +2195,38 @@ void test('Emperor special Karama guide forwards only a current legal force pack
|
||||
owner.tanks = 1; press(button); assert.equal(sent.length, 1);
|
||||
owner.tanks = 3; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
void test('Atreides special Karama authoring uses the public battle window and owner', () => {
|
||||
const game = combat(false);
|
||||
game.advanced = true;
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
const other = game.players.find(player => player.id === 'a')!;
|
||||
owner.faction = 'atreides'; owner.specialKaramaUsed = false; other.faction = 'emperor';
|
||||
const source = JSON.stringify(['karamaAtreidesSpecial', game.turn, 'p', game.battle!.event, game.battle!.territory]);
|
||||
const initial = windowClaim('karamaAtreidesSpecial', source), e = editor(initial, false, game);
|
||||
Object.defineProperty(owner, 'hand', { get() { throw Error('Question authoring read private hand'); }, configurable: true });
|
||||
assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Use Atreides’ special Karama in this battle/);
|
||||
assert.match(e.html(), /Turn 2 · Atreides special Karama in this battle/);
|
||||
assert.ok(turnActionInputError(initial, game, 'r'));
|
||||
owner.specialKaramaUsed = true; assert.ok(e.error());
|
||||
owner.specialKaramaUsed = false; game.battle!.revealed = true; assert.ok(e.error());
|
||||
});
|
||||
|
||||
void test('Atreides special Karama guide forwards only the live target battle packet', () => {
|
||||
const game = combat(false, { type:'card', mode:'special', card:'controlled-printed-karama', target:'r' });
|
||||
game.advanced = true;
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
const asker = game.players.find(player => player.id === 'a')!;
|
||||
owner.faction = 'atreides'; owner.specialKaramaUsed = false; asker.faction = 'emperor';
|
||||
// The Atreides owner may inspect a battle in which they are not a participant.
|
||||
game.battle!.attacker = 'a'; game.battle!.defender = 'r';
|
||||
const source = JSON.stringify(['karamaAtreidesSpecial', 2, 'p', game.battle!.event, game.battle!.territory]);
|
||||
game.turnActionPromises[0].mixed = windowClaim('karamaAtreidesSpecial', 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), /Atreides’ special Karama/);
|
||||
press(button); assert.deepEqual(sent, [game.turnActionGuide!.action]);
|
||||
game.battle!.defender = 'p'; press(button); assert.equal(sent.length, 1);
|
||||
game.battle!.defender = 'r'; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
@@ -63,7 +63,8 @@ const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expressi
|
||||
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaHarkonnenSpecial'
|
||||
? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaEmperorSpecial'
|
||||
? JSON.stringify(['karamaEmperorSpecial', 1, 'respondent']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
? JSON.stringify(['karamaEmperorSpecial', 1, 'respondent']) : effect === 'karamaAtreidesSpecial'
|
||||
? JSON.stringify(['karamaAtreidesSpecial', 1, 'respondent', 'battle:1', 'carthag']) : 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 =>
|
||||
@@ -1933,3 +1934,21 @@ void test('Emperor special No and source binding retain exact responder, turn an
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
|
||||
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
|
||||
});
|
||||
|
||||
|
||||
void test('Atreides special window retains owner, battle and ordinary Yes/No outcomes', () => {
|
||||
const source = windowSource('karamaAtreidesSpecial');
|
||||
const yes = windowed(group('and', windowCard('karamaAtreidesSpecial'), pay()));
|
||||
assert.deepEqual(branches(yes), [[{ kind: 'windowCardPlay', effect: 'karamaAtreidesSpecial', source, answer: true }, signedPay()]]);
|
||||
const actual = recordTurnActionWindowCardPlay([yes], 'respondent', 1, 'karamaAtreidesSpecial', source);
|
||||
assert.equal(recordTurnActionPayment(actual, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
const no = windowed(windowCard('karamaAtreidesSpecial'), false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAtreidesSpecial', source), TurnActionError);
|
||||
assert.equal(closeTurnActionWindowCardPlay([no], 'respondent', 1, 'karamaAtreidesSpecial', source)[0].fulfilled, true);
|
||||
for (const bad of ['', JSON.stringify(['karamaAtreidesSpecial', 0, 'respondent', 'battle:1', 'carthag']),
|
||||
JSON.stringify(['karamaAtreidesSpecial', 1, 'respondent', '', 'carthag'])])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'karamaAtreidesSpecial', source: bad }), TurnActionError);
|
||||
for (const bad of [JSON.stringify(['karamaAtreidesSpecial', 2, 'respondent', 'battle:1', 'carthag']),
|
||||
JSON.stringify(['karamaAtreidesSpecial', 1, 'other', 'battle:1', 'carthag'])])
|
||||
assert.throws(() => turnActionValue(windowed(windowCard('karamaAtreidesSpecial', bad))), TurnActionError);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseKaramaAtreidesSpecialSource as parse, windowCardNativeEffect,
|
||||
windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
|
||||
|
||||
const source = (): WindowCardGame => ({ status: 'playing', advanced: true, phase: 6, turn: 2,
|
||||
shieldWallDestroyed: false, players: [{ id: 'a', faction: 'atreides', specialKaramaUsed: false },
|
||||
{ id: 'h', faction: 'harkonnen' }], battle: { event: 'battle:1', territory: 'carthag', revealed: false } });
|
||||
const event = JSON.stringify(['karamaAtreidesSpecial', 2, 'a', 'battle:1', 'carthag']);
|
||||
|
||||
void test('Atreides special source is public and bound to the original unrevealed battle', () => {
|
||||
const game = source(), before = structuredClone(game);
|
||||
assert.deepEqual(windowCardOpportunity(game, 'karamaAtreidesSpecial'),
|
||||
{ effect: 'karamaAtreidesSpecial', source: event, turn: 2 });
|
||||
assert.deepEqual(parse(event), { turn: 2, owner: 'a', battle: 'battle:1', territory: 'carthag' });
|
||||
assert.equal(windowCardNativeEffect('karamaAtreidesSpecial'), 'karama');
|
||||
assert.equal(windowCardOpportunity({ ...game, response: { kind: 'voice' }, decision: { kind: 'other' } },
|
||||
'karamaAtreidesSpecial')?.source, event);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('Atreides special source rejects changed battle, use, timing and malformed tuples', () => {
|
||||
const game = source();
|
||||
for (const changes of [{ status: 'lobby' }, { advanced: false }, { phase: 3 }, { turn: 0 },
|
||||
{ battle: null }, { battle: { event: 'battle:1', territory: 'carthag', revealed: true } },
|
||||
{ battle: { event: '', territory: 'carthag', revealed: false } },
|
||||
{ players: [{ id: 'a', faction: 'atreides', specialKaramaUsed: true }] },
|
||||
{ players: [{ id: 'a', faction: 'atreides' }, { id: 'b', faction: 'atreides' }] }])
|
||||
assert.equal(windowCardOpportunity({ ...game, ...changes } as WindowCardGame, 'karamaAtreidesSpecial'), null);
|
||||
for (const value of ['', '[]', '["karamaAtreidesSpecial", 2,"a","battle:1","carthag"]',
|
||||
JSON.stringify(['karamaAtreidesSpecial', 0, 'a', 'battle:1', 'carthag']),
|
||||
JSON.stringify(['karamaAtreidesSpecial', 2, 'a', '', 'carthag'])]) assert.equal(parse(value), null);
|
||||
assert.equal(windowCardOpportunity({ ...game, battle: { event: 'battle:2', territory: 'carthag', revealed: false } },
|
||||
'karamaAtreidesSpecial', event), null);
|
||||
});
|
||||
@@ -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', 'karamaEmperorSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
});
|
||||
|
||||
void test('Amal names each original phase opening with only its public turn and phase identity', () => {
|
||||
|
||||
Reference in new issue
Block a user