Files
Dune/tests/fixture-semuta-skills.ts
Quintine b2b9a024bd
Verify and publish checkpoint container / container (push) Canceled after 0s
Enable Semuta in public Richese Skills games
2026-10-29 09:27:53 +11:00

252 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
export type SemutaSkillsStep = { actor: string; action: Action };
export const reloadSemutaSkills = (game: Game): Game => JSON.parse(JSON.stringify(game));
export function semutaSkillsPlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player, `Missing original ${id} seat`);
return player;
}
/** Control only the original fourteen-card shuffle, not native setup lotteries.
* Safe companion skills keep this fixture focused on the normal Mentat consumer. */
function withOriginalOffers<T>(initialize: () => T): T {
const selected: LeaderSkillId[] = ['mentat', 'killer-medic', 'swordmaster-of-ginaz'];
const originalCards = LEADER_SKILL_CARDS.map(c => c.id);
const remaining = originalCards.filter(c => !selected.includes(c));
const desired = selected.flatMap(c => [c, remaining.shift()!]);
desired.push(...remaining);
const working = [...originalCards], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor(((j + 0.5) / (i + 1)) * 0x100000000));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (cursor < rolls.length) {
assert.ok(array instanceof Uint32Array && array.length === 1);
array[0] = rolls[cursor++];
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Fresh native Richese/CHOAM setup; no played save conversion or invented
* assignment. Every seat selects its actual two-card offer on an eligible disc. */
export function initializeSemutaSkillsGame(advanced = false, tech = false, strongholds = false): Game {
assert.ok(advanced || !strongholds, 'Original Stronghold Cards require Advanced rules');
let game = createGame('SEMUTASKILLS', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
if (tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = withOriginalOffers(() => initializeSemutaGameForAudit(game, true));
const selected: Record<string, LeaderSkillId> = {
r: 'mentat', a: 'killer-medic', e: 'swordmaster-of-ginaz',
};
for (let step = 0; game.status === 'setup' && step < 250; step++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor], own = viewGame(game, actor).leaderSkills!;
const eligible = new Set(own.eligibleLeaders.map(l => l.id));
const leader = semutaSkillsPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
const skill = selected[actor];
assert.ok(offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader,
'Actual original offer and living eligible leader must supply training');
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id });
} else {
const next = nextRicheseSkillsTechNativeStep(game);
game = applyAction(game, next.actor, next.action);
}
}
assert.equal(game.status, 'playing');
assertAdvancedNativeCustody(game);
return game;
}
/** Explicit physical staging AFTER original offers/training: exchange the
* requested card with the owner's existing card, retaining both exact slots
* and the original living trained leader. No assignment is added or removed. */
export function swapSemutaSkill(game: Game, owner: string, skill: LeaderSkillId): string {
const skills = game.leaderSkills!;
validateLeaderSkills(skills, game.players);
const assignment = skills.assignments.find(a => a.owner === owner);
assert.ok(assignment, 'Only an actually trained original owner may exchange a physical card');
const leader = semutaSkillsPlayer(game, owner).leaders.find(l => l.id === assignment.leader);
assert.ok(leader && !leader.dead && !leader.capturedBy && !leader.gholaBy);
if (assignment.skill === skill) return assignment.leader;
const previous = assignment.skill, donor = skills.assignments.find(a => a.skill === skill);
if (donor) donor.skill = previous;
else {
const offer = Object.values(skills.offers).find(o => o.cards.includes(skill));
if (offer) offer.cards[offer.cards.indexOf(skill)] = previous;
else {
const index = skills.deck.indexOf(skill);
assert.ok(index >= 0, 'Requested original physical skill must have a real custody slot');
skills.deck[index] = previous;
}
}
assignment.skill = skill;
validateLeaderSkills(skills, game.players);
return assignment.leader;
}
export function semutaSkillsInventory(game: Game): string[] {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
assert.equal(new Set(cards).size, cards.length, 'Each original Treachery Card has one physical slot');
return cards;
}
export function semutaSkillsPhysical(game: Game) {
return structuredClone({
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces,
reserves: p.reserves, tanks: p.tanks, elites: p.elites, leaders: p.leaders,
traitors: p.traitors, revealedTraitors: p.revealedTraitors })),
skills: game.leaderSkills, tech: game.techTokens, strongholds: game.strongholdCards,
});
}
export type SemutaSkillsMentatFixture = {
game: Game; owner: 'r'; target: 'e'; spender: 'a'; trainer: string;
semuta: string; hajr: string; weapon: string; fallback: string; territory: string; location: string;
};
function takeCard(game: Game, actor: string, identity: string): string {
const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(p => p.hand)];
for (const zone of zones) {
const index = zone.findIndex(c => c.id === identity || c.name === identity || c.effect === identity);
if (index < 0) continue;
const card = zone.splice(index, 1)[0];
semutaSkillsPlayer(game, actor).hand.push(card);
return card.id;
}
throw Error(`No original physical ${identity} card remains available`);
}
/** Genuine setup and original phase controls reach Movement. Only the conserved
* board/hand position is staged: native forces return to their own reserves,
* then five original counters per combatant and canonical cards change slots.
* Advanced also stages one conserved Atreides reserve counter at Polar Sink;
* its actual adjacent movement earns the immediate Hajr opportunity.
* Wallets, printed leaders, skill training, turn, phase and module custody stay real. */
export function createSemutaSkillsMentatFixture(advanced = false, tech = false, strongholds = false): SemutaSkillsMentatFixture {
let game = initializeSemutaSkillsGame(advanced, tech, strongholds);
for (let n = 0; n < 1500; n++) {
if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break;
assert.equal(game.turn, 1);
const next = nextRicheseSkillsTechNativeStep(game);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.phase, 5);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
// End preceding actual movement owners, leaving Atreides as the real Hajr owner.
for (let n = 0; game.active !== 'a' && n < 3; n++)
game = applyAction(game, game.active!, { type: 'endMovement' });
assert.equal(game.active, 'a');
const physical = semutaSkillsPhysical(game), cards = semutaSkillsInventory(game);
for (const player of game.players) {
game.deck.push(...player.hand); player.hand = [];
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
}
const battleTerritory = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm));
assert.ok(battleTerritory);
const location = `${battleTerritory.id}:${battleTerritory.sectors[0]}`;
for (const actor of ['r', 'e']) {
const player = semutaSkillsPlayer(game, actor);
assert.ok(player.reserves >= 5);
player.reserves -= 5; player.forces[location] = 5;
}
const semuta = takeCard(game, 'r', SEMUTA_DRUG_ID), hajr = takeCard(game, 'a', 'hajr');
const weapon = takeCard(game, 'e', 'Crysknife'), fallback = takeCard(game, 'e', 'Snooper');
if (advanced) {
const atreides = semutaSkillsPlayer(game, 'a');
assert.ok(atreides.reserves > 0);
atreides.reserves--;
atreides.forces['polar_sink:0'] = 1;
const own = viewGame(game, 'a'), seat = own.players.find(p => p.id === 'a')!;
const from = 'polar_sink:0';
const destination = TERRITORIES.filter(place => place.type === 'sand' &&
!game.players.some(p => Object.keys(p.forces).some(key => key.startsWith(`${place.id}:`))) &&
!Object.keys(game.spice).some(key => key.startsWith(`${place.id}:`) && game.spice[key] > 0))
.flatMap(place => place.sectors.map(sector => ({territory: place.id, sector})))
.find(place => groundMoveRouteDistance(own, seat, from, `${place.territory}:${place.sector}`) === 1 &&
botGroundMoveAllowed(own, seat, from, `${place.territory}:${place.sector}`, 0));
assert.ok(destination, 'The original staged force needs a clear adjacent movement.');
game = applyAction(game, 'a', {type:'move', from, ...destination, amount:1, elite:0});
for (let n=0; (game.phaseOpening || game.response || game.decision) && n<100; n++) {
const next = nextRicheseSkillsTechNativeStep(game);
game = applyAction(game, next.actor, next.action);
}
assert.equal(semutaSkillsPlayer(game, 'a').hajrWindow?.turn, game.turn);
assert.equal(semutaSkillsPlayer(game, 'a').hajrWindow?.move, semutaSkillsPlayer(game, 'a').moved);
}
assertAdvancedNativeCustody(game);
assert.deepEqual(semutaSkillsInventory(game), cards);
for (const player of game.players) {
const before = physical.players.find(p => p.id === player.id)!;
assert.equal(player.spice, before.spice);
assert.deepEqual(player.leaders, before.leaders);
}
assert.deepEqual(game.leaderSkills, physical.skills);
return { game, owner: 'r', target: 'e', spender: 'a',
trainer: game.leaderSkills!.assignments.find(a => a.owner === 'r')!.leader,
semuta, hajr, weapon, fallback, territory: battleTerritory.id, location };
}
export function semutaSkillsDiscardStep(fixture: SemutaSkillsMentatFixture): SemutaSkillsStep {
return { actor: fixture.spender, action: { type: 'card', card: fixture.hajr } };
}
export function reactSemutaSkillsDiscard(state: Game, claim: boolean): Game {
let game = state;
const pending = game.pendingTreacheryDiscard!;
assert.equal(pending.reaction?.stage, 'offer');
const event = pending.batch.event;
if (claim) return applyAction(game, 'r', { type: 'semutaCommit', event });
for (const player of game.players)
game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
/** The discarded Hajr continuation must return Atreides' Movement first.
* Native movement closings then choose the original Richese/Emperor battle. */
export function openSemutaSkillsMentat(state: Game, fixture: SemutaSkillsMentatFixture): Game {
let game = state;
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.active, fixture.spender);
assert.equal(game.phase, 5);
for (let n = 0; game.phase === 5 && n < 5; n++)
game = applyAction(game, game.active!, { type: 'endMovement' });
assert.equal(game.phase, 6);
for (let n = 0; game.phaseOpening && n < 3; n++) {
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!;
game = applyAction(game, actor.id, { type: 'ready' });
}
const actor = game.active!;
assert.ok(actor === fixture.owner || actor === fixture.target);
return applyAction(game, actor, { type: 'chooseBattle', territory: fixture.territory,
target: actor === fixture.owner ? fixture.target : fixture.owner });
}