Enable Basic Kull preview with Tech Tokens
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
92d5c0ac2e
commit
16f10beecf
11 files changed
+375
-18
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
|
||||
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
import { NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS } from './fixture-no-field-reinforcements';
|
||||
import { ecazOccupyRicheseFixture, finishOccupyRichese, nextOccupyRichese,
|
||||
occupyRicheseSeat, openOccupyRichese } from './fixture-ecaz-occupy-richese';
|
||||
|
||||
/** Select the independent physical cards before the original Basic deal. */
|
||||
function basicSetup(): Game {
|
||||
const roster = ['ecaz', 'richese', 'guild', 'choam'] as const;
|
||||
let game = createGame('BASICMIXEDCARDS', newPlayer(roster[0], roster[0], roster[0]),
|
||||
false, ['ecaz', 'choam']);
|
||||
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
for (let i = roster.length - 1; i >= 0; i--) {
|
||||
if (viewGame(game, roster[i]).playerPositions[roster[i]] !== i + 1)
|
||||
game = applyAction(game, roster[i], { type: 'seatPosition', position: i + 1 });
|
||||
}
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game.ecazTreachery = true;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array);
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
|
||||
return array;
|
||||
};
|
||||
try { return initializeEcazOccupyGameForAudit(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Finish real post-reveal allocation and traitor opportunities. */
|
||||
function finishBattle(state: Game, owner: string, opponent: string): Game {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 80; attempt++) {
|
||||
if (game.response || game.phaseOpening) {
|
||||
game = nextOccupyRichese(game);
|
||||
continue;
|
||||
}
|
||||
if (game.decision?.kind === 'harassWithdraw') {
|
||||
const decision = game.decision;
|
||||
const allocation = viewGame(game, decision.player).battle?.harassAllocation;
|
||||
assert.ok(allocation);
|
||||
game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
|
||||
returns: defaultHarassWithdrawAllocation(allocation.context, allocation.dial, allocation.support) });
|
||||
continue;
|
||||
}
|
||||
const battle = viewGame(game, owner).battle;
|
||||
const voter = !game.decision && battle?.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
|
||||
if (!voter) return finishOccupyRichese(game);
|
||||
assert.ok(voter === owner || voter === opponent || game.players.some(player => player.id === voter));
|
||||
game = applyAction(game, voter, { type: 'traitorCall', call: false });
|
||||
}
|
||||
throw Error('Basic mixed Occupy card battle did not finish its native decisions.');
|
||||
}
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) {
|
||||
void test(`Basic mixed Richese marker with ${lead} leading resolves both independent battle cards`, () => {
|
||||
const source = ecazOccupyRicheseFixture({ initial: basicSetup(), advanced: false,
|
||||
allyFaction: 'richese', opponentFaction: 'guild', marker: 3,
|
||||
mixedRicheseForces: 2, richeseReserves: 8 });
|
||||
assert.equal(source.initial.advanced, false);
|
||||
assert.equal(source.initial.ecazTreachery, true);
|
||||
const owner = lead === 'ecaz' ? source.ecaz : source.richese;
|
||||
// Conserved staging: return dealt faces to their actual deck, then give
|
||||
// the lead the two original physical cards before choosing the battle lead.
|
||||
for (const seat of source.game.players) {
|
||||
source.game.deck.push(...seat.hand);
|
||||
seat.hand = [];
|
||||
}
|
||||
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_HARASS);
|
||||
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_REINFORCEMENTS);
|
||||
let game = openOccupyRichese(source, lead);
|
||||
const original = structuredClone(game);
|
||||
const offer = viewGame(game, owner).battle!;
|
||||
assert.equal(offer.harassWithdraw?.blocked, null);
|
||||
assert.equal(offer.reinforcements?.blocked, null);
|
||||
const profile = offer.ecazOccupy!.profile;
|
||||
assert.ok(profile);
|
||||
assert.equal(profile.fixedEcazDial, 3);
|
||||
const leader = occupyRicheseSeat(game, owner).leaders.filter(face => !face.dead && !face.usedAt)
|
||||
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
|
||||
const enemy = occupyRicheseSeat(game, source.opponent).leaders.filter(face => !face.dead && !face.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
assert.ok(leader && enemy);
|
||||
const marker = structuredClone(occupyRicheseSeat(game, source.richese).noField);
|
||||
game = applyAction(game, owner, { type: 'battlePlan', dial: 6, support: 0,
|
||||
leader: leader.id, weapon: NO_FIELD_REINFORCEMENTS, defense: NO_FIELD_HARASS });
|
||||
assert.deepEqual(occupyRicheseSeat(game, source.richese).noField, marker);
|
||||
assert.equal(occupyRicheseSeat(game, source.richese).reserves, 8);
|
||||
game = applyAction(game, source.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id });
|
||||
assert.equal(game.battle?.revealed, true);
|
||||
assert.equal(occupyRicheseSeat(game, source.richese).noField!.deployed, null);
|
||||
assert.equal(occupyRicheseSeat(game, source.richese).forces[source.location], 5);
|
||||
assert.equal(occupyRicheseSeat(game, source.richese).reserves, 5);
|
||||
game = finishBattle(game, owner, source.opponent);
|
||||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||
assert.equal(game.lastBattleContext?.winner, owner);
|
||||
const ecaz = occupyRicheseSeat(game, source.ecaz);
|
||||
const richese = occupyRicheseSeat(game, source.richese);
|
||||
const returned = 2;
|
||||
assert.equal(occupyRicheseSeat(game, owner).reserves,
|
||||
occupyRicheseSeat(original, owner).reserves - (lead === 'ally' ? 3 : 0) - 3 + returned);
|
||||
assert.equal(ecaz.tanks - occupyRicheseSeat(original, source.ecaz).tanks, 3 + (lead === 'ecaz' ? 3 : 0));
|
||||
assert.equal(richese.tanks - occupyRicheseSeat(original, source.richese).tanks, 3 + (lead === 'ally' ? 3 : 0));
|
||||
assert.equal(ecaz.forces[source.location] ?? 0, lead === 'ecaz' ? 0 : 2);
|
||||
assert.equal(richese.forces[source.location] ?? 0, lead === 'ally' ? 0 : 2);
|
||||
for (const card of [NO_FIELD_REINFORCEMENTS, NO_FIELD_HARASS])
|
||||
assert.equal(game.discard.filter(face => face.id === card).length, 1);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { kullDefaultEligible, kullDefaultProfile, type KullDefaultProfile } from '../game/kull-default-profile';
|
||||
import { nativeFactionTechProfile } from '../game/faction-module-profile';
|
||||
|
||||
const lobby: KullDefaultProfile = {
|
||||
status: 'lobby', advanced: false, expansions: ['choam', 'ix'],
|
||||
@@ -65,6 +66,24 @@ void test('public eligibility does not change when the host chooses an opt-out',
|
||||
assert.equal(kullDefaultEligible({ ...lobby, lobbyOptions: selectedOn }), true);
|
||||
});
|
||||
|
||||
void test('Basic Tech Kull has a narrow three-to-six-seat CHOAM and classic roster', () => {
|
||||
const tech = { ...lobby, players: [{ faction: 'choam' }, { faction: 'atreides' }, { faction: 'guild' }], techTokens: {} };
|
||||
assert.equal(kullDefaultEligible(tech), true);
|
||||
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true }), true);
|
||||
for (const variant of [
|
||||
{ players: tech.players.slice(0, 2) },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'ixians' }, { faction: 'guild' }] },
|
||||
{ expansions: ['ix', 'choam', 'ecaz'] },
|
||||
{ lobbyOptions: { ecazTreachery: true } },
|
||||
{ lobbyModules: { ecazTreachery: true } },
|
||||
{ homeworlds: {} }, { nexusCards: {} }, { leaderSkills: {} }, { advanced: true },
|
||||
]) assert.equal(kullDefaultEligible({ ...tech, ...variant }), false);
|
||||
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true, sandtrout: true }), false);
|
||||
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true, ecazTreachery: true }), false);
|
||||
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true, advanced: true }), false);
|
||||
assert.equal(nativeFactionTechProfile({ ...tech, kullPreview: true }, true), false);
|
||||
});
|
||||
|
||||
void test('profile reads only public faction and configuration data, never hidden hands or spice', () => {
|
||||
const player = { faction: 'choam', get hand() { throw Error('private hand read'); },
|
||||
get spice() { throw Error('private spice read'); } };
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
|
||||
import { defaultKullInventory } from './fixture-default-choam-kull';
|
||||
import { kullPurposeClean, nextKullPurposeStep } from './fixture-kull-purpose';
|
||||
|
||||
const player = (game: Game, id: string) => {
|
||||
const result = game.players.find(seat => seat.id === id);
|
||||
assert.ok(result);
|
||||
return result;
|
||||
};
|
||||
const held = (game: Game, id: string, card: string) =>
|
||||
player(game, id).hand.some(item => item.id === card);
|
||||
|
||||
/** Continue actual setup, first Storm, auctions and turns. Tech's public choice
|
||||
* takes priority; the existing native driver handles the remaining decisions. */
|
||||
function advance(game: Game, until: (game: Game) => boolean) {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const decision = game.decision;
|
||||
const step = !game.phaseOpening && !game.response && decision?.kind === 'techToken'
|
||||
? { actor: decision.player, action: { type: 'decision' as const, token: decision.choices[0] } }
|
||||
: nextKullPurposeStep(game);
|
||||
assert.ok(step, 'Native setup or turn requires a legal next action');
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
throw new Error('Native Tech/Kull programme did not reach its boundary');
|
||||
}
|
||||
|
||||
/** Three-seat, public Basic setup and native actions throughout. Only unopened
|
||||
* starting-deck order and one safe unopened Spice card are selected. No played
|
||||
* game hands, wallets, Tech ownership, phases or turn order are staged. */
|
||||
function fixture() {
|
||||
let game = createGame('KULLTECH', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
|
||||
for (const faction of ['emperor', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
game = applyAction(game, 'choam', { type: 'techTokens', enabled: true });
|
||||
assert.equal(viewGame(game, 'choam').lobbyModules?.kullDefault, true);
|
||||
assert.equal(viewGame(game, 'choam').lobbyModules?.kullPreview, true);
|
||||
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
|
||||
game = applyAction(game, 'choam', { type: 'start' });
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.kullPreview, true);
|
||||
assert.ok(game.techTokens);
|
||||
assert.ok(game.players.every(seat => seat.hand.length === 0));
|
||||
const cards = defaultKullInventory(game);
|
||||
const selected: Game['deck'] = [];
|
||||
for (const kind of ['kull', 'karama', 'karama']) {
|
||||
const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
selected.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
game.deck.unshift(...selected);
|
||||
const [kull, karama, counter] = selected.map(card => card.id);
|
||||
game = advance(game, state => state.status === 'playing');
|
||||
assert.ok(held(game, 'choam', kull));
|
||||
assert.ok(held(game, 'emperor', karama));
|
||||
assert.ok(held(game, 'guild', counter));
|
||||
const safe = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
||||
assert.ok(safe >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(safe, 1)[0]);
|
||||
game = advance(game, state => state.phase === 5 && state.active === 'emperor' && kullPurposeClean(state));
|
||||
const owners = TECH_TOKENS.map(token => game.techTokens![token.id].owner);
|
||||
assert.equal(new Set(owners).size, 3, 'The real first Storm assigned all three physical Tech tokens');
|
||||
assert.ok(owners.every(id => game.players.some(seat => seat.id === id)));
|
||||
assert.deepEqual(defaultKullInventory(game), cards);
|
||||
return { game, cards, kull, karama, counter, tech: structuredClone(game.techTokens!) };
|
||||
}
|
||||
|
||||
type Fixture = ReturnType<typeof fixture>;
|
||||
function offer(f: Fixture) {
|
||||
const game = applyAction(f.game, 'emperor', {
|
||||
type: 'card', mode: 'shipment', card: f.karama, target: 'emperor',
|
||||
});
|
||||
assert.equal(game.pendingKull?.stage, 'offer');
|
||||
assert.ok(held(game, 'emperor', f.karama));
|
||||
assert.deepEqual(game.techTokens, f.tech);
|
||||
const reaction = viewGame(game, 'choam').kullReaction;
|
||||
assert.ok(reaction?.plays.some(play => play.source === 'printed' && play.card.id === f.kull && !play.blocked));
|
||||
return game;
|
||||
}
|
||||
function declare(f: Fixture, game: Game) {
|
||||
const reaction = viewGame(game, 'choam').kullReaction;
|
||||
assert.ok(reaction);
|
||||
return applyAction(game, 'choam', { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
|
||||
}
|
||||
function shipAndCheckTech(game: Game, f: Fixture) {
|
||||
const before = player(game, 'emperor');
|
||||
const reserves = before.reserves;
|
||||
const owner = game.techTokens!.heighliners.owner!;
|
||||
const existing = game.techTokens!.heighliners.triggeredTurn === game.turn;
|
||||
const expectedSpice = existing ? game.techTokens!.heighliners.spice : ownedTech(game.techTokens, owner).length;
|
||||
game = applyAction(game, 'emperor', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 });
|
||||
game = advance(game, state => kullPurposeClean(state));
|
||||
assert.equal(player(game, 'emperor').reserves, reserves - 1);
|
||||
assert.equal(game.techTokens!.heighliners.owner, owner);
|
||||
assert.equal(game.techTokens!.heighliners.spice, expectedSpice);
|
||||
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
|
||||
assert.deepEqual(defaultKullInventory(game), f.cards);
|
||||
}
|
||||
|
||||
void test('fresh Basic Tech selection keeps the default Kull offer and four legal bot responses', () => {
|
||||
const f = fixture(), pending = offer(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, 'choam');
|
||||
view.players.find(seat => seat.id === 'choam')!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, difficulty);
|
||||
assert.equal(action.type, 'kullDecision');
|
||||
assert.doesNotThrow(() => applyAction(pending, 'choam', action));
|
||||
}
|
||||
});
|
||||
|
||||
void test('the host can opt out of default Kull in a fresh Tech lobby', () => {
|
||||
let game = createGame('KULLTECHOFF', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
|
||||
for (const faction of ['emperor', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
game = applyAction(game, 'choam', { type: 'techTokens', enabled: true });
|
||||
game = applyAction(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: false });
|
||||
assert.equal(viewGame(game, 'choam').lobbyModules?.kullPreview, false);
|
||||
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
|
||||
game = applyAction(game, 'choam', { type: 'start' });
|
||||
assert.equal(game.kullPreview ?? false, false);
|
||||
assert.ok(game.techTokens);
|
||||
});
|
||||
|
||||
void test('declining Kull resumes the original Karama and an actual Tech-bearing shipment', () => {
|
||||
const f = fixture(), pending = offer(f);
|
||||
let game = applyAction(pending, 'choam', { type: 'kullDecision', event: pending.pendingKull!.event, decline: true });
|
||||
game = advance(game, kullPurposeClean);
|
||||
assert.equal(game.karamaShipping?.owner, 'emperor');
|
||||
assert.ok(held(game, 'choam', f.kull));
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.deepEqual(game.techTokens, f.tech);
|
||||
shipAndCheckTech(game, f);
|
||||
});
|
||||
|
||||
void test('successful Kull retains the attempted Karama and an ordinary shipment still earns Tech income', () => {
|
||||
const f = fixture();
|
||||
const game = advance(declare(f, offer(f)), kullPurposeClean);
|
||||
assert.equal(game.karamaShipping, null);
|
||||
assert.ok(held(game, 'emperor', f.karama));
|
||||
assert.equal(game.discard.filter(card => card.id === f.kull).length, 1);
|
||||
assert.ok(viewGame(game, 'emperor').karamaBlocked);
|
||||
assert.deepEqual(game.techTokens, f.tech);
|
||||
shipAndCheckTech(game, f);
|
||||
});
|
||||
|
||||
void test('a distinct Karama counters Kull without replacing Tech custody or the original shipment', () => {
|
||||
const f = fixture(), pending = declare(f, offer(f));
|
||||
assert.ok(viewGame(pending, 'guild').responseControls?.cancelCards.includes(f.counter));
|
||||
let game = applyAction(pending, 'guild', { type: 'card', mode: 'cancel', card: f.counter });
|
||||
game = advance(game, kullPurposeClean);
|
||||
assert.equal(game.karamaShipping?.owner, 'emperor');
|
||||
assert.ok(held(game, 'choam', f.kull));
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.counter).length, 1);
|
||||
assert.deepEqual(game.techTokens, f.tech);
|
||||
shipAndCheckTech(game, f);
|
||||
});
|
||||
Reference in new issue
Block a user