Resolve advisor conversion after battle losses before optional cleanup
Preserve committed casualties, payments and mandatory disposal when battle losses leave BG bystanders alone. Bind the public phase-six receipt to the original battle and resume its private winner/aftermath cursor once. Record provisional priority; explosions with no surviving advisors create no response. Repair stale Semuta fixtures to acknowledge the existing final battle window; a representative failure reproduces against unchanged base035be51. Production Semuta guards stay intact. Validation: 16 affected files264/264, types/lint/build, five native battle and GameTable SSR journeys, eight existing-profile response paths. Genuine44-action sources earn original cards/forces and actual Collection outcomes. Broader combinations and complete modes remain unfinished. Not deployed; no room/D1, reset or restart operation. Runtime fingerprint 665defe51a1f46a6b5992b6e1e4b034203f91e7101acf860eb549f5bf9865151; checkpoint prose/reference overlays are reviewed separately.
This commit is contained in:
1 parent
035be51915
commit
d83db397c0
17 files changed
+908
-26
No files matched your search
@@ -10,12 +10,14 @@ import {
|
||||
type AutomaticAdvisorFlipSource,
|
||||
type AutomaticAdvisorTransport,
|
||||
type AutomaticAdvisorGamont,
|
||||
type AutomaticAdvisorBattle,
|
||||
} from '../game/advisor-automatic-flip';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
|
||||
|
||||
type GroundSource = Extract<AutomaticAdvisorFlipSource, { producer: 'groundMovement' }>;
|
||||
type TransportSource = Extract<AutomaticAdvisorFlipSource, { producer: 'guildTransport' }>;
|
||||
type GamontSource = Extract<AutomaticAdvisorFlipSource, { producer: 'gamont' }>;
|
||||
type BattleSource = Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosses' }>;
|
||||
|
||||
function context(): AutomaticAdvisorFlipContext {
|
||||
return {
|
||||
@@ -66,7 +68,7 @@ void test('quotes exclude held, occupied and physically empty advisor groups whi
|
||||
ctx.players[1].forces = { ...ctx.players[1].forces, 'arrakeen:10': 1 };
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
|
||||
for (const patch of [
|
||||
{ advanced: false }, { status: 'lobby' }, { phase: 0 }, { phase: 4 }, { phase: 6 },
|
||||
{ advanced: false }, { status: 'lobby' }, { phase: 0 }, { phase: 4 }, { phase: 7 },
|
||||
]) {
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []);
|
||||
}
|
||||
@@ -482,3 +484,151 @@ void test('post-Gamont validation does not ask for prior availability, a remaini
|
||||
assert.deepEqual(ctx.players[2].forces, {});
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
|
||||
});
|
||||
|
||||
function battleResult(result: AutomaticAdvisorBattle['result'] = 'normal') {
|
||||
const { ctx, source: ground } = held();
|
||||
ctx.phase = 6;
|
||||
ctx.battle = null;
|
||||
ctx.players[1].forces = {};
|
||||
ctx.players[2].forces = {};
|
||||
const receipt: AutomaticAdvisorBattle = {
|
||||
event: 'original-resolved-battle', turn: ctx.turn, territory: 'imperial_basin',
|
||||
combatants: ['a', 'r'], winner: result === 'normal' || result === 'traitor' ? 'a' : null, result,
|
||||
};
|
||||
ctx.lastBattleContext = { ...receipt, combatants: [...receipt.combatants] };
|
||||
const source: BattleSource = {
|
||||
event: 'automatic-after-original-battle-losses', owner: ground.owner,
|
||||
territory: ground.territory, turn: ground.turn, phase: 6,
|
||||
forces: ground.forces, producer: 'battleLosses', battle: receipt,
|
||||
};
|
||||
ctx.players[0].advisors = {
|
||||
...ctx.players[0].advisors,
|
||||
imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event },
|
||||
};
|
||||
return { ctx, source };
|
||||
}
|
||||
|
||||
void test('phase-six quotes and exact public resolved battle receipts retain a physically nonempty BG bystander group', () => {
|
||||
const quiet = context();
|
||||
quiet.phase = 6;
|
||||
const original = structuredClone(quiet);
|
||||
assert.equal(quoteAutomaticAdvisorFlips(quiet).some((entry) => entry.territory === 'imperial_basin'), true);
|
||||
assert.deepEqual(quiet, original);
|
||||
for (const result of ['normal', 'traitor', 'mutualTraitors'] as const) {
|
||||
const { ctx, source } = battleResult(result);
|
||||
const before = structuredClone({ ctx, source });
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
|
||||
assert.deepEqual({ ctx, source }, before);
|
||||
assert.deepEqual(Object.keys(source).sort(), ['battle', 'event', 'forces', 'owner', 'phase', 'producer', 'territory', 'turn']);
|
||||
assert.deepEqual(Object.keys(source.battle).sort(), ['combatants', 'event', 'result', 'territory', 'turn', 'winner']);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a stale battle event, turn, winner, participant, location or active battle cannot settle the held source', () => {
|
||||
const changes: ((ctx: AutomaticAdvisorFlipContext, source: BattleSource) => void)[] = [
|
||||
(ctx) => { ctx.advanced = false; },
|
||||
(ctx) => { ctx.status = 'lobby'; },
|
||||
(ctx) => { ctx.turn++; },
|
||||
(ctx) => { ctx.battle = {}; },
|
||||
(ctx) => { ctx.lastBattleContext = null; },
|
||||
(ctx) => { delete ctx.lastBattleContext; },
|
||||
(ctx) => { ctx.lastBattleContext!.event = 'changed-battle'; },
|
||||
(ctx) => { ctx.lastBattleContext!.turn++; },
|
||||
(ctx) => { ctx.lastBattleContext!.territory = 'arrakeen'; },
|
||||
(ctx) => { ctx.lastBattleContext!.winner = 'r'; },
|
||||
(ctx) => { ctx.lastBattleContext!.result = 'traitor'; },
|
||||
(_ctx, source) => { source.battle.event = 'stale-battle'; },
|
||||
(_ctx, source) => { source.battle.turn++; },
|
||||
(_ctx, source) => { source.battle.territory = 'arrakeen'; },
|
||||
(_ctx, source) => { source.battle.territory = 'missing'; },
|
||||
(_ctx, source) => { source.battle.combatants = ['a', 'a']; },
|
||||
(_ctx, source) => { source.battle.combatants = ['a', 'missing']; },
|
||||
(_ctx, source) => { source.battle.combatants.reverse(); },
|
||||
(_ctx, source) => { source.battle.winner = null; },
|
||||
(_ctx, source) => { source.battle.winner = 'b'; },
|
||||
(_ctx, source) => { source.battle.result = 'mutualTraitors'; },
|
||||
(_ctx, source) => { source.battle.result = 'explosion'; },
|
||||
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; },
|
||||
(ctx) => { ctx.players[1].forces = { 'imperial_basin:9': 1 }; },
|
||||
(_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const { ctx, source } = battleResult();
|
||||
change(ctx, source);
|
||||
const before = structuredClone({ ctx, source });
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
|
||||
assert.deepEqual({ ctx, source }, before);
|
||||
}
|
||||
const { ctx, source } = battleResult();
|
||||
for (const malformed of [
|
||||
{ ...source, battle: null },
|
||||
{ ...source, battle: { ...source.battle, result: 'legacy' } },
|
||||
{ ...source, battle: { ...source.battle, winner: 1 } },
|
||||
{ ...source, battle: { ...source.battle, combatants: ['a'] } },
|
||||
{ ...source, battle: { ...source.battle, combatants: ['a', 'r', 'b'] } },
|
||||
]) {
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a BG combatant or an explosion with no remaining physical advisor group has no valid bystander conversion', () => {
|
||||
const combatant = battleResult();
|
||||
combatant.source.battle.combatants = ['b', 'r'];
|
||||
combatant.source.battle.winner = 'r';
|
||||
combatant.ctx.lastBattleContext = { ...combatant.source.battle, combatants: ['b', 'r'] };
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(combatant.ctx, combatant.source), AutomaticAdvisorFlipError);
|
||||
const exploded = battleResult('explosion');
|
||||
assert.equal(exploded.source.battle.winner, null);
|
||||
for (const player of exploded.ctx.players) player.forces = {};
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(exploded.ctx), []);
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(exploded.ctx, exploded.source), AutomaticAdvisorFlipError);
|
||||
});
|
||||
|
||||
void test('battle source and receipt layers reject private cards, skills, casualty choices and continuation payloads', () => {
|
||||
const { ctx, source } = battleResult();
|
||||
for (const field of ['cards', 'skills', 'casualtyChoices', 'continuation']) {
|
||||
for (const malformed of [
|
||||
{ ...source, [field]: 'private-payload' },
|
||||
{ ...source, battle: { ...source.battle, [field]: 'private-payload' } },
|
||||
]) {
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('phase-six battle conversion does not widen the old movement, transport or Gamont producer windows', () => {
|
||||
for (const original of [held(), transported(), gamont(false)]) {
|
||||
const before = JSON.stringify(original.source);
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
|
||||
assert.equal(JSON.stringify(original.source), before);
|
||||
original.ctx.phase = 6;
|
||||
original.source.phase = 6;
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
|
||||
}
|
||||
for (const phase of [5, 8]) {
|
||||
const { ctx, source } = battleResult();
|
||||
ctx.phase = phase;
|
||||
source.phase = phase;
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('battle validation reads only its six public context fields and the current physical group', () => {
|
||||
const { ctx, source } = battleResult();
|
||||
for (const player of ctx.players) {
|
||||
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'moved', 'shipped', 'reserves']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Battle advisor rule read ${player.id} private or unrelated ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const field of ['cardRoles', 'mandatoryDiscard', 'winnerDiscards', 'skills', 'casualtyChoices', 'continuation']) {
|
||||
Object.defineProperty(ctx.lastBattleContext, field, {
|
||||
enumerable: true,
|
||||
get() { throw new Error(`Battle advisor rule read private context ${field}`); },
|
||||
});
|
||||
}
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
|
||||
ctx.battle = false;
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
|
||||
});
|
||||
Reference in new issue
Block a user