Prototype Mentat/Bureaucrat score effects, automatic Sandmaster board spice, and Rihani inspection plus committed exchange. Preserve private views, physical custody, saved winner cleanup and captive/Suk sequencing. Remaining normal bands, missing skill families and combined modules remain gated. Validation: 45 focused tests; typecheck, lint, 4465 offline tests, production build and 43 HTTP tests passed. Independent rules/privacy/persistence review completed. New isolated browser setup exercised inspection, draw, pending refresh, exchange and completed refresh. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not trigger Rihani. Preserved all 393 opening rooms and original seat/recovery rows (427 rooms after QA/HTTP). SQLite integrity and foreign keys passed. Server remained healthy after the reported outage; no restart or reset. Verified source tree: c64ce9537269de94c0d785bc08b17346e826c5797352a636771e1dc6ce5e4859
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
|
|
import {
|
|
leaderSkillBattleBonus,
|
|
type BattleLeaderSkill,
|
|
type DirectLeaderSkillId,
|
|
} from '../game/leader-skill-combat';
|
|
import {
|
|
quoteBattleResolution,
|
|
type BattleResolutionInput,
|
|
type ResolutionCombatant,
|
|
} from '../game/battle-resolution-quote';
|
|
|
|
const catalog = () => [...baseDeck(), ...ixBattleCards(), ...richeseCards()];
|
|
function one(predicate: (card: Card) => boolean) {
|
|
const card = catalog().find(predicate);
|
|
assert.ok(card);
|
|
return card;
|
|
}
|
|
const selected = { id: 'selected-disc', kind: 'disc' as const };
|
|
const assignment = (
|
|
skill: DirectLeaderSkillId,
|
|
leader = selected.id,
|
|
faceUp = false,
|
|
captured = false,
|
|
): BattleLeaderSkill => ({ skill, leader, faceUp, captured });
|
|
|
|
void test('all five direct families use the physical selected card role for normal +1 or skilled +3 without +4', () => {
|
|
const cases: [DirectLeaderSkillId, Card | undefined, Card | undefined][] = [
|
|
['warmaster', one((card) => card.kind === 'worthless'), undefined],
|
|
[
|
|
'master-of-assassins',
|
|
one((card) => card.kind === 'chemistry'),
|
|
undefined,
|
|
],
|
|
[
|
|
'swordmaster-of-ginaz',
|
|
one((card) => card.kind === 'weirdingWay'),
|
|
undefined,
|
|
],
|
|
['killer-medic', undefined, one((card) => card.kind === 'chemistry')],
|
|
[
|
|
'prana-bindu-adept',
|
|
undefined,
|
|
one((card) => card.kind === 'weirdingWay'),
|
|
],
|
|
];
|
|
for (const [skill, weapon, defense] of cases) {
|
|
assert.deepEqual(
|
|
leaderSkillBattleBonus({
|
|
assignments: [assignment(skill)],
|
|
selectedLeader: selected,
|
|
weapon,
|
|
defense,
|
|
skilledLeaderSurvives: true,
|
|
}),
|
|
{ bonus: 3, applied: [{ skill, amount: 3, mode: 'skilled' }] },
|
|
);
|
|
assert.deepEqual(
|
|
leaderSkillBattleBonus({
|
|
assignments: [assignment(skill, 'trainer', true)],
|
|
selectedLeader: selected,
|
|
weapon,
|
|
defense,
|
|
skilledLeaderSurvives: true,
|
|
}),
|
|
{ bonus: 1, applied: [{ skill, amount: 1, mode: 'normal' }] },
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('dead, bluffed, face-up selected and captured assignments preserve their distinct effect bands', () => {
|
|
const weapon = one((card) => card.kind === 'projectile');
|
|
const quote = (skill: BattleLeaderSkill, survives = true) =>
|
|
leaderSkillBattleBonus({
|
|
assignments: [skill],
|
|
selectedLeader: selected,
|
|
weapon,
|
|
defense: undefined,
|
|
skilledLeaderSurvives: survives,
|
|
});
|
|
assert.equal(quote(assignment('swordmaster-of-ginaz'), false).bonus, 0);
|
|
assert.equal(
|
|
quote(assignment('swordmaster-of-ginaz', 'trainer', false)).bonus,
|
|
0,
|
|
'A concealed bluff supplies neither effect band.',
|
|
);
|
|
assert.equal(
|
|
quote(assignment('swordmaster-of-ginaz', selected.id, true)).bonus,
|
|
0,
|
|
'A still-public skilled disc cannot receive its lower battle effect.',
|
|
);
|
|
assert.equal(
|
|
quote(assignment('swordmaster-of-ginaz', 'trainer', true, true)).bonus,
|
|
0,
|
|
'A captured assignment cannot train another leader.',
|
|
);
|
|
assert.equal(
|
|
quote(assignment('swordmaster-of-ginaz', selected.id, false, true)).bonus,
|
|
3,
|
|
'A surviving captured skilled leader retains only its lower effect.',
|
|
);
|
|
assert.equal(
|
|
leaderSkillBattleBonus({
|
|
assignments: [assignment('swordmaster-of-ginaz', 'trainer', true)],
|
|
selectedLeader: { id: 'cheap-hero', kind: 'hero' },
|
|
weapon,
|
|
defense: undefined,
|
|
skilledLeaderSurvives: true,
|
|
}).bonus,
|
|
1,
|
|
'Cheap Hero substitutes for another leader for the public normal band.',
|
|
);
|
|
assert.equal(
|
|
leaderSkillBattleBonus({
|
|
assignments: [assignment('swordmaster-of-ginaz', 'trainer', true)],
|
|
selectedLeader: undefined,
|
|
weapon,
|
|
defense: undefined,
|
|
skilledLeaderSurvives: true,
|
|
}).bonus,
|
|
0,
|
|
);
|
|
});
|
|
|
|
function battle(): BattleResolutionInput {
|
|
const side = (
|
|
id: string,
|
|
faction: 'emperor' | 'guild',
|
|
strength: number,
|
|
): ResolutionCombatant => {
|
|
const leader = { ...leaders(faction)[0], strength };
|
|
return {
|
|
id,
|
|
faction,
|
|
spice: 10,
|
|
hand: [],
|
|
leader,
|
|
plan: {
|
|
dial: 2,
|
|
support: 0,
|
|
leader: leader.id,
|
|
weapon: null,
|
|
defense: null,
|
|
},
|
|
forces: {
|
|
normal: 5,
|
|
elite: 0,
|
|
eliteStrength: 2,
|
|
freeSupport: false,
|
|
},
|
|
};
|
|
};
|
|
return {
|
|
advanced: false,
|
|
turn: 2,
|
|
territory: 'arrakeen',
|
|
attacker: side('a', 'emperor', 3),
|
|
defender: side('d', 'guild', 2),
|
|
voters: [
|
|
{ id: 'a', beneficiary: 'a', called: false, traitors: [] },
|
|
{ id: 'd', beneficiary: 'd', called: false, traitors: [] },
|
|
],
|
|
participants: [
|
|
{ id: 'a', faction: 'emperor' },
|
|
{ id: 'd', faction: 'guild' },
|
|
],
|
|
physicalCards: catalog(),
|
|
pendingAuditorPresent: false,
|
|
pendingRetentionPresent: false,
|
|
};
|
|
}
|
|
function play(
|
|
input: BattleResolutionInput,
|
|
side: 'attacker' | 'defender',
|
|
slot: 'leader' | 'weapon' | 'defense' | 'lateDefense',
|
|
predicate: (card: Card) => boolean,
|
|
) {
|
|
const card = one(predicate);
|
|
input[side].hand = [...input[side].hand, card];
|
|
if (slot === 'lateDefense') input[side].lateDefense = card.id;
|
|
else input[side].plan[slot] = card.id;
|
|
if (slot === 'leader') delete input[side].leader;
|
|
return card;
|
|
}
|
|
|
|
void test('Mentat and Bureaucrat change the winner at the score boundary without changing printed strength', () => {
|
|
const mentat = battle();
|
|
assert.equal(quoteBattleResolution(mentat).winner, 'a');
|
|
mentat.defender.leaderSkills = [{ skill: 'mentat', leader: mentat.defender.leader!.id, faceUp: false, captured: false }];
|
|
const boosted = quoteBattleResolution(mentat);
|
|
assert.equal(boosted.winner, 'd');
|
|
assert.deepEqual(boosted.scores, { attacker: 5, defender: 6 });
|
|
assert.deepEqual(boosted.leaderStrengths, { attacker: 3, defender: 2 });
|
|
const bureau = battle();
|
|
bureau.defender.leaderSkills = [{ skill: 'bureaucrat', leader: bureau.defender.leader!.id, faceUp: false, captured: false }];
|
|
bureau.attacker.occupiedStrongholds = 2;
|
|
const penalized = quoteBattleResolution(bureau);
|
|
assert.equal(penalized.winner, 'd');
|
|
assert.deepEqual(penalized.scores, { attacker: 3, defender: 4 });
|
|
assert.deepEqual(penalized.leaderStrengths, { attacker: 3, defender: 2 });
|
|
});
|
|
|
|
void test('battle quote adds only surviving skill receipts to scores while preserving printed strength and bounty', () => {
|
|
const input = battle();
|
|
const worthless = play(
|
|
input,
|
|
'attacker',
|
|
'weapon',
|
|
(card) => card.kind === 'worthless',
|
|
);
|
|
input.attacker.leaderSkills = [
|
|
assignment('warmaster', input.attacker.leader!.id),
|
|
];
|
|
const before = structuredClone(input);
|
|
const quote = quoteBattleResolution(input);
|
|
assert.deepEqual(quote.leaderStrengths, { attacker: 3, defender: 2 });
|
|
assert.deepEqual(quote.leaderSkillBonuses.attacker, {
|
|
bonus: 3,
|
|
applied: [{ skill: 'warmaster', amount: 3, mode: 'skilled' }],
|
|
});
|
|
assert.deepEqual(quote.scores, { attacker: 8, defender: 4 });
|
|
assert.deepEqual(input, before);
|
|
|
|
const killed = structuredClone(input);
|
|
play(killed, 'defender', 'weapon', (card) => card.kind === 'poison');
|
|
const resolved = quoteBattleResolution(killed);
|
|
assert.equal(resolved.winner, 'd');
|
|
assert.equal(resolved.leaderSkillBonuses.attacker.bonus, 0);
|
|
assert.equal(resolved.scores!.attacker, 2);
|
|
assert.equal(resolved.bounty!.amount, 3);
|
|
assert.equal(killed.attacker.plan.weapon, worthless.id);
|
|
});
|
|
|
|
void test('a supplemental physical defense activates its matching surviving skill role', () => {
|
|
const input = battle();
|
|
const snooper = play(
|
|
input,
|
|
'attacker',
|
|
'lateDefense',
|
|
(card) => card.kind === 'snooper',
|
|
);
|
|
input.attacker.leaderSkills = [
|
|
assignment('killer-medic', input.attacker.leader!.id),
|
|
];
|
|
const quote = quoteBattleResolution(input);
|
|
assert.equal(input.attacker.plan.defense, null);
|
|
assert.equal(input.attacker.lateDefense, snooper.id);
|
|
assert.deepEqual(quote.leaderSkillBonuses.attacker, {
|
|
bonus: 3,
|
|
applied: [{ skill: 'killer-medic', amount: 3, mode: 'skilled' }],
|
|
});
|
|
assert.deepEqual(quote.scores, { attacker: 8, defender: 4 });
|
|
});
|
|
|
|
void test('normal training follows a Cheap Hero substitution but traitor precedence suppresses skill scoring', () => {
|
|
const input = battle();
|
|
const hero = play(
|
|
input,
|
|
'attacker',
|
|
'leader',
|
|
(card) => card.kind === 'hero',
|
|
);
|
|
play(input, 'attacker', 'weapon', (card) => card.kind === 'projectile');
|
|
input.attacker.leaderSkills = [
|
|
assignment('swordmaster-of-ginaz', 'public-trainer', true),
|
|
];
|
|
let quote = quoteBattleResolution(input);
|
|
assert.equal(quote.leaderStrengths.attacker, 0);
|
|
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 1);
|
|
assert.equal(quote.scores!.attacker, 3);
|
|
|
|
input.voters = [
|
|
input.voters[0],
|
|
{
|
|
id: 'd',
|
|
beneficiary: 'd',
|
|
called: true,
|
|
traitors: [CHEAP_HERO_TRAITOR],
|
|
},
|
|
];
|
|
quote = quoteBattleResolution(input);
|
|
assert.equal(quote.result, 'traitor');
|
|
assert.equal(quote.winner, 'd');
|
|
assert.equal(quote.scores, null);
|
|
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 0);
|
|
assert.equal(quote.bounty!.amount, 0);
|
|
assert.equal(hero.kind, 'hero');
|
|
});
|
|
|
|
void test('Stone Burner comparison remains independent of a surviving skilled bonus', () => {
|
|
const input = battle();
|
|
input.attacker.forces.freeSupport = true;
|
|
input.defender.forces.freeSupport = true;
|
|
input.attacker.plan.dial = 4;
|
|
input.defender.plan.dial = 1;
|
|
play(input, 'attacker', 'weapon', (card) => card.effect === 'stoneBurner');
|
|
play(input, 'attacker', 'defense', (card) => card.kind === 'snooper');
|
|
input.attacker.stoneMode = 'ignore';
|
|
input.attacker.leaderSkills = [
|
|
assignment('killer-medic', input.attacker.leader!.id),
|
|
];
|
|
const quote = quoteBattleResolution(input);
|
|
assert.equal(quote.result, 'normal');
|
|
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 3);
|
|
assert.deepEqual(quote.scores, { attacker: 10, defender: 3 });
|
|
assert.equal(quote.stone?.winner, 'defender');
|
|
assert.equal(quote.winner, 'd');
|
|
});
|