Counter each changing origin in a resident BG gather
Hold both physical groups until all required matching responses allow the original movement. Share normal packet denials across gather and ordinary moves, then resume one original movement suffix.
This commit is contained in:
1 parent
b2a20226d7
commit
0d8481e821
16 files changed
+987
-10
No files matched your search
@@ -2503,6 +2503,14 @@ export function GameTable({
|
|||||||
{' '}Any already-played movement or declaration card keeps its original use. Choose a legal replacement if matching is prevented.
|
{' '}Any already-played movement or declaration card keeps its original use. Choose a legal replacement if matching is prevented.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{g.response.kind==='advisorFlip'&&g.response.advisorGatherMatchingSource&&(
|
||||||
|
<p className="notice">
|
||||||
|
Gathered group {g.response.advisorGatherMatchingPosition?.current} of {g.response.advisorGatherMatchingPosition?.total}:
|
||||||
|
{' '}the incoming forces from {territory(g.response.advisorGatherMatchingSource.origin).name} must match your residents.
|
||||||
|
{' '}Neither origin has moved, changed type or collected spice. Every required group response must allow the same original gather before it moves once.
|
||||||
|
{' '}Preventing one group leaves both origins and the movement unused, while this prevented packet here remains unavailable. Choose a legal replacement.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{g.response.kind==='advisorFlip'&&g.response.advisorTransportMatchingSource&&(
|
{g.response.kind==='advisorFlip'&&g.response.advisorTransportMatchingSource&&(
|
||||||
<p className="notice">
|
<p className="notice">
|
||||||
The incoming transport group must match your residents before payment or force transfer.
|
The incoming transport group must match your residents before payment or force transfer.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type PlanetologistMoveMode,
|
type PlanetologistMoveMode,
|
||||||
} from '@/game/planetologist-movement';
|
} from '@/game/planetologist-movement';
|
||||||
import { botGroundMoveAllowed } from '@/game/bot-mobility';
|
import { botGroundMoveAllowed } from '@/game/bot-mobility';
|
||||||
|
import { isAdvisor } from '@/game/advisors';
|
||||||
|
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from '@/game/advisor-gather-matching';
|
||||||
import {
|
import {
|
||||||
gameTerritories,
|
gameTerritories,
|
||||||
location,
|
location,
|
||||||
@@ -92,6 +94,12 @@ export function planetologistMoveDraft(
|
|||||||
? 'The selected route is beyond the boosted range or is blocked.'
|
? 'The selected route is beyond the boosted range or is blocked.'
|
||||||
: 'Each selected source must reach the destination using its normal range.',
|
: 'Each selected source must reach the destination using its normal range.',
|
||||||
};
|
};
|
||||||
|
if(mode==='gather'){
|
||||||
|
try{
|
||||||
|
quoteAdvisorGatherMatching(game,{owner:me.id,move:me.moved??0,destination,sector,
|
||||||
|
advisors:isAdvisor(me,destination),group:Object.fromEntries(selected)});
|
||||||
|
}catch(error){if(!(error instanceof AdvisorGatherMatchingError))throw error;return {action:null,blocked:error.message};}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
action: {
|
action: {
|
||||||
type: 'move',
|
type: 'move',
|
||||||
@@ -168,6 +176,9 @@ export function PlanetologistMovement({
|
|||||||
gather forces from exactly two territories into the selected
|
gather forces from exactly two territories into the selected
|
||||||
destination.
|
destination.
|
||||||
</p>
|
</p>
|
||||||
|
{game.advanced&&me.faction==='beneGesserit'&&mode==='gather'&&(
|
||||||
|
<p className="notice">When joining your residents, each incoming origin that changes type has its own matching response. Both groups wait for every required allowance. Prevention leaves the movement unused and keeps both groups in place.</p>
|
||||||
|
)}
|
||||||
<label htmlFor={`${id}-mode`}>Movement mode</label>
|
<label htmlFor={`${id}-mode`}>Movement mode</label>
|
||||||
<select
|
<select
|
||||||
id={`${id}-mode`}
|
id={`${id}-mode`}
|
||||||
|
|||||||
@@ -1,5 +1,73 @@
|
|||||||
# Matching incoming Bene Gesserit groups
|
# Matching incoming Bene Gesserit groups
|
||||||
|
|
||||||
|
## Planetologist gathering into residents — 24 October 2026
|
||||||
|
|
||||||
|
**Prototyped / Partial.** Supplied physical28
|
||||||
|
requires matching resident BG forces, while physical17 counters one physical
|
||||||
|
group's conversion. Partition an actual Planetologist gather by its two board
|
||||||
|
origins and reuse normal matching quotes for each origin whose stance differs
|
||||||
|
from the residents. One changing origin gets one response; two get two. Normal
|
||||||
|
public ten-field movement receipts and denial keys stay unchanged.
|
||||||
|
|
||||||
|
Neither source supplies a worked simultaneous two-origin ordering. Under the
|
||||||
|
standing recommended-policy authority, order changing origins by their stable
|
||||||
|
territory IDs and keep the whole gather physically atomic. A private parent
|
||||||
|
binds the original complete movement, exact receipt queue, current index and
|
||||||
|
approved events. Public progress identifies the current group without copying
|
||||||
|
the original private order. Allowing the first counter moves or flips nothing;
|
||||||
|
all required allowances precede the original CHOAM/movement/collection suffix.
|
||||||
|
Consume the batch before invoking that suffix once. Revalidate the actual
|
||||||
|
living Planetologist, both packets, routes and original destination throughout.
|
||||||
|
|
||||||
|
Preventing any group stops the whole declared gather before force, stance,
|
||||||
|
collection or movement use changes. Record only that origin packet's existing
|
||||||
|
normal matching denial for this destination and movement opportunity. Gathering
|
||||||
|
cannot bypass a prior normal denial, nor can an ordinary move bypass a gather
|
||||||
|
denial. A new response UUID, changed sector or reordered selection does not
|
||||||
|
create another permission. A legal replacement without the prevented packet
|
||||||
|
remains available. Do not import the separate enemy-only provisional flip stamp
|
||||||
|
or a turn-wide ban. Human drafts and minimal bots reuse the partition quote.
|
||||||
|
|
||||||
|
Basic, same-type arrivals and the existing no-resident enemy-only/empty policy
|
||||||
|
retain their original behavior. Existing recorded `planetologistGather`
|
||||||
|
responses keep their one-shot protocol; they are not converted into new queues.
|
||||||
|
This resident-matching addition does not settle wider empty-arrival sequencing
|
||||||
|
or certify complete Leader Skills/mode/module combinations.
|
||||||
|
|
||||||
|
[Native fixture](../tests/fixture-advisor-gather-matching.ts) earns original
|
||||||
|
public Advanced BG/Atreides/Emperor base33 and all14 Skills. The actual original
|
||||||
|
offer/assignment grants Planetologist; actual advisor setup retains Arrakeen
|
||||||
|
beside native Atreides10. Paid Basin2 and Oh Gap1 sources are both distance1
|
||||||
|
away without advisor ornithopters. A real Emperor bribe1 funds the six-spice
|
||||||
|
shipments; mixed sources additionally earn actual Emperor entry and Intrusion.
|
||||||
|
No played force, wallet, hand, phase, assignment, stance or response is installed.
|
||||||
|
The mixed source takes78 public actions; the double source takes73.
|
||||||
|
|
||||||
|
[Connected cases](../tests/advisor-gather-matching-engine.test.ts) and the frozen
|
||||||
|
batch pass **15 files274/274**, types/lint/build, seven native declaration/group-
|
||||||
|
response/replacement/GameTable SSR journeys and eight existing-profile paths
|
||||||
|
with sixteen accepted response/replacement actions. Both changing groups have
|
||||||
|
separate windows. First allowance changes only the private approval cursor;
|
||||||
|
both forces and original Karama remain. Second prevention leaves the whole
|
||||||
|
movement unused, including through a bounded JSON boundary. Same-packet retries
|
||||||
|
are blocked in both normal/gather directions; a genuine larger Basin packet
|
||||||
|
and ordinary/same-type replacement move once. The actual Skill inventory stays
|
||||||
|
intact. Tampered original signatures and unearned approval indices fail before
|
||||||
|
cancellation cost. This is not a saved-game recovery certificate.
|
||||||
|
|
||||||
|
Focused mixed allowance and second-group prevention pass. Fixture type feedback
|
||||||
|
makes the owned offer and before-action stage snapshot explicit; final runtime,
|
||||||
|
union, types/lint/build pass. Bounded review finds no concrete queue/callback
|
||||||
|
blocker. Private proof and HTML:
|
||||||
|
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-gather-matching/`.
|
||||||
|
`frozen-integrated-2/report.json` binds tree
|
||||||
|
`a80519bde0b171bf7d5b2325e58b2d6cab6d9173566e954811e2bcf6e47d0374`,
|
||||||
|
2770 files on base `b2a20226d7caa34a5c020052c1eb7f0ebafdd385`.
|
||||||
|
Final prose/reference overlays are separately reviewed. SSR is not callback/
|
||||||
|
hydration/browser acceptance. Wider empty sequencing, modules and complete-mode/
|
||||||
|
final assurance remain open. No AI refinement or saved-room/D1/server/reset/
|
||||||
|
restart/deployment operation. This is the user's requested stopping push.
|
||||||
|
|
||||||
## Guild and Junction transport — 24 October 2026
|
## Guild and Junction transport — 24 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** Compose supplied revised
|
**Prototyped / Partial.** Compose supplied revised
|
||||||
|
|||||||
@@ -16,6 +16,37 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**Planetologist resident matching — 24 October. Prototyped / Partial.**
|
||||||
|
Resident gathers now queue one normal-format matching response per changing
|
||||||
|
incoming origin. Both physical groups wait through partial allowance; any
|
||||||
|
prevention stops the whole declared gather without moving/flipping forces,
|
||||||
|
collecting spice or spending movement. The prevented origin packet shares its
|
||||||
|
normal denial key, closing normal/gather retries while keeping legal replacements.
|
||||||
|
Consume the private approval batch before one original movement suffix. Stable
|
||||||
|
queue order and atomic cancellation remain explicit provisional composition;
|
||||||
|
the existing enemy-only/empty policy and stored one-shot responses remain.
|
||||||
|
[Source, native flows and limits](ADVISOR_MATCHING.md#planetologist-gathering-into-residents--24-october-2026).
|
||||||
|
|
||||||
|
Disjoint helper/native-fixture lanes, bounded priority/source/integration review
|
||||||
|
and root integration pass **15 files274/274**, types/lint/build, seven native
|
||||||
|
declaration/group-response/replacement/GameTable SSR journeys and eight
|
||||||
|
existing-profile paths with sixteen accepted response/replacement actions.
|
||||||
|
Genuine78-action mixed and73-action double sources earn original base33/all14
|
||||||
|
Skills, actual offered/assigned Planetologist, original Karama, resident advisor,
|
||||||
|
paid Basin2/Oh Gap1, actual Emperor bribe1 and, for mixed, real Intrusion.
|
||||||
|
Both origins are distance1 from Arrakeen without advisor ornithopters. First
|
||||||
|
allowance leaves both groups untouched; second prevention retains the whole
|
||||||
|
unused movement. Different-packet and same-type/ordinary replacements complete
|
||||||
|
once; normal prevention cannot be bypassed by adding a gather origin. No played
|
||||||
|
state is installed. Fixture type feedback makes offer narrowing and stage
|
||||||
|
snapshots explicit; the final runtime/union/types/lint/build pass. Wider empty
|
||||||
|
sequencing, modules, complete modes and final browser/deployed assurance remain
|
||||||
|
open. No AI refinement or saved-room/D1/server/reset/restart/deployment operation.
|
||||||
|
|
||||||
|
**Requested stopping checkpoint:** the user requested stopping after the next
|
||||||
|
Git push. This verified gathering checkpoint is that push; the full objective
|
||||||
|
is unchanged and further goal work waits for the user to resume it.
|
||||||
|
|
||||||
**Guild/Junction incoming matching — 24 October. Prototyped / Partial.**
|
**Guild/Junction incoming matching — 24 October. Prototyped / Partial.**
|
||||||
Fresh native Guild and absent-Guild Secret Ally cross-transports plus Junction
|
Fresh native Guild and absent-Guild Secret Ally cross-transports plus Junction
|
||||||
board/World arrivals now offer incoming BG matching before original permission,
|
board/World arrivals now offer incoming BG matching before original permission,
|
||||||
|
|||||||
@@ -1,5 +1,48 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 24 October 2026 — One matching counter per changing resident-gather origin
|
||||||
|
|
||||||
|
Supplied physical28's resident matching and physical17's one-group counter now
|
||||||
|
compose the two-origin Planetologist move. Partition selected physical sources
|
||||||
|
and reuse normal quotes/receipt/denial formats. A changing origin gets one
|
||||||
|
counter, so two changing groups require two. Stable origin order and all-or-
|
||||||
|
nothing commitment are explicit provisional composition. A private parent binds
|
||||||
|
the original full movement, exact receipt queue, current index and approved
|
||||||
|
events. Partial allowance moves/flips/collects nothing. Prevention stops both
|
||||||
|
groups while recording only the prevented origin packet's normal scoped denial;
|
||||||
|
normal and gather declarations cannot bypass one another. Legal replacements
|
||||||
|
retain the movement. Consume the batch before one original CHOAM/movement suffix.
|
||||||
|
Basic, existing enemy-only/empty policy and recorded one-shot responses remain.
|
||||||
|
|
||||||
|
Disjoint helper/native-fixture lanes, bounded priority/source/integration review
|
||||||
|
and root integration pass **15 files274/274**, types/lint/build and seven native
|
||||||
|
declaration/group-response/replacement/GameTable SSR journeys, plus eight
|
||||||
|
existing-profile paths with sixteen accepted actions. Genuine78/73-action mixed/
|
||||||
|
double public Advanced base33/all14 sources earn original Planetologist offers
|
||||||
|
and assignment, original Karama, resident advisor, paid Basin2/Oh Gap1, actual
|
||||||
|
Emperor bribe1 and real mixed Intrusion. Both origins are distance1 without
|
||||||
|
advisor ornithopters. No played force, wallet, hand, phase, assignment, stance
|
||||||
|
or response is installed. First approval is physically inert; second prevention
|
||||||
|
keeps both sources and the unused movement. Larger-packet and normal/same-type
|
||||||
|
replacements complete once. Original Skill inventory and rejected-action state
|
||||||
|
remain intact. Tampered approval/signature evidence fails before cost.
|
||||||
|
|
||||||
|
Focused mixed allowance/second prevention and final union pass. Fixture type
|
||||||
|
feedback makes offer narrowing and the before-action stage snapshot explicit;
|
||||||
|
final runtime/types/lint/build pass. Bounded review finds no concrete blocker.
|
||||||
|
Private proof:
|
||||||
|
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-gather-matching/`.
|
||||||
|
`frozen-integrated-2/report.json` binds tree
|
||||||
|
`a80519bde0b171bf7d5b2325e58b2d6cab6d9173566e954811e2bcf6e47d0374`,
|
||||||
|
2770 files on base `b2a20226d7caa34a5c020052c1eb7f0ebafdd385`.
|
||||||
|
Final prose/reference overlays are separately reviewed. The bounded partial-
|
||||||
|
approval JSON boundary is not recovery certification; SSR is not callback/
|
||||||
|
hydration/browser acceptance. Wider empty sequencing, modules, complete modes
|
||||||
|
and final assurance remain open. No AI refinement or saved-room/D1/server/reset/
|
||||||
|
restart/deployment operation. The user requested stopping after this next push;
|
||||||
|
the full goal scope is unchanged.
|
||||||
|
[Source, atomic queue and evidence](ADVISOR_MATCHING.md#planetologist-gathering-into-residents--24-october-2026).
|
||||||
|
|
||||||
## 24 October 2026 — Fresh Guild, Junction and Secret Ally incoming BG matching
|
## 24 October 2026 — Fresh Guild, Junction and Secret Ally incoming BG matching
|
||||||
|
|
||||||
Incoming opposite-type BG packets now have a distinct precommit counter through
|
Incoming opposite-type BG packets now have a distinct precommit counter through
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Planetologist rule contract
|
# Planetologist rule contract
|
||||||
|
|
||||||
Updated 18 October 2026. This note audits the movement and battle effects of the
|
Updated 24 October 2026. This note audits the movement and battle effects of the
|
||||||
Planetologist Leader Skill against its physical card, the common Leader Skills
|
Planetologist Leader Skill against its physical card, the common Leader Skills
|
||||||
rules, the GF9 base battle rules and designer Jack Reda's walkthrough. The connected prototype is described below; other Leader Skill effects remain separate work.
|
rules, the GF9 base battle rules and designer Jack Reda's walkthrough. The connected prototype is described below; other Leader Skill effects remain separate work.
|
||||||
|
|
||||||
@@ -202,6 +202,15 @@ the prototype adopts an explicit **common-fighter, precommit flip** policy:
|
|||||||
compulsory common stance. Uniform origins and already-opened ordinary
|
compulsory common stance. Uniform origins and already-opened ordinary
|
||||||
flip windows keep their existing timing.
|
flip windows keep their existing timing.
|
||||||
|
|
||||||
|
The [24 October resident follow-up](ADVISOR_MATCHING.md#planetologist-gathering-into-residents--24-october-2026)
|
||||||
|
retains that final resident stance but adds one precommit matching counter per
|
||||||
|
incoming origin that changes type. Two changing origins require two responses.
|
||||||
|
Partial allowance leaves both origins untouched; prevention stops the whole
|
||||||
|
gather and shares the prevented origin packet's normal movement denial.
|
||||||
|
The original living trainer, complete movement and approved receipt queue stay
|
||||||
|
bound privately until the atomic movement can resume once. The earlier
|
||||||
|
enemy-only/empty policy and already-recorded one-shot responses remain separate.
|
||||||
|
|
||||||
This is visibly provisional product composition, not a retrieved combined
|
This is visibly provisional product composition, not a retrieved combined
|
||||||
FAQ or a separately answered numerical ruling. The shared quote drives the
|
FAQ or a separately answered numerical ruling. The shared quote drives the
|
||||||
engine, human gather control and legal bot candidates. The exact original
|
engine, human gather control and legal bot candidates. The exact original
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# Rule decision index
|
# Rule decision index
|
||||||
|
|
||||||
|
## Planetologist resident matching groups — 24 October 2026
|
||||||
|
|
||||||
|
Compose supplied physical28's resident-matching rule with physical17's counter
|
||||||
|
for one group. Partition the actual two-origin gather and offer one counter per
|
||||||
|
changing physical origin, using existing normal receipt/denial formats. Stable
|
||||||
|
origin order and all-or-nothing gather commitment are explicit provisional
|
||||||
|
composition: partial allowance moves/flips nothing, and prevention stops both
|
||||||
|
groups while recording only the prevented packet's normal scoped denial.
|
||||||
|
Retain the unused movement and legal replacement; normal/gather declarations
|
||||||
|
cannot bypass each other's prevented packet. Consume the private approval batch
|
||||||
|
before one original movement/collection suffix. Preserve Basic, the existing
|
||||||
|
enemy-only/empty policy and already-recorded legacy one-shot responses.
|
||||||
|
[Contract, sources and evidence](ADVISOR_MATCHING.md#planetologist-gathering-into-residents--24-october-2026).
|
||||||
|
|
||||||
## Guild/Junction incoming matching — 24 October 2026
|
## Guild/Junction incoming matching — 24 October 2026
|
||||||
|
|
||||||
Supplied physical28's marked shipping/moving clause and physical17's group
|
Supplied physical28's marked shipping/moving clause and physical17's group
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { gameTerritories, splitLocation } from './board';
|
||||||
|
import {
|
||||||
|
AdvisorMatchingError,
|
||||||
|
quoteAdvisorMatching,
|
||||||
|
type AdvisorMatchingContext,
|
||||||
|
type AdvisorMatchingQuote,
|
||||||
|
} from './advisor-matching';
|
||||||
|
|
||||||
|
export type AdvisorGatherMatchingIntent = {
|
||||||
|
owner: string;
|
||||||
|
move: number;
|
||||||
|
destination: string;
|
||||||
|
sector: number;
|
||||||
|
advisors: boolean;
|
||||||
|
group: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AdvisorGatherMatchingError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AdvisorGatherMatchingError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = (value: unknown): value is string =>
|
||||||
|
typeof value === 'string' && value.trim().length > 0;
|
||||||
|
const nonnegative = (value: unknown): value is number =>
|
||||||
|
Number.isSafeInteger(value) && (value as number) >= 0;
|
||||||
|
const positive = (value: unknown): value is number =>
|
||||||
|
Number.isSafeInteger(value) && (value as number) > 0;
|
||||||
|
const record = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
function requireMatching(value: unknown, message: string): asserts value {
|
||||||
|
if (!value) throw new AdvisorGatherMatchingError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resident gather changes each incoming origin independently. The original
|
||||||
|
* Planetologist quote retains trainer, route and complete-order permission;
|
||||||
|
* enemy-only and empty destinations retain their existing native policy. */
|
||||||
|
export function quoteAdvisorGatherMatching(
|
||||||
|
ctx: AdvisorMatchingContext,
|
||||||
|
intent: AdvisorGatherMatchingIntent,
|
||||||
|
): AdvisorMatchingQuote[] {
|
||||||
|
if (ctx.advanced !== true || ctx.status !== 'playing' || ctx.phase !== 5 || !positive(ctx.turn)) return [];
|
||||||
|
requireMatching(record(intent) && identifier(intent.owner), 'Choose the current BG gather owner.');
|
||||||
|
const owners = ctx.players.filter((player) => player.id === intent.owner);
|
||||||
|
const owner = owners[0];
|
||||||
|
if (owners.length !== 1 || owner.faction !== 'beneGesserit' || ctx.active !== owner.id) return [];
|
||||||
|
requireMatching(nonnegative(intent.move) && identifier(intent.destination) && nonnegative(intent.sector) &&
|
||||||
|
typeof intent.advisors === 'boolean',
|
||||||
|
'Choose the original gather movement and resident destination stance.');
|
||||||
|
const residents = Object.entries(owner.forces).some(([key, count]) =>
|
||||||
|
splitLocation(key).territory === intent.destination && count > 0);
|
||||||
|
if (!residents) return [];
|
||||||
|
const places = gameTerritories(ctx);
|
||||||
|
requireMatching(places.some((place) => place.id === intent.destination && place.sectors.includes(intent.sector)),
|
||||||
|
'Gather matching needs a current board destination sector.');
|
||||||
|
requireMatching(record(intent.group) && Object.keys(intent.group).length > 0,
|
||||||
|
'Select two nonempty physical board origins for gather matching.');
|
||||||
|
const origins = new Map<string, Record<string, number>>();
|
||||||
|
let total = 0;
|
||||||
|
// Sorting also gives every public origin packet a stable physical-key order.
|
||||||
|
for (const key of Object.keys(intent.group).sort()) {
|
||||||
|
const point = splitLocation(key);
|
||||||
|
requireMatching(key === `${point.territory}:${point.sector}` &&
|
||||||
|
places.some((place) => place.id === point.territory && place.sectors.includes(point.sector)),
|
||||||
|
'Gather matching needs physical counters from current canonical board sectors.');
|
||||||
|
const count = intent.group[key];
|
||||||
|
const held = owner.forces[key];
|
||||||
|
requireMatching(positive(count) && nonnegative(held) && held >= count,
|
||||||
|
'The selected gather packet is no longer held at its original board sources.');
|
||||||
|
total += count;
|
||||||
|
requireMatching(positive(total), 'The selected gather packet exceeds the supported physical count.');
|
||||||
|
const group = origins.get(point.territory) ?? {};
|
||||||
|
group[key] = count;
|
||||||
|
origins.set(point.territory, group);
|
||||||
|
}
|
||||||
|
requireMatching(origins.size === 2, 'Resident gather matching requires exactly two selected origin territories.');
|
||||||
|
const quotes: AdvisorMatchingQuote[] = [];
|
||||||
|
try {
|
||||||
|
for (const origin of [...origins.keys()].sort()) {
|
||||||
|
const quote = quoteAdvisorMatching(ctx, {
|
||||||
|
kind: 'movement', owner: owner.id, move: intent.move, origin,
|
||||||
|
destination: intent.destination, sector: intent.sector, advisors: intent.advisors,
|
||||||
|
group: origins.get(origin)!,
|
||||||
|
});
|
||||||
|
if (quote) quotes.push(quote);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AdvisorMatchingError) throw new AdvisorGatherMatchingError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return quotes;
|
||||||
|
}
|
||||||
+8
-1
@@ -119,6 +119,7 @@ import { fighterCount, isAdvisor } from './advisors';
|
|||||||
import { quoteAdvisorMatching, AdvisorMatchingError } from './advisor-matching';
|
import { quoteAdvisorMatching, AdvisorMatchingError } from './advisor-matching';
|
||||||
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from './advisor-ambassador-matching';
|
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from './advisor-ambassador-matching';
|
||||||
import { guildTransportQuote } from './transport-quote';
|
import { guildTransportQuote } from './transport-quote';
|
||||||
|
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching';
|
||||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||||
import { reinforcementsBattleForces } from './reinforcements-homeworld';
|
import { reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||||
import type { NativeReserveSelections } from './homeworld-native-reserves';
|
import type { NativeReserveSelections } from './homeworld-native-reserves';
|
||||||
@@ -4087,7 +4088,7 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
|||||||
|
|
||||||
/** Obligations apply across policy branches, including choosing to move first. */
|
/** Obligations apply across policy branches, including choosing to move first. */
|
||||||
function botAdvisorMatchingAllowed(g:GameView,action:Action) {
|
function botAdvisorMatchingAllowed(g:GameView,action:Action) {
|
||||||
if(!g.advisorMatchingDenied?.length || !['move','ship'].includes(action.type) || action.planetologist==='gather') return true;
|
if(!g.advisorMatchingDenied?.length || !['move','ship'].includes(action.type)) return true;
|
||||||
const me=g.players.find(p=>p.id===g.me)!;
|
const me=g.players.find(p=>p.id===g.me)!;
|
||||||
if(!g.advanced || me.faction!=='beneGesserit' || g.phase!==5 || g.active!==me.id) return true;
|
if(!g.advanced || me.faction!=='beneGesserit' || g.phase!==5 || g.active!==me.id) return true;
|
||||||
const destination=String(action.territory),sector=Number(action.sector);
|
const destination=String(action.territory),sector=Number(action.sector);
|
||||||
@@ -4095,6 +4096,12 @@ function botAdvisorMatchingAllowed(g:GameView,action:Action) {
|
|||||||
action.forces && typeof action.forces==='object' ? action.forces as Record<string,number> :
|
action.forces && typeof action.forces==='object' ? action.forces as Record<string,number> :
|
||||||
{[String(action.from)]:Number(action.amount)};
|
{[String(action.from)]:Number(action.amount)};
|
||||||
const origins=[...new Set(Object.keys(group).map(key=>splitLocation(key).territory))];
|
const origins=[...new Set(Object.keys(group).map(key=>splitLocation(key).territory))];
|
||||||
|
if(action.type==='move'&&action.planetologist==='gather'){
|
||||||
|
try{
|
||||||
|
quoteAdvisorGatherMatching(g,{owner:me.id,move:me.moved??0,destination,sector,
|
||||||
|
advisors:isAdvisor(me,destination),group});return true;
|
||||||
|
}catch(error){if(!(error instanceof AdvisorGatherMatchingError))throw error;return false;}
|
||||||
|
}
|
||||||
if(action.type==='move' && origins.length!==1) return true;
|
if(action.type==='move' && origins.length!==1) return true;
|
||||||
try {
|
try {
|
||||||
quoteAdvisorMatching(g,{kind:action.type==='ship'?'shipment':'movement',owner:me.id,
|
quoteAdvisorMatching(g,{kind:action.type==='ship'?'shipment':'movement',owner:me.id,
|
||||||
|
|||||||
+92
-3
@@ -612,6 +612,7 @@ import { quoteAdvisorMatching, validateAdvisorMatching, advisorMatchingKey,
|
|||||||
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
|
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
|
||||||
import { quoteAdvisorAmbassadorMatching, validateAdvisorAmbassadorMatching, advisorAmbassadorMatchingKey, AdvisorAmbassadorMatchingError, type AdvisorAmbassadorMatchingSource } from './advisor-ambassador-matching';
|
import { quoteAdvisorAmbassadorMatching, validateAdvisorAmbassadorMatching, advisorAmbassadorMatchingKey, AdvisorAmbassadorMatchingError, type AdvisorAmbassadorMatchingSource } from './advisor-ambassador-matching';
|
||||||
import { quoteAdvisorTransportMatching, validateAdvisorTransportMatching, advisorTransportMatchingKey, AdvisorTransportMatchingError, type AdvisorTransportMatchingSource } from './advisor-transport-matching';
|
import { quoteAdvisorTransportMatching, validateAdvisorTransportMatching, advisorTransportMatchingKey, AdvisorTransportMatchingError, type AdvisorTransportMatchingSource } from './advisor-transport-matching';
|
||||||
|
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching';
|
||||||
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival, validateSpiritualAdvisorSource,
|
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival, validateSpiritualAdvisorSource,
|
||||||
spiritualAdvisorMatchingKey, SpiritualAdvisorError, type SpiritualAdvisorSource } from './spiritual-advisor';
|
spiritualAdvisorMatchingKey, SpiritualAdvisorError, type SpiritualAdvisorSource } from './spiritual-advisor';
|
||||||
import {
|
import {
|
||||||
@@ -1076,6 +1077,8 @@ export type ResponseWindow = {
|
|||||||
advisorMatchingSource?: AdvisorMatchingSource;
|
advisorMatchingSource?: AdvisorMatchingSource;
|
||||||
advisorAmbassadorMatchingSource?: AdvisorAmbassadorMatchingSource;
|
advisorAmbassadorMatchingSource?: AdvisorAmbassadorMatchingSource;
|
||||||
advisorTransportMatchingSource?: AdvisorTransportMatchingSource;
|
advisorTransportMatchingSource?: AdvisorTransportMatchingSource;
|
||||||
|
advisorGatherMatchingSource?: AdvisorMatchingSource;
|
||||||
|
advisorGatherMatchingPosition?: {current:number;total:number};
|
||||||
spiritualAdvisorSource?: SpiritualAdvisorSource;
|
spiritualAdvisorSource?: SpiritualAdvisorSource;
|
||||||
fremenGrant?: FremenRevivalGrantSource;
|
fremenGrant?: FremenRevivalGrantSource;
|
||||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||||
@@ -1299,6 +1302,13 @@ export type Game = {
|
|||||||
intent: {kind:'movement'; order:MovementOrder} | {kind:'shipment'; order:PendingShipment};
|
intent: {kind:'movement'; order:MovementOrder} | {kind:'shipment'; order:PendingShipment};
|
||||||
};
|
};
|
||||||
advisorMatchingDenied?: string[];
|
advisorMatchingDenied?: string[];
|
||||||
|
pendingAdvisorGatherMatching?: {
|
||||||
|
order: MovementOrder;
|
||||||
|
receipts: AdvisorMatchingSource[];
|
||||||
|
signature: string;
|
||||||
|
index: number;
|
||||||
|
approved: string[];
|
||||||
|
};
|
||||||
pendingAdvisorAmbassadorMatching?: {
|
pendingAdvisorAmbassadorMatching?: {
|
||||||
receipt: AdvisorAmbassadorMatchingSource;
|
receipt: AdvisorAmbassadorMatchingSource;
|
||||||
order: MovementOrder;
|
order: MovementOrder;
|
||||||
@@ -14642,6 +14652,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function movementCancellationQuote(g: Game, response: ResponseWindow) {
|
function movementCancellationQuote(g: Game, response: ResponseWindow) {
|
||||||
|
if (response.advisorGatherMatchingSource) { currentAdvisorGatherMatching(g,response); return null; }
|
||||||
if (response.advisorTransportMatchingSource) { currentAdvisorTransportMatching(g,response); return null; }
|
if (response.advisorTransportMatchingSource) { currentAdvisorTransportMatching(g,response); return null; }
|
||||||
if (response.advisorAmbassadorMatchingSource) { currentAdvisorAmbassadorMatching(g,response); return null; }
|
if (response.advisorAmbassadorMatchingSource) { currentAdvisorAmbassadorMatching(g,response); return null; }
|
||||||
if (response.spiritualAdvisorSource) { currentSpiritualAdvisorResponse(g,response); return null; }
|
if (response.spiritualAdvisorSource) { currentSpiritualAdvisorResponse(g,response); return null; }
|
||||||
@@ -16604,6 +16615,7 @@ function groundMovementProbe(source: Game, actor: string): Game {
|
|||||||
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
|
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
|
||||||
game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null;
|
game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null;
|
||||||
delete game.pendingAdvisorTransportMatching;
|
delete game.pendingAdvisorTransportMatching;
|
||||||
|
delete game.pendingAdvisorGatherMatching;
|
||||||
game.pendingArrivalReaction = []; game.pendingExchange = null;
|
game.pendingArrivalReaction = []; game.pendingExchange = null;
|
||||||
if (game.pendingShipment?.player === game.active && recoverableUnsupportedShipment(game.pendingShipment)) {
|
if (game.pendingShipment?.player === game.active && recoverableUnsupportedShipment(game.pendingShipment)) {
|
||||||
const shipment = game.pendingShipment; game.pendingShipment = null;
|
const shipment = game.pendingShipment; game.pendingShipment = null;
|
||||||
@@ -17011,9 +17023,12 @@ function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = f
|
|||||||
);
|
);
|
||||||
validateFlightSelection(g, p, move);
|
validateFlightSelection(g, p, move);
|
||||||
const matching = quoteMatchingMovement(g, move);
|
const matching = quoteMatchingMovement(g, move);
|
||||||
|
const gatherMatching=quoteGatherMatching(g,move);
|
||||||
if (quoteOnly) return move;
|
if (quoteOnly) return move;
|
||||||
checkGroundMovementPromises(g, p, move);
|
checkGroundMovementPromises(g, p, move);
|
||||||
if (advisorGather) {
|
if(gatherMatching.length){
|
||||||
|
openAdvisorGatherMatching(g,move,gatherMatching);
|
||||||
|
} else if (advisorGather) {
|
||||||
g.response = {
|
g.response = {
|
||||||
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
|
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
|
||||||
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
|
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
|
||||||
@@ -20773,6 +20788,76 @@ function finishAdvisorAmbassadorMatching(g:Game,response:ResponseWindow,canceled
|
|||||||
{faction:'beneGesserit',name:'Ambassador group matching prevented'});
|
{faction:'beneGesserit',name:'Ambassador group matching prevented'});
|
||||||
}else offerChoamMovement(g,parent.order);
|
}else offerChoamMovement(g,parent.order);
|
||||||
}
|
}
|
||||||
|
function gatherMatchingRule<T>(quote:()=>T):T{
|
||||||
|
try{return quote();}catch(error){if(error instanceof AdvisorGatherMatchingError)throw new RuleError(error.message);throw error;}
|
||||||
|
}
|
||||||
|
function quoteGatherMatching(g:Game,move:MovementOrder){
|
||||||
|
if(move.planetologist?.mode!=='gather')return [];
|
||||||
|
return gatherMatchingRule(()=>quoteAdvisorGatherMatching(g,{owner:move.player,move:getPlayer(g,move.player).moved,
|
||||||
|
destination:move.to,sector:move.sector,advisors:move.advisors,group:Object.fromEntries(move.group)}));
|
||||||
|
}
|
||||||
|
function nextAdvisorGatherMatching(g:Game){
|
||||||
|
const parent=g.pendingAdvisorGatherMatching!,source=parent.receipts[parent.index];
|
||||||
|
g.response={kind:'advisorFlip',owner:source.owner,location:source.destination,advisors:source.advisors,
|
||||||
|
amount:Object.values(source.group).reduce((n,count)=>n+count,0),passed:[],advisorGatherMatchingSource:source,
|
||||||
|
advisorGatherMatchingPosition:{current:parent.index+1,total:parent.receipts.length}};
|
||||||
|
}
|
||||||
|
function openAdvisorGatherMatching(g:Game,order:MovementOrder,quotes:Omit<AdvisorMatchingSource,'event'>[]){
|
||||||
|
requireRule(!g.pendingAdvisorGatherMatching&&!g.response&&!g.decision&"es.length>0,
|
||||||
|
'Finish the original gathered matching before another movement.');
|
||||||
|
const receipts=quotes.map(quote=>({event:crypto.randomUUID(),...quote}));
|
||||||
|
g.pendingAdvisorGatherMatching={order,receipts,signature:JSON.stringify([order,receipts]),index:0,approved:[]};
|
||||||
|
nextAdvisorGatherMatching(g);
|
||||||
|
}
|
||||||
|
function currentAdvisorGatherMatching(g:Game,response:ResponseWindow){
|
||||||
|
const parent=g.pendingAdvisorGatherMatching,source=response.advisorGatherMatchingSource;
|
||||||
|
requireRule(parent&&source&&Number.isSafeInteger(parent.index)&&parent.index>=0&&parent.index<parent.receipts.length&&
|
||||||
|
parent.approved.length===parent.index&&parent.approved.every((event,index)=>event===parent.receipts[index].event)&&
|
||||||
|
parent.signature===JSON.stringify([parent.order,parent.receipts])&&parent.order.planetologist?.mode==='gather'&&
|
||||||
|
JSON.stringify(parent.receipts[parent.index])===JSON.stringify(source)&&response.kind==='advisorFlip'&&
|
||||||
|
response.owner===source.owner&&response.location===source.destination&&response.advisors===source.advisors&&
|
||||||
|
response.amount===Object.values(source.group).reduce((n,count)=>n+count,0)&&
|
||||||
|
response.advisorGatherMatchingPosition?.current===parent.index+1&&
|
||||||
|
response.advisorGatherMatchingPosition?.total===parent.receipts.length&&
|
||||||
|
!response.advisorResume&&!response.advisorMatchingSource&&!response.advisorAmbassadorMatchingSource&&
|
||||||
|
!response.advisorTransportMatchingSource&&!response.spiritualAdvisorSource&&!response.automaticAdvisorFlip&&
|
||||||
|
!response.advisorPreparationSource&&!response.advisorGatherMove,
|
||||||
|
'The gathered matching response lost its original two-origin declaration or approvals.');
|
||||||
|
matchingAdvisorRule(()=>validateAdvisorMatching(g,source));
|
||||||
|
validateMovementOrder(g,getPlayer(g,source.owner),parent.order);
|
||||||
|
const quotes=quoteGatherMatching(g,parent.order);
|
||||||
|
requireRule(JSON.stringify(quotes)===JSON.stringify(parent.receipts.map(receipt=>Object.fromEntries(
|
||||||
|
Object.entries(receipt).filter(([key])=>key!=='event')))),
|
||||||
|
'The gathered matching responses no longer describe their original selected groups.');
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
function advisorGatherMatchingIntegrity(g:Game){
|
||||||
|
if(!g.pendingAdvisorGatherMatching)return;
|
||||||
|
const continuation=g.pendingTreacheryDiscard?.continuation;
|
||||||
|
const controls=[g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,
|
||||||
|
g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume,g.pendingKull?.resume,
|
||||||
|
continuation&&'resume'in continuation?continuation.resume:null];
|
||||||
|
const responses=controls.flatMap(c=>c?.response?[c.response]:[]);
|
||||||
|
for(const {pending}of savedKaramaContexts(g))if(pending.use.kind==='cancel')responses.push(pending.use.response);
|
||||||
|
const matching=responses.filter(r=>r.advisorGatherMatchingSource);
|
||||||
|
requireRule(matching.length>0,'Restore the original gathered matching response.');
|
||||||
|
for(const response of matching)currentAdvisorGatherMatching(g,response);
|
||||||
|
}
|
||||||
|
function finishAdvisorGatherMatching(g:Game,response:ResponseWindow,canceled:boolean){
|
||||||
|
const parent=currentAdvisorGatherMatching(g,response),source=parent.receipts[parent.index];
|
||||||
|
if(canceled){
|
||||||
|
delete g.pendingAdvisorGatherMatching;
|
||||||
|
const key=advisorMatchingKey(source);if(!(g.advisorMatchingDenied??=[]).includes(key))g.advisorMatchingDenied.push(key);
|
||||||
|
log(g,`${getPlayer(g,source.owner).name}'s gathered matching from ${territory(source.origin).name} in ${territory(source.destination).name} was prevented. Neither group moved or changed type, and the movement remains unused. Choose a legal replacement without this prevented packet.`,
|
||||||
|
{faction:'beneGesserit',name:'Gathered group matching prevented'});
|
||||||
|
}else{
|
||||||
|
parent.approved.push(source.event);parent.index++;
|
||||||
|
if(parent.index<parent.receipts.length){nextAdvisorGatherMatching(g);return;}
|
||||||
|
delete g.pendingAdvisorGatherMatching;
|
||||||
|
validateMovementOrder(g,getPlayer(g,source.owner),parent.order);
|
||||||
|
offerChoamMovement(g,parent.order);
|
||||||
|
}
|
||||||
|
}
|
||||||
function matchingAdvisorRule<T>(quote: () => T): T {
|
function matchingAdvisorRule<T>(quote: () => T): T {
|
||||||
try { return quote(); }
|
try { return quote(); }
|
||||||
catch(error) { if(error instanceof AdvisorMatchingError) throw new RuleError(error.message); throw error; }
|
catch(error) { if(error instanceof AdvisorMatchingError) throw new RuleError(error.message); throw error; }
|
||||||
@@ -23305,7 +23390,7 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
|
|||||||
function victory(g: Game, quote?: VictoryQuote) {
|
function victory(g: Game, quote?: VictoryQuote) {
|
||||||
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport &&
|
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport &&
|
||||||
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport &&
|
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport &&
|
||||||
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingAdvisorTransportMatching && !g.pendingSpiritualAdvisor,
|
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingAdvisorTransportMatching && !g.pendingAdvisorGatherMatching && !g.pendingSpiritualAdvisor,
|
||||||
'Resolve mandatory advisor conversions before checking victory.');
|
'Resolve mandatory advisor conversions before checking victory.');
|
||||||
quote ??= currentVictoryQuote(g);
|
quote ??= currentVictoryQuote(g);
|
||||||
applyAdvisorReleases(g, quote.released);
|
applyAdvisorReleases(g, quote.released);
|
||||||
@@ -29097,7 +29182,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
|||||||
);
|
);
|
||||||
} else if (response.kind === 'advisorFlip') {
|
} else if (response.kind === 'advisorFlip') {
|
||||||
const bg = getPlayer(g, response.owner);
|
const bg = getPlayer(g, response.owner);
|
||||||
if (response.advisorTransportMatchingSource) finishAdvisorTransportMatching(g,response,canceled);
|
if (response.advisorGatherMatchingSource) finishAdvisorGatherMatching(g,response,canceled);
|
||||||
|
else if (response.advisorTransportMatchingSource) finishAdvisorTransportMatching(g,response,canceled);
|
||||||
else if (response.advisorAmbassadorMatchingSource) finishAdvisorAmbassadorMatching(g,response,canceled);
|
else if (response.advisorAmbassadorMatchingSource) finishAdvisorAmbassadorMatching(g,response,canceled);
|
||||||
else if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
|
else if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
|
||||||
else if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
|
else if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
|
||||||
@@ -32784,6 +32870,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
|||||||
treacheryDiscardIntegrity(state);
|
treacheryDiscardIntegrity(state);
|
||||||
shipmentPromiseIntegrity(state);
|
shipmentPromiseIntegrity(state);
|
||||||
saphoMovementIntegrity(state);
|
saphoMovementIntegrity(state);
|
||||||
|
advisorGatherMatchingIntegrity(state);
|
||||||
advisorTransportMatchingIntegrity(state);
|
advisorTransportMatchingIntegrity(state);
|
||||||
advisorAmbassadorMatchingIntegrity(state);
|
advisorAmbassadorMatchingIntegrity(state);
|
||||||
spiritualAdvisorIntegrity(state);
|
spiritualAdvisorIntegrity(state);
|
||||||
@@ -33197,6 +33284,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
|||||||
treacheryDiscardIntegrity(state);
|
treacheryDiscardIntegrity(state);
|
||||||
shipmentPromiseIntegrity(state);
|
shipmentPromiseIntegrity(state);
|
||||||
saphoMovementIntegrity(state);
|
saphoMovementIntegrity(state);
|
||||||
|
advisorGatherMatchingIntegrity(state);
|
||||||
advisorTransportMatchingIntegrity(state);
|
advisorTransportMatchingIntegrity(state);
|
||||||
advisorAmbassadorMatchingIntegrity(state);
|
advisorAmbassadorMatchingIntegrity(state);
|
||||||
spiritualAdvisorIntegrity(state);
|
spiritualAdvisorIntegrity(state);
|
||||||
@@ -37286,6 +37374,7 @@ export function viewGame(state: Game, id: string) {
|
|||||||
treacheryDiscardIntegrity(state);
|
treacheryDiscardIntegrity(state);
|
||||||
shipmentPromiseIntegrity(state);
|
shipmentPromiseIntegrity(state);
|
||||||
saphoMovementIntegrity(state);
|
saphoMovementIntegrity(state);
|
||||||
|
advisorGatherMatchingIntegrity(state);
|
||||||
advisorTransportMatchingIntegrity(state);
|
advisorTransportMatchingIntegrity(state);
|
||||||
advisorAmbassadorMatchingIntegrity(state);
|
advisorAmbassadorMatchingIntegrity(state);
|
||||||
spiritualAdvisorIntegrity(state);
|
spiritualAdvisorIntegrity(state);
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ const cancellationSources = {
|
|||||||
...(response.automaticAdvisorFlip.producer === 'spiritualArrival'
|
...(response.automaticAdvisorFlip.producer === 'spiritualArrival'
|
||||||
? { spiritual:g.pendingSpiritualAdvisor?.source??null } : {}),
|
? { spiritual:g.pendingSpiritualAdvisor?.source??null } : {}),
|
||||||
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
|
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
|
||||||
|
: response.advisorGatherMatchingSource
|
||||||
|
? {matching:response.advisorGatherMatchingSource,
|
||||||
|
pending:g.pendingAdvisorGatherMatching?.receipts[g.pendingAdvisorGatherMatching.index]??null}
|
||||||
: response.advisorTransportMatchingSource
|
: response.advisorTransportMatchingSource
|
||||||
? {matching:response.advisorTransportMatchingSource,pending:g.pendingAdvisorTransportMatching?.receipt??null}
|
? {matching:response.advisorTransportMatchingSource,pending:g.pendingAdvisorTransportMatching?.receipt??null}
|
||||||
: response.advisorAmbassadorMatchingSource
|
: response.advisorAmbassadorMatchingSource
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ export function quoteMovementCancellation(
|
|||||||
if (response.kind === 'advisorFlip' && response.advisorMatchingSource) return null;
|
if (response.kind === 'advisorFlip' && response.advisorMatchingSource) return null;
|
||||||
if (response.kind === 'advisorFlip' && response.advisorAmbassadorMatchingSource) return null;
|
if (response.kind === 'advisorFlip' && response.advisorAmbassadorMatchingSource) return null;
|
||||||
if (response.kind === 'advisorFlip' && response.advisorTransportMatchingSource) return null;
|
if (response.kind === 'advisorFlip' && response.advisorTransportMatchingSource) return null;
|
||||||
|
if (response.kind === 'advisorFlip' && response.advisorGatherMatchingSource) return null;
|
||||||
if (response.kind === 'advisorFlip' && response.spiritualAdvisorSource) return null;
|
if (response.kind === 'advisorFlip' && response.spiritualAdvisorSource) return null;
|
||||||
if (
|
if (
|
||||||
![
|
![
|
||||||
|
|||||||
+26
-5
@@ -4317,6 +4317,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'A normal shipment enters as fighters unless joining existing advisors. Opposite-type normal ground arrivals and normal reserves joining advisors now offer Karama matching prevention before movement or payment. Residents keep their type. Provisionally stop a prevented packet into this territory for that action opportunity, leaving a legal replacement and the unused action available. Same-type moves need no flip. Advisors entering another occupied territory may keep their existing optional fighter choice.',
|
'A normal shipment enters as fighters unless joining existing advisors. Opposite-type normal ground arrivals and normal reserves joining advisors now offer Karama matching prevention before movement or payment. Residents keep their type. Provisionally stop a prevented packet into this territory for that action opportunity, leaving a legal replacement and the unused action available. Same-type moves need no flip. Advisors entering another occupied territory may keep their existing optional fighter choice.',
|
||||||
'An allied Fremen Ambassador relocation also offers incoming matching before any selected force moves or collects spice. Residents keep their type. Provisionally, prevention restores the same relocation chooser while denying that same packet here for this Ambassador event. The already-triggered token and ordinary movement/shipment remain unchanged; another legal packet or destination stays available.',
|
'An allied Fremen Ambassador relocation also offers incoming matching before any selected force moves or collects spice. Residents keep their type. Provisionally, prevention restores the same relocation chooser while denying that same packet here for this Ambassador event. The already-triggered token and ordinary movement/shipment remain unchanged; another legal packet or destination stays available.',
|
||||||
'Fresh Guild cross-transport and Junction board arrivals also offer incoming matching before their original permission, price, payment or transfer. Junction Homeworld groups provisionally enter fighter-side. Prevention leaves the original shipment and grant unused while that same packet here stays unavailable for this producer opportunity; choose another legal packet or destination. Junction permission and tariff remain immune. Secret Ally keeps its actual Nexus until the shipment commits.',
|
'Fresh Guild cross-transport and Junction board arrivals also offer incoming matching before their original permission, price, payment or transfer. Junction Homeworld groups provisionally enter fighter-side. Prevention leaves the original shipment and grant unused while that same packet here stays unavailable for this producer opportunity; choose another legal packet or destination. Junction permission and tariff remain immune. Secret Ally keeps its actual Nexus until the shipment commits.',
|
||||||
|
'Planetologist gathering into your residents offers one matching response per incoming origin that must change type. Two changing origins need two separate responses. Provisionally, neither group moves or changes type until every required allowance; preventing any group leaves both origins and the movement unused. That prevented origin packet here is also unavailable to a normal move during the same opportunity. Choose another legal packet or destination.',
|
||||||
'After Trip to Gamont completes during Mentat, the last outsider’s return or empty No-Field reveal can leave advisors alone. Karama can then prevent their conversion without undoing the completed effect or card use. The final CHOAM opportunity resumes afterward; victory waits for its original confirmation. Ordinary Mentat does not gain an extra final chooser.',
|
'After Trip to Gamont completes during Mentat, the last outsider’s return or empty No-Field reveal can leave advisors alone. Karama can then prevent their conversion without undoing the completed effect or card use. The final CHOAM opportunity resumes afterward; victory waits for its original confirmation. Ordinary Mentat does not gain an extra final chooser.',
|
||||||
'A committed Guild cross-shipment or return to reserves can also leave advisors alone. Original transport permission, rate and any Guild income resolve first; payment, force transfer and shipment use stay committed if the later conversion is prevented. The ordinary ground move remains unused. Cross-shipment arrival follows the conversion once; a reserve return has no arrival.',
|
'A committed Guild cross-shipment or return to reserves can also leave advisors alone. Original transport permission, rate and any Guild income resolve first; payment, force transfer and shipment use stay committed if the later conversion is prevented. The ordinary ground move remains unused. Cross-shipment arrival follows the conversion once; a reserve return has no arrival.',
|
||||||
'After actual battle losses leave surviving bystanding advisors alone, Karama may prevent their conversion without undoing losses or costs. Mandatory card disposal, original skills and substitution finish first. Provisionally resolve this before optional winner-card cleanup and later aftermath, then resume the original cleanup once. An explosion that destroys the advisors creates no conversion.',
|
'After actual battle losses leave surviving bystanding advisors alone, Karama may prevent their conversion without undoing losses or costs. Mandatory card disposal, original skills and substitution finish first. Provisionally resolve this before optional winner-card cleanup and later aftermath, then resume the original cleanup once. An explosion that destroys the advisors creates no conversion.',
|
||||||
@@ -4335,8 +4336,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{
|
{
|
||||||
"area": "Implementation",
|
"area": "Implementation",
|
||||||
"status": "Partial",
|
"status": "Partial",
|
||||||
"detail": "Fresh Guild/native Secret Ally and Junction board/Homeworld arrival declarations now own precommit matching before their original permission/rate/settlement suffix. Original scope, funding and order stay private; prevention retains unused shipment and grant. Fremen Ambassador incoming matching now owns a separate precommit counter tied to the actual allied grant, with scoped denial and one original CHOAM/transfer/arrival suffix. Residents, consumed trigger and ordinary allowances are retained. Fresh Advanced Spiritual declarations share target legality and distinct shipping/matching/committed-alone stages. A public seven-field source binds a private original chooser/parent/order; late unavailability restores choice before placement, and committed callbacks never recheck withdrawn reserves. Ordinary/Guild continuation and prior alone holds stay intact. Basic and already-recorded legacy responses keep their old protocol.",
|
"detail": "Resident Planetologist gathers partition incoming physical origins, queue one normal-format matching counter per changing group and retain a private original order/approval batch until atomic commitment. Fresh Guild/native Secret Ally and Junction board/Homeworld arrival declarations now own precommit matching before their original permission/rate/settlement suffix. Original scope, funding and order stay private; prevention retains unused shipment and grant. Fremen Ambassador incoming matching now owns a separate precommit counter tied to the actual allied grant, with scoped denial and one original CHOAM/transfer/arrival suffix. Residents, consumed trigger and ordinary allowances are retained. Fresh Advanced Spiritual declarations share target legality and distinct shipping/matching/committed-alone stages. A public seven-field source binds a private original chooser/parent/order; late unavailability restores choice before placement, and committed callbacks never recheck withdrawn reserves. Ordinary/Guild continuation and prior alone holds stay intact. Basic and already-recorded legacy responses keep their old protocol.",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
|
"game/advisor-gather-matching.ts",
|
||||||
|
"components/planetologist-movement.tsx",
|
||||||
|
"tests/advisor-gather-matching.test.ts",
|
||||||
|
"tests/advisor-gather-matching-engine.test.ts",
|
||||||
|
"tests/fixture-advisor-gather-matching.ts",
|
||||||
"game/advisor-transport-matching.ts",
|
"game/advisor-transport-matching.ts",
|
||||||
"game/transport-quote.ts",
|
"game/transport-quote.ts",
|
||||||
"game/junction-transport-options.ts",
|
"game/junction-transport-options.ts",
|
||||||
@@ -4367,8 +4373,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{
|
{
|
||||||
"area": "Player controls",
|
"area": "Player controls",
|
||||||
"status": "Partial",
|
"status": "Partial",
|
||||||
"detail": "Transport and Junction quotes share scoped packet denials and persistent before-payment matching guidance; twenty native GameTable SSR journeys and two controlled Truthtrance cases pass. Ambassador relocation drafts and persistent matching notices reuse the shared packet denial quote. Six native declaration/response/replacement GameTable SSR journeys pass; callback/hydration and browser acceptance remain separate. Shared projected target choices drive ordinary/Guild buttons and minimal bots. Persistent notices distinguish uncommitted shipping/matching from an already-arrived force awaiting conversion. Same prevented matching packet stays blocked when the original chooser returns. Fifteen native GameTable SSR journeys and two controlled late-stance cases pass; callback/hydration and browser acceptance remain separate.",
|
"detail": "Gather drafts share normal/gather denial quotes; persistent progress identifies each required changing origin. Partial allowance keeps both groups in place. Seven native gather GameTable SSR journeys pass; callback/hydration/browser acceptance remains separate. Transport and Junction quotes share scoped packet denials and persistent before-payment matching guidance; twenty native GameTable SSR journeys and two controlled Truthtrance cases pass. Ambassador relocation drafts and persistent matching notices reuse the shared packet denial quote. Six native declaration/response/replacement GameTable SSR journeys pass; callback/hydration and browser acceptance remain separate. Shared projected target choices drive ordinary/Guild buttons and minimal bots. Persistent notices distinguish uncommitted shipping/matching from an already-arrived force awaiting conversion. Same prevented matching packet stays blocked when the original chooser returns. Fifteen native GameTable SSR journeys and two controlled late-stance cases pass; callback/hydration and browser acceptance remain separate.",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
|
"game/advisor-gather-matching.ts",
|
||||||
|
"components/planetologist-movement.tsx",
|
||||||
|
"tests/advisor-gather-matching.test.ts",
|
||||||
|
"tests/advisor-gather-matching-engine.test.ts",
|
||||||
|
"tests/fixture-advisor-gather-matching.ts",
|
||||||
"game/advisor-transport-matching.ts",
|
"game/advisor-transport-matching.ts",
|
||||||
"game/transport-quote.ts",
|
"game/transport-quote.ts",
|
||||||
"game/junction-transport-options.ts",
|
"game/junction-transport-options.ts",
|
||||||
@@ -4388,8 +4399,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{
|
{
|
||||||
"area": "AI",
|
"area": "AI",
|
||||||
"status": "Partial",
|
"status": "Partial",
|
||||||
"detail": "Transport policies filter prevented packets through the same quotes; sixteen native transport paths across existing profiles accept32 response/replacement actions. No strategic refinement or difficulty calibration. Fremen Ambassador candidates reuse the public packet quote to omit prevented retries. Eight native existing-profile paths accept sixteen response/replacement actions after Ambassador matching; no strategy refinement. Minimal policies consume projected legal targets, including all-blocked matching fallback to decline. Twenty-four paths across four existing profiles, two native parents and three stances accept72 choice/response actions. This is legal participation; no strategy refinement or difficulty calibration.",
|
"detail": "Minimal gather candidates reuse the partition quote so normal/gather prevented packets cannot bypass one another. Eight native gather paths across existing profiles accept sixteen response/replacement actions; no strategy refinement or calibration. Transport policies filter prevented packets through the same quotes; sixteen native transport paths across existing profiles accept32 response/replacement actions. No strategic refinement or difficulty calibration. Fremen Ambassador candidates reuse the public packet quote to omit prevented retries. Eight native existing-profile paths accept sixteen response/replacement actions after Ambassador matching; no strategy refinement. Minimal policies consume projected legal targets, including all-blocked matching fallback to decline. Twenty-four paths across four existing profiles, two native parents and three stances accept72 choice/response actions. This is legal participation; no strategy refinement or difficulty calibration.",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
|
"game/advisor-gather-matching.ts",
|
||||||
|
"components/planetologist-movement.tsx",
|
||||||
|
"tests/advisor-gather-matching.test.ts",
|
||||||
|
"tests/advisor-gather-matching-engine.test.ts",
|
||||||
|
"tests/fixture-advisor-gather-matching.ts",
|
||||||
"game/advisor-transport-matching.ts",
|
"game/advisor-transport-matching.ts",
|
||||||
"game/transport-quote.ts",
|
"game/transport-quote.ts",
|
||||||
"game/junction-transport-options.ts",
|
"game/junction-transport-options.ts",
|
||||||
@@ -4432,7 +4448,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{
|
{
|
||||||
"area": "Documentation",
|
"area": "Documentation",
|
||||||
"status": "Partial",
|
"status": "Partial",
|
||||||
"detail": "Guild/Junction/World/Secret matching records provisional response priority, immune Junction tariff, private scope and fresh-declaration cutover. Source and native/control evidence remain separate from broader mode acceptance. Supplied physical28 redirects retained non-Polar fighters and starts Spiritual Advisors advisor-side, with no alone-conversion Polar exemption. Distinct physical17 counters, provisional Polar matching/restored-choice remedy, continuously-alone duration and the legacy source cutover are explicit. Wider combinations remain open.",
|
"detail": "Resident gathering records one counter per changing origin, provisional atomic commitment, shared normal denials and preserved enemy-only/empty/legacy boundaries. Guild/Junction/World/Secret matching records provisional response priority, immune Junction tariff, private scope and fresh-declaration cutover. Source and native/control evidence remain separate from broader mode acceptance. Supplied physical28 redirects retained non-Polar fighters and starts Spiritual Advisors advisor-side, with no alone-conversion Polar exemption. Distinct physical17 counters, provisional Polar matching/restored-choice remedy, continuously-alone duration and the legacy source cutover are explicit. Wider combinations remain open.",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"docs/SPIRITUAL_ADVISORS.md",
|
"docs/SPIRITUAL_ADVISORS.md",
|
||||||
"docs/ADVISOR_MATCHING.md",
|
"docs/ADVISOR_MATCHING.md",
|
||||||
@@ -4445,8 +4461,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{
|
{
|
||||||
"area": "Verification",
|
"area": "Verification",
|
||||||
"status": "Partial",
|
"status": "Partial",
|
||||||
"detail": "The fresh transport batch passes25 files437/437, types/lint/build, twenty native Guild/Junction/World/Secret GameTable SSR journeys, two controlled Truthtrance cases and sixteen existing-profile paths. Genuine85/82/36/74-action sources earn original grants; permission and actual-discount responses remain separate, and Secret spends Nexus once at commitment. Review repairs the owned Truthtrance shadow and price consistency; smoke/type feedback is repaired. Earlier results below retain their original scope. The new Fremen Ambassador batch passes17 files358/358, types/lint/build, six native declaration/response/replacement GameTable SSR journeys and eight existing-profile paths. Genuine81-action sources earn a Nexus alliance and actual allied grant; both directions and responses retain trigger/ordinary allowances. One zero/absence assertion is repaired. The following earlier frozen results retain their original scope. The frozen batch passes22 files390/390, types/lint/build, fifteen native ordinary/Guild declaration/response/GameTable SSR journeys, two controlled late-stance cases and twenty-four existing-profile paths. Public35/37-action ordinary and45/48-action Guild sources earn actual shipment/Intrusion; a second genuine grant preserves the held group. Four older matching/projection expectations are updated. Controlled legacy responses are format compatibility, not saved captures. Wider Homeworld/Tech/Nexus/No-Field combinations, other matching producers, full modes and final assurance remain open.",
|
"detail": "Resident gather proof passes15 files274/274, types/lint/build, seven native group-response/replacement GameTable SSR journeys and eight existing-profile paths. Genuine78/73-action sources earn Planetologist, original Karama, paid origins and real funding/Intrusion. Partial allowance is physically inert; second prevention retains the whole movement, and normal/gather denials cannot be bypassed. Fixture type feedback is repaired. Earlier results retain their original scope. The fresh transport batch passes25 files437/437, types/lint/build, twenty native Guild/Junction/World/Secret GameTable SSR journeys, two controlled Truthtrance cases and sixteen existing-profile paths. Genuine85/82/36/74-action sources earn original grants; permission and actual-discount responses remain separate, and Secret spends Nexus once at commitment. Review repairs the owned Truthtrance shadow and price consistency; smoke/type feedback is repaired. Earlier results below retain their original scope. The new Fremen Ambassador batch passes17 files358/358, types/lint/build, six native declaration/response/replacement GameTable SSR journeys and eight existing-profile paths. Genuine81-action sources earn a Nexus alliance and actual allied grant; both directions and responses retain trigger/ordinary allowances. One zero/absence assertion is repaired. The following earlier frozen results retain their original scope. The frozen batch passes22 files390/390, types/lint/build, fifteen native ordinary/Guild declaration/response/GameTable SSR journeys, two controlled late-stance cases and twenty-four existing-profile paths. Public35/37-action ordinary and45/48-action Guild sources earn actual shipment/Intrusion; a second genuine grant preserves the held group. Four older matching/projection expectations are updated. Controlled legacy responses are format compatibility, not saved captures. Wider Homeworld/Tech/Nexus/No-Field combinations, other matching producers, full modes and final assurance remain open.",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
|
"game/advisor-gather-matching.ts",
|
||||||
|
"components/planetologist-movement.tsx",
|
||||||
|
"tests/advisor-gather-matching.test.ts",
|
||||||
|
"tests/advisor-gather-matching-engine.test.ts",
|
||||||
|
"tests/fixture-advisor-gather-matching.ts",
|
||||||
"game/advisor-transport-matching.ts",
|
"game/advisor-transport-matching.ts",
|
||||||
"game/transport-quote.ts",
|
"game/transport-quote.ts",
|
||||||
"game/junction-transport-options.ts",
|
"game/junction-transport-options.ts",
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction,normalizeAutomaticGame,viewGame,type Action,type Game } from '../game/engine';
|
||||||
|
import { isAdvisor } from '../game/advisors';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { planetologistMoveDraft } from '../components/planetologist-movement';
|
||||||
|
import { createAdvisorGatherMatchingFixture as fixture,actAdvisorGatherMatching as act,
|
||||||
|
finishAdvisorGatherMatching as finish,advisorGatherMatchingPlayer as player,
|
||||||
|
advisorGatherMatchingInventory as inventory,advisorGatherMatchingSkillInventory as skills,
|
||||||
|
} from './fixture-advisor-gather-matching';
|
||||||
|
|
||||||
|
const physical=(g:Game)=>g.players.map(p=>({id:p.id,forces:p.forces,advisors:p.advisors,
|
||||||
|
reserves:p.reserves,tanks:p.tanks,spice:p.spice,moved:p.moved,shipped:p.shipped}));
|
||||||
|
function allow(g:Game){
|
||||||
|
const source=g.response?.advisorGatherMatchingSource??g.response?.advisorMatchingSource;assert.ok(source);
|
||||||
|
for(let n=0;(g.response?.advisorGatherMatchingSource??g.response?.advisorMatchingSource)?.event===source.event&&n<30;n++){
|
||||||
|
const actor=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(actor);g=act(g,actor.id,{type:'passResponse'});
|
||||||
|
}
|
||||||
|
assert.notEqual((g.response?.advisorGatherMatchingSource??g.response?.advisorMatchingSource)?.event,source.event);return g;
|
||||||
|
}
|
||||||
|
function complete(g:Game){
|
||||||
|
for(let n=0;g.response&&(g.response.advisorGatherMatchingSource||g.response.advisorMatchingSource)&&n<4;n++)g=allow(g);
|
||||||
|
return finish(g);
|
||||||
|
}
|
||||||
|
function reject(g:Game,actor:string,action:Action){const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);}
|
||||||
|
for(const kind of ['mixed','double'] as const){
|
||||||
|
void test(`native ${kind} resident gather offers one response per changing origin and moves once only after every allowance`,()=>{
|
||||||
|
const f=fixture({kind}),draft=planetologistMoveDraft(viewGame(f.game,f.bg),'gather',f.resident,10,f.forces,{});
|
||||||
|
assert.equal(draft.blocked,null);assert.ok(draft.action);
|
||||||
|
let g=act(f.game,f.bg,draft.action);const seen:string[]=[];
|
||||||
|
for(let index=0;index<f.matchingCount;index++){
|
||||||
|
const source=g.response?.advisorGatherMatchingSource;assert.ok(source);seen.push(source.origin);
|
||||||
|
assert.equal(g.response?.advisorGatherMatchingPosition?.current,index+1);assert.equal(g.response?.advisorGatherMatchingPosition?.total,f.matchingCount);
|
||||||
|
assert.deepEqual(physical(g),physical(f.game));assert.deepEqual(physical(normalizeAutomaticGame(g)),physical(f.game));
|
||||||
|
assert.equal(g.pendingAdvisorGatherMatching?.approved.length,index);
|
||||||
|
assert.ok(player(g,f.holder).hand.some(c=>c.id===f.karama));g=allow(g);
|
||||||
|
}
|
||||||
|
assert.deepEqual(seen,[...seen].sort());g=finish(g);
|
||||||
|
assert.equal(g.pendingAdvisorGatherMatching,undefined);assert.equal(player(g,f.bg).forces[f.location],3);
|
||||||
|
assert.equal(player(g,f.bg).forces['imperial_basin:9'],1);assert.equal(player(g,f.bg).forces['oh_gap:9']??0,0);
|
||||||
|
assert.equal(isAdvisor(player(g,f.bg),f.resident),true);assert.equal(player(g,f.bg).moved,player(f.game,f.bg).moved+1);
|
||||||
|
assert.equal(player(g,f.bg).shipped,player(f.game,f.bg).shipped);assert.equal(player(g,f.bg).spice,player(f.game,f.bg).spice);
|
||||||
|
assert.deepEqual(inventory(g),f.originalInventory);assert.deepEqual(skills(g),f.originalSkillInventory);
|
||||||
|
});
|
||||||
|
void test(`native ${kind} gather prevention preserves both groups and shares the normal packet denial`,()=>{
|
||||||
|
const f=fixture({kind}),pending=act(f.game,f.bg,f.action),source=pending.response?.advisorGatherMatchingSource;assert.ok(source);
|
||||||
|
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||||
|
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.pendingAdvisorGatherMatching,undefined);
|
||||||
|
reject(stopped,f.bg,f.action);
|
||||||
|
reject(stopped,f.bg,{...f.action,forces:Object.fromEntries(Object.entries(f.forces).reverse())});
|
||||||
|
reject(stopped,f.bg,{type:'move',forces:source.group,territory:f.resident,sector:10});
|
||||||
|
const draft=planetologistMoveDraft(viewGame(stopped,f.bg),'gather',f.resident,10,f.forces,{});
|
||||||
|
assert.equal(draft.action,null);assert.match(draft.blocked??'',/prevented/);
|
||||||
|
const done=complete(act(stopped,f.bg,f.sameTypeReplacement));
|
||||||
|
assert.equal(player(done,f.bg).moved,1);assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);
|
||||||
|
assert.deepEqual(inventory(done),f.originalInventory);assert.deepEqual(skills(done),f.originalSkillInventory);
|
||||||
|
});
|
||||||
|
void test(`native ${kind} public group receipt retains normal ten-field format without the private batch`,()=>{
|
||||||
|
const f=fixture({kind}),pending=act(f.game,f.bg,f.action),view=viewGame(pending,f.holder),source=view.response?.advisorGatherMatchingSource;assert.ok(source);
|
||||||
|
assert.deepEqual(Object.keys(source).sort(),['advisors','destination','event','group','kind','move','origin','owner','sector','turn']);
|
||||||
|
assert.equal(source.kind,'movement');assert.equal('pendingAdvisorGatherMatching'in view,false);
|
||||||
|
assert.equal(JSON.stringify(source).includes('trainer'),false);
|
||||||
|
});
|
||||||
|
for(const profile of DIFFICULTIES)void test(`${profile} responds to native ${kind} gather and chooses a legal replacement after denial`,()=>{
|
||||||
|
const f=fixture({kind});let pending=act(f.game,f.bg,f.action);if(kind==='double')pending=allow(pending);
|
||||||
|
const view=viewGame(pending,f.holder);view.players.find(p=>p.id===f.holder)!.bot=profile;
|
||||||
|
const before=structuredClone(view),answer=botActions(view)[0];assert.ok(answer);assert.deepEqual(view,before);
|
||||||
|
assert.doesNotThrow(()=>applyAction(pending,f.holder,answer));
|
||||||
|
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}),replacementView=viewGame(stopped,f.bg);
|
||||||
|
replacementView.players.find(p=>p.id===f.bg)!.bot=profile;const replacement=botActions(replacementView)[0];assert.ok(replacement);
|
||||||
|
assert.doesNotThrow(()=>applyAction(stopped,f.bg,replacement));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void test('native second-group prevention after partial allowance leaves both original groups and the whole movement unused',()=>{
|
||||||
|
const f=fixture({kind:'double'}),first=act(f.game,f.bg,f.action),firstSource=first.response?.advisorGatherMatchingSource;assert.ok(firstSource);
|
||||||
|
const second=allow(first),source=second.response?.advisorGatherMatchingSource;assert.ok(source);assert.notEqual(source.event,firstSource.event);
|
||||||
|
assert.equal(source.origin,'oh_gap');assert.equal(second.pendingAdvisorGatherMatching?.approved.length,1);
|
||||||
|
assert.deepEqual(physical(second),physical(f.game));
|
||||||
|
const stopped=act(JSON.parse(JSON.stringify(second)),f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||||
|
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.pendingAdvisorGatherMatching,undefined);reject(stopped,f.bg,f.action);
|
||||||
|
const done=complete(act(stopped,f.bg,f.ordinaryReplacement));assert.equal(player(done,f.bg).forces[f.location],2);
|
||||||
|
assert.equal(player(done,f.bg).forces['oh_gap:9'],1);assert.equal(player(done,f.bg).moved,1);
|
||||||
|
});
|
||||||
|
void test('native first-group prevention admits a different actual Basin packet for one atomic replacement gather',()=>{
|
||||||
|
const f=fixture({kind:'double'}),stopped=act(act(f.game,f.bg,f.action),f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||||
|
const done=complete(act(stopped,f.bg,f.largerPacket));assert.equal(player(done,f.bg).forces[f.location],4);
|
||||||
|
assert.equal(player(done,f.bg).forces['imperial_basin:9']??0,0);assert.equal(player(done,f.bg).forces['oh_gap:9']??0,0);
|
||||||
|
assert.equal(player(done,f.bg).moved,1);assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);
|
||||||
|
});
|
||||||
|
void test('native ordinary matching prevention cannot be bypassed by adding Planetologist second origin',()=>{
|
||||||
|
const f=fixture({kind:'double'}),normal=act(f.game,f.bg,f.ordinaryReplacement);assert.ok(normal.response?.advisorMatchingSource);
|
||||||
|
const stopped=act(normal,f.holder,{type:'card',card:f.karama,mode:'cancel'});reject(stopped,f.bg,f.action);
|
||||||
|
const done=complete(act(stopped,f.bg,f.largerPacket));assert.equal(player(done,f.bg).forces[f.location],4);assert.equal(player(done,f.bg).moved,1);
|
||||||
|
});
|
||||||
|
void test('an invalid original gather signature or unearned approval index fails before cancellation cost',()=>{
|
||||||
|
const f=fixture({kind:'double'}),pending=act(f.game,f.bg,f.action);
|
||||||
|
const signature=structuredClone(pending);signature.pendingAdvisorGatherMatching!.signature+='changed';
|
||||||
|
const index=structuredClone(pending);index.pendingAdvisorGatherMatching!.index=1;
|
||||||
|
const approved=structuredClone(pending);approved.pendingAdvisorGatherMatching!.approved.push('unearned');
|
||||||
|
for(const changed of [signature,index,approved]){reject(changed,f.holder,{type:'card',card:f.karama,mode:'cancel'});assert.ok(player(changed,f.holder).hand.some(c=>c.id===f.karama));}
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
AdvisorGatherMatchingError,
|
||||||
|
quoteAdvisorGatherMatching,
|
||||||
|
type AdvisorGatherMatchingIntent,
|
||||||
|
} from '../game/advisor-gather-matching';
|
||||||
|
import {
|
||||||
|
advisorMatchingKey,
|
||||||
|
AdvisorMatchingError,
|
||||||
|
quoteAdvisorMatching,
|
||||||
|
validateAdvisorMatching,
|
||||||
|
type AdvisorMatchingContext,
|
||||||
|
type AdvisorMatchingIntent,
|
||||||
|
type AdvisorMatchingSource,
|
||||||
|
} from '../game/advisor-matching';
|
||||||
|
|
||||||
|
function context(): AdvisorMatchingContext {
|
||||||
|
return {
|
||||||
|
advanced: true, status: 'playing', turn: 4, phase: 5, active: 'b',
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 'b', faction: 'beneGesserit', moved: 0,
|
||||||
|
forces: {
|
||||||
|
'hagga_basin:12': 3, 'hagga_basin:13': 2, 'arrakeen:10': 2,
|
||||||
|
'carthag:11': 2, 'imperial_basin:9': 2,
|
||||||
|
},
|
||||||
|
advisors: { imperial_basin: { lockedTurn: 4 }, carthag: {} },
|
||||||
|
},
|
||||||
|
{ id: 'a', faction: 'atreides', forces: { 'polar_sink:0': 1, 'imperial_basin:9': 1 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function intent(fields: Partial<AdvisorGatherMatchingIntent> = {}): AdvisorGatherMatchingIntent {
|
||||||
|
return {
|
||||||
|
owner: 'b', move: 0, destination: 'imperial_basin', sector: 10, advisors: true,
|
||||||
|
group: { 'hagga_basin:12': 1, 'arrakeen:10': 1 }, ...fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normal(fields: Partial<AdvisorMatchingIntent> = {}): AdvisorMatchingIntent {
|
||||||
|
return {
|
||||||
|
kind: 'movement', owner: 'b', move: 0, origin: 'hagga_basin',
|
||||||
|
destination: 'imperial_basin', sector: 10, advisors: true,
|
||||||
|
group: { 'hagga_basin:12': 1 }, ...fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('resident gather returns one normal quote per changing origin in deterministic order without changing either packet', () => {
|
||||||
|
const ctx = context();
|
||||||
|
const incoming = intent({ group: { 'hagga_basin:13': 1, 'arrakeen:10': 1, 'hagga_basin:12': 2 } });
|
||||||
|
const before = structuredClone({ ctx, incoming });
|
||||||
|
const quotes = quoteAdvisorGatherMatching(ctx, incoming);
|
||||||
|
assert.deepEqual(quotes, [
|
||||||
|
{
|
||||||
|
kind: 'movement', owner: 'b', turn: 4, move: 0, origin: 'arrakeen',
|
||||||
|
destination: 'imperial_basin', sector: 10, advisors: true, group: { 'arrakeen:10': 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'movement', owner: 'b', turn: 4, move: 0, origin: 'hagga_basin',
|
||||||
|
destination: 'imperial_basin', sector: 10, advisors: true,
|
||||||
|
group: { 'hagga_basin:12': 2, 'hagga_basin:13': 1 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual({ ctx, incoming }, before);
|
||||||
|
assert.deepEqual(quoteAdvisorGatherMatching(ctx, intent({
|
||||||
|
group: { 'hagga_basin:12': 2, 'hagga_basin:13': 1, 'arrakeen:10': 1 },
|
||||||
|
})), quotes);
|
||||||
|
quotes[1].group['hagga_basin:12'] = 99;
|
||||||
|
assert.equal(incoming.group['hagga_basin:12'], 2);
|
||||||
|
assert.equal(ctx.players[0].forces['hagga_basin:12'], 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('same-type halves need no counter and mixed gather quotes only its changing incoming half in either stance direction', () => {
|
||||||
|
const ctx = context();
|
||||||
|
const one = quoteAdvisorGatherMatching(ctx, intent({
|
||||||
|
group: { 'carthag:11': 1, 'hagga_basin:12': 1 },
|
||||||
|
}));
|
||||||
|
assert.equal(one.length, 1);
|
||||||
|
assert.equal(one[0].origin, 'hagga_basin');
|
||||||
|
assert.equal(one[0].advisors, true);
|
||||||
|
const fighters = quoteAdvisorGatherMatching(ctx, intent({
|
||||||
|
destination: 'arrakeen', sector: 10, advisors: false,
|
||||||
|
group: { 'imperial_basin:9': 1, 'carthag:11': 1 },
|
||||||
|
}));
|
||||||
|
assert.deepEqual(fighters.map((quote) => [quote.origin, quote.advisors]), [
|
||||||
|
['carthag', false], ['imperial_basin', false],
|
||||||
|
]);
|
||||||
|
ctx.players[0].advisors = { imperial_basin: {} };
|
||||||
|
assert.deepEqual(quoteAdvisorGatherMatching(ctx, intent({
|
||||||
|
destination: 'arrakeen', sector: 10, advisors: false,
|
||||||
|
group: { 'carthag:11': 1, 'hagga_basin:12': 1 },
|
||||||
|
})), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Basic, other windows, foreign or inactive owners, empty and enemy-only destinations keep their existing nonmatching branch', () => {
|
||||||
|
for (const patch of [
|
||||||
|
{ advanced: false }, { status: 'setup' }, { phase: 4 }, { phase: 6 }, { active: 'a' },
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(quoteAdvisorGatherMatching({ ...context(), ...patch }, intent()), []);
|
||||||
|
}
|
||||||
|
const foreign = context();
|
||||||
|
foreign.players[0].faction = 'atreides';
|
||||||
|
assert.deepEqual(quoteAdvisorGatherMatching(foreign, intent()), []);
|
||||||
|
assert.deepEqual(quoteAdvisorGatherMatching(context(), intent({ owner: 'missing' })), []);
|
||||||
|
assert.deepEqual(quoteAdvisorGatherMatching(context(), intent({
|
||||||
|
destination: 'polar_sink', sector: 0, advisors: false,
|
||||||
|
})), []);
|
||||||
|
const empty = context();
|
||||||
|
empty.players[0].forces = { ...empty.players[0].forces, 'imperial_basin:9': 0 };
|
||||||
|
assert.deepEqual(quoteAdvisorGatherMatching(empty, intent()), []);
|
||||||
|
// The original native gather branch remains responsible for its original
|
||||||
|
// physical order when there is no resident BG group to match.
|
||||||
|
assert.deepEqual(quoteAdvisorGatherMatching(empty, intent({ group: {} })), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('resident gather validates every selected physical packet including a same-type half before any quote is usable', () => {
|
||||||
|
const cases: Record<string, number>[] = [
|
||||||
|
{}, { 'hagga_basin:12': 1, 'hagga_basin:13': 1 },
|
||||||
|
{ 'hagga_basin:12': 1, 'arrakeen:10': 1, 'carthag:11': 1 },
|
||||||
|
{ 'hagga_basin:12': 4, 'arrakeen:10': 1 },
|
||||||
|
{ 'hagga_basin:12': 1, 'carthag:11': 3 },
|
||||||
|
{ 'hagga_basin:12': 0, 'arrakeen:10': 1 },
|
||||||
|
{ 'hagga_basin:12': -1, 'arrakeen:10': 1 },
|
||||||
|
{ 'hagga_basin:12': 1.5, 'arrakeen:10': 1 },
|
||||||
|
{ 'hagga_basin:12.0': 1, 'arrakeen:10': 1 },
|
||||||
|
{ 'hagga_basin:11': 1, 'arrakeen:10': 1 },
|
||||||
|
{ reserves: 1, 'arrakeen:10': 1 },
|
||||||
|
{ 'homeworld:beneGesserit': 1, 'arrakeen:10': 1 },
|
||||||
|
{ 'missing:0': 1, 'arrakeen:10': 1 },
|
||||||
|
];
|
||||||
|
for (const group of cases) {
|
||||||
|
const ctx = context();
|
||||||
|
const incoming = intent({ group });
|
||||||
|
const before = structuredClone({ ctx, incoming });
|
||||||
|
assert.throws(() => quoteAdvisorGatherMatching(ctx, incoming), AdvisorGatherMatchingError);
|
||||||
|
assert.deepEqual({ ctx, incoming }, before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('normal movement prevention also denies its identical gather half despite selection reordering or a different destination sector', () => {
|
||||||
|
const ctx = context();
|
||||||
|
const original = quoteAdvisorMatching(ctx, normal());
|
||||||
|
assert.ok(original);
|
||||||
|
ctx.advisorMatchingDenied = [advisorMatchingKey(original)];
|
||||||
|
const before = structuredClone(ctx);
|
||||||
|
assert.throws(() => quoteAdvisorGatherMatching(ctx, intent()), (error: unknown) =>
|
||||||
|
error instanceof AdvisorGatherMatchingError && /already prevented/.test(error.message));
|
||||||
|
assert.throws(() => quoteAdvisorGatherMatching(ctx, intent({
|
||||||
|
sector: 11, group: { 'arrakeen:10': 1, 'hagga_basin:12': 1 },
|
||||||
|
})), AdvisorGatherMatchingError);
|
||||||
|
assert.deepEqual(ctx, before);
|
||||||
|
assert.equal(quoteAdvisorGatherMatching(ctx, intent({
|
||||||
|
group: { 'arrakeen:10': 1, 'hagga_basin:12': 2 },
|
||||||
|
})).length, 2);
|
||||||
|
assert.equal(quoteAdvisorGatherMatching(ctx, intent({ destination: 'carthag', sector: 11 })).length, 2);
|
||||||
|
ctx.players[0].moved = 1;
|
||||||
|
assert.equal(quoteAdvisorGatherMatching(ctx, intent({ move: 1 })).length, 2);
|
||||||
|
ctx.players[0].moved = 0;
|
||||||
|
ctx.turn++;
|
||||||
|
assert.equal(quoteAdvisorGatherMatching(ctx, intent()).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a prevented gather origin uses the same normal denial key and cannot be retried as ordinary movement', () => {
|
||||||
|
const ctx = context();
|
||||||
|
const original = quoteAdvisorGatherMatching(ctx, intent())[0];
|
||||||
|
assert.equal(original.origin, 'arrakeen');
|
||||||
|
ctx.advisorMatchingDenied = [advisorMatchingKey(original)];
|
||||||
|
const ordinary = normal({ origin: 'arrakeen', group: { 'arrakeen:10': 1 } });
|
||||||
|
assert.throws(() => quoteAdvisorMatching(ctx, ordinary), AdvisorMatchingError);
|
||||||
|
assert.throws(() => validateAdvisorMatching(ctx, {
|
||||||
|
event: 'new-counter', ...original, sector: 11,
|
||||||
|
}), AdvisorMatchingError);
|
||||||
|
assert.ok(quoteAdvisorMatching(ctx, normal()));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('stale selected groups, current movement and resident stance produce the named gather error without changing the original action', () => {
|
||||||
|
const changes: ((ctx: AdvisorMatchingContext, incoming: AdvisorGatherMatchingIntent) => void)[] = [
|
||||||
|
(ctx) => { ctx.players[0].moved = 1; },
|
||||||
|
(ctx) => { delete ctx.players[0].moved; },
|
||||||
|
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'arrakeen:10': 0 }; },
|
||||||
|
(ctx) => { ctx.players[0].advisors = { carthag: {} }; },
|
||||||
|
(_ctx, incoming) => { incoming.advisors = false; },
|
||||||
|
(_ctx, incoming) => { incoming.destination = 'imperial_basin'; incoming.sector = 8; },
|
||||||
|
];
|
||||||
|
for (const change of changes) {
|
||||||
|
const ctx = context();
|
||||||
|
const incoming = intent();
|
||||||
|
change(ctx, incoming);
|
||||||
|
const before = structuredClone({ ctx, incoming });
|
||||||
|
assert.throws(() => quoteAdvisorGatherMatching(ctx, incoming), (error: unknown) =>
|
||||||
|
error instanceof AdvisorGatherMatchingError && error.name === 'AdvisorGatherMatchingError');
|
||||||
|
assert.deepEqual({ ctx, incoming }, before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('gather emits the unchanged ordinary movement receipt shape and delegates its actual source validation', () => {
|
||||||
|
const ctx = context();
|
||||||
|
const quotes = quoteAdvisorGatherMatching(ctx, intent());
|
||||||
|
for (const quote of quotes) {
|
||||||
|
const original: AdvisorMatchingSource = { event: `counter-${quote.origin}`, ...quote };
|
||||||
|
assert.deepEqual(Object.keys(original).sort(), [
|
||||||
|
'advisors', 'destination', 'event', 'group', 'kind', 'move', 'origin', 'owner', 'sector', 'turn',
|
||||||
|
]);
|
||||||
|
assert.equal(original.kind, 'movement');
|
||||||
|
assert.doesNotThrow(() => validateAdvisorMatching(ctx, original));
|
||||||
|
assert.throws(() => validateAdvisorMatching(ctx, {
|
||||||
|
...original, gatherOrder: 'private-parent',
|
||||||
|
} as AdvisorMatchingSource), AdvisorMatchingError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('gather reads public physical counters and stance only, leaving private trainer, routes, concealed forces and funding to the native quote', () => {
|
||||||
|
const ctx = context();
|
||||||
|
const incoming = intent();
|
||||||
|
for (const player of ctx.players) {
|
||||||
|
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'skills', 'noField', 'eliteForces', 'reserves', 'shipped']) {
|
||||||
|
Object.defineProperty(player, field, {
|
||||||
|
get() { throw new Error(`Gather matching read ${player.id} private or unrelated ${field}`); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const stance of Object.values(ctx.players[0].advisors!)) {
|
||||||
|
for (const field of ['lockedTurn', 'aloneFlipHold']) {
|
||||||
|
Object.defineProperty(stance, field, {
|
||||||
|
get() { throw new Error(`Gather matching read unrelated stance ${field}`); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const object of [ctx, incoming]) {
|
||||||
|
for (const field of ['leaderSkills', 'planetologist', 'order', 'signature', 'forecast', 'stormCard', 'pendingMove', 'payment']) {
|
||||||
|
Object.defineProperty(object, field, {
|
||||||
|
get() { throw new Error(`Gather matching read private native ${field}`); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.defineProperty(ctx.players[1], 'forces', {
|
||||||
|
get() { throw new Error('Gather matching reran native foreign occupancy'); },
|
||||||
|
});
|
||||||
|
assert.equal(quoteAdvisorGatherMatching(ctx, incoming).length, 2);
|
||||||
|
const unexpected = new Error('native physical getter failure');
|
||||||
|
Object.defineProperty(ctx.players[0], 'moved', { get() { throw unexpected; } });
|
||||||
|
assert.throws(() => quoteAdvisorGatherMatching(ctx, incoming), (error: unknown) => error === unexpected);
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { distance } from '../game/board';
|
||||||
|
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||||
|
import { planetologistLeader } from '../game/planetologist-movement';
|
||||||
|
import {
|
||||||
|
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
|
||||||
|
advisorPreparationPlayer, nextAdvisorPreparationStep,
|
||||||
|
} from './fixture-advisor-preparation';
|
||||||
|
|
||||||
|
export type AdvisorGatherMatchingStep = { actor: string; action: Action };
|
||||||
|
export type AdvisorGatherMatchingTransition = { before: Game; steps: AdvisorGatherMatchingStep[]; after: Game };
|
||||||
|
export const actAdvisorGatherMatching = actAdvisorPreparation;
|
||||||
|
export const advisorGatherMatchingPlayer = advisorPreparationPlayer;
|
||||||
|
export const advisorGatherMatchingInventory = advisorPreparationInventory;
|
||||||
|
export const advisorGatherMatchingClean = advisorPreparationClean;
|
||||||
|
export const advisorGatherMatchingSkillInventory = (game: Game) => {
|
||||||
|
const state = game.leaderSkills!;
|
||||||
|
const ids = [...state.deck, ...Object.values(state.offers).flatMap(offer => offer.cards),
|
||||||
|
...state.assignments.map(assignment => assignment.skill)].sort();
|
||||||
|
assert.equal(new Set(ids).size, ids.length, 'Every original Leader Skill has one physical slot'); return ids;
|
||||||
|
};
|
||||||
|
const training: Record<string, LeaderSkillId> = {
|
||||||
|
beneGesserit: 'planetologist', atreides: 'killer-medic', emperor: 'swordmaster-of-ginaz',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All technique confirmation is through original owned offers. Intrusion and
|
||||||
|
* later matching flips stay caller-owned; ordinary preparation is declined. */
|
||||||
|
export function nextAdvisorGatherMatching(game: Game): AdvisorGatherMatchingStep | null {
|
||||||
|
if (game.response?.kind === 'advisorFlip') return null;
|
||||||
|
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||||
|
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
|
||||||
|
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
|
||||||
|
const skill = training[actor], offer=own.offer; assert.ok(offer && offer.cards.includes(skill) && !own.unavailableSkills?.[skill]);
|
||||||
|
const leader = own.eligibleLeaders.sort((a, b) => {
|
||||||
|
const player = advisorGatherMatchingPlayer(game, actor);
|
||||||
|
return player.leaders.find(disc => disc.id === b.id)!.strength - player.leaders.find(disc => disc.id === a.id)!.strength || a.id.localeCompare(b.id);
|
||||||
|
})[0]; assert.ok(leader);
|
||||||
|
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
|
||||||
|
}
|
||||||
|
if (game.decision?.kind === 'intrusion') return null;
|
||||||
|
if (game.decision?.kind === 'advisorBattle')
|
||||||
|
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||||
|
}
|
||||||
|
return nextAdvisorPreparationStep(game);
|
||||||
|
}
|
||||||
|
export function advanceAdvisorGatherMatching(game: Game, until: (state: Game) => boolean,
|
||||||
|
steps: AdvisorGatherMatchingStep[] = []): Game {
|
||||||
|
for (let count = 0; count < 2000; count++) {
|
||||||
|
if (until(game)) return game;
|
||||||
|
assert.notEqual(game.status, 'finished', 'The original gather chronology ended before its requested boundary');
|
||||||
|
const next = nextAdvisorGatherMatching(game);
|
||||||
|
assert.ok(next, `Choose the original Intrusion/matching control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||||
|
game = actAdvisorGatherMatching(game, next.actor, next.action, steps);
|
||||||
|
}
|
||||||
|
throw Error('The native advisor gather matching source did not reach its finite boundary');
|
||||||
|
}
|
||||||
|
export function passAdvisorGatherMatching(game: Game, steps: AdvisorGatherMatchingStep[] = []): Game {
|
||||||
|
for (let count = 0; game.response && game.response.kind !== 'advisorFlip' && count < 60; count++) {
|
||||||
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||||
|
game = actAdvisorGatherMatching(game, actor.id, { type: 'passResponse' }, steps);
|
||||||
|
}
|
||||||
|
assert.ok(!game.response || game.response.kind === 'advisorFlip'); return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actual public Advanced classic/all14 Skills source. The initial untouched
|
||||||
|
* Skill Deck is ordered before real offers, then Planetologist is really
|
||||||
|
* assigned. Two paid shipments and optional real Intrusion earn both origins. */
|
||||||
|
export function createAdvisorGatherMatchingFixture(options: { kind?: 'mixed' | 'double' } = {}) {
|
||||||
|
const kind = options.kind ?? 'mixed', bg = 'beneGesserit', holder = 'atreides', emperor = 'emperor';
|
||||||
|
let game = createGame('ADVISORGATHERMATCHING', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
|
||||||
|
joinGame(game, newPlayer(holder, 'Atreides', 'atreides')); joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
|
||||||
|
const initial = structuredClone(game), steps: AdvisorGatherMatchingStep[] = [];
|
||||||
|
const act = (actor: string, action: Action) => { game = actAdvisorGatherMatching(game, actor, action, steps); };
|
||||||
|
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorGatherMatching(game, until, steps); };
|
||||||
|
const transition = (run: () => void): AdvisorGatherMatchingTransition => {
|
||||||
|
const before = structuredClone(game), start = steps.length; run();
|
||||||
|
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||||
|
};
|
||||||
|
const enableSkills = transition(() => { act(game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true }); });
|
||||||
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||||
|
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||||
|
const setup = structuredClone(game);
|
||||||
|
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.setupStage, 'prediction');
|
||||||
|
assert.deepEqual(game.expansions, []); assert.ok(game.leaderSkills);
|
||||||
|
assert.ok(!game.homeworlds && !game.nexusCards && !game.techTokens && !game.strongholdCards &&
|
||||||
|
!game.discoveryEnabled && !game.ecazTreachery);
|
||||||
|
assert.ok(game.players.every(player => !player.hand.length));
|
||||||
|
assert.ok(!Object.keys(game.leaderSkills.offers).length && !game.leaderSkills.assignments.length);
|
||||||
|
const originalInventory = advisorGatherMatchingInventory(game); assert.equal(originalInventory.length, 33);
|
||||||
|
const originalSkillInventory = advisorGatherMatchingSkillInventory(game);
|
||||||
|
assert.deepEqual(originalSkillInventory, LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||||
|
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||||
|
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||||
|
assert.equal(ordinary.length, 2);
|
||||||
|
const selected = [ordinary[0], karama, ordinary[1]];
|
||||||
|
const dealOrder = selected.map((card, position) => {
|
||||||
|
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||||
|
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||||
|
return { position, from, card: card.id };
|
||||||
|
});
|
||||||
|
const beforeSkillOrder = structuredClone(game), originalSkillOrder = [...game.leaderSkills.deck];
|
||||||
|
const wanted = game.players.map(player => training[player.id]);
|
||||||
|
const rest = game.leaderSkills.deck.filter(skill => !wanted.includes(skill));
|
||||||
|
const skillOrder = wanted.flatMap(skill => [skill, rest.shift()!]).concat(rest);
|
||||||
|
game.leaderSkills.deck.splice(0, game.leaderSkills.deck.length, ...skillOrder);
|
||||||
|
assert.deepEqual(advisorGatherMatchingSkillInventory(game), originalSkillInventory);
|
||||||
|
const beforeStartingDeal = structuredClone(game);
|
||||||
|
let advisorSetup: AdvisorGatherMatchingTransition | null = null;
|
||||||
|
let skillOffers: Game | null = null;
|
||||||
|
const skillChoices: AdvisorGatherMatchingTransition[] = [];
|
||||||
|
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||||
|
const next = nextAdvisorGatherMatching(game); assert.ok(next);
|
||||||
|
const before = structuredClone(game), start = steps.length;
|
||||||
|
if (before.setupStage === 'leaderSkills' && !skillOffers) skillOffers = structuredClone(game);
|
||||||
|
act(next.actor, next.action);
|
||||||
|
const record = { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||||
|
if (next.action.type === 'leaderSkill') skillChoices.push(record);
|
||||||
|
if (next.action.type === 'advisorSetup') advisorSetup = record;
|
||||||
|
}
|
||||||
|
assert.equal(game.status, 'playing'); assert.ok(advisorSetup && skillOffers); assert.equal(skillChoices.length, 3);
|
||||||
|
const afterSetup = structuredClone(game), trainer = planetologistLeader(game, bg); assert.ok(trainer);
|
||||||
|
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||||
|
assert.equal(advisorGatherMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
|
||||||
|
assert.ok(advisorGatherMatchingPlayer(game, bg).advisors?.arrakeen);
|
||||||
|
assert.equal(advisorGatherMatchingPlayer(game, holder).forces['arrakeen:10'], 10);
|
||||||
|
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||||
|
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||||
|
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||||
|
!['imperial_basin', 'oh_gap', 'arrakeen'].includes(card.territory));
|
||||||
|
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||||
|
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||||
|
});
|
||||||
|
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||||
|
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && advisorGatherMatchingClean(state));
|
||||||
|
const fundingAction: Action = { type: 'bribe', target: bg, amount: 1 };
|
||||||
|
const funding = transition(() => { act(emperor, fundingAction); });
|
||||||
|
const firstShipment = transition(() => {
|
||||||
|
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
|
||||||
|
reach(advisorGatherMatchingClean);
|
||||||
|
});
|
||||||
|
assert.equal(advisorGatherMatchingPlayer(firstShipment.before, bg).spice - advisorGatherMatchingPlayer(game, bg).spice, 4);
|
||||||
|
let outsiderShipment: AdvisorGatherMatchingTransition | null = null;
|
||||||
|
let intrusion: AdvisorGatherMatchingTransition | null = null;
|
||||||
|
if (kind === 'mixed') {
|
||||||
|
reach(state => state.turn === 1 && state.phase === 5 && state.active === emperor && advisorGatherMatchingClean(state));
|
||||||
|
outsiderShipment = transition(() => {
|
||||||
|
act(emperor, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
|
||||||
|
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'intrusion');
|
||||||
|
});
|
||||||
|
intrusion = transition(() => {
|
||||||
|
act(bg, { type: 'decision', accept: true });
|
||||||
|
for (let count = 0; game.response && count < 60; count++) {
|
||||||
|
assert.equal(game.response.kind, 'advisorFlip'); assert.equal(game.response.advisors, true);
|
||||||
|
assert.ok(!game.response.automaticAdvisorFlip, 'Only the original Intrusion response is allowed in the source');
|
||||||
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||||
|
act(actor.id, { type: 'passResponse' });
|
||||||
|
}
|
||||||
|
reach(advisorGatherMatchingClean);
|
||||||
|
});
|
||||||
|
assert.ok(advisorGatherMatchingPlayer(game, bg).advisors?.imperial_basin);
|
||||||
|
}
|
||||||
|
reach(state => state.turn === 2 && state.phase === 5 && state.active === bg && advisorGatherMatchingClean(state));
|
||||||
|
const beforeSecondShipment = structuredClone(game);
|
||||||
|
const secondShipment = transition(() => {
|
||||||
|
act(bg, { type: 'ship', territory: 'oh_gap', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
|
||||||
|
reach(advisorGatherMatchingClean);
|
||||||
|
});
|
||||||
|
assert.equal(advisorGatherMatchingPlayer(secondShipment.before, bg).spice - advisorGatherMatchingPlayer(game, bg).spice, 2);
|
||||||
|
const resident = 'arrakeen', location = 'arrakeen:10', sourceLocations = ['imperial_basin:9', 'oh_gap:9'];
|
||||||
|
const player = advisorGatherMatchingPlayer(game, bg);
|
||||||
|
assert.equal(player.forces[sourceLocations[0]], 2); assert.equal(player.forces[sourceLocations[1]], 1);
|
||||||
|
assert.equal(!!player.advisors?.imperial_basin, kind === 'mixed'); assert.ok(!player.advisors?.oh_gap);
|
||||||
|
assert.equal(player.forces[location], 1); assert.ok(player.advisors?.[resident]);
|
||||||
|
assert.equal(player.shipped, true); assert.equal(player.moved, 0);
|
||||||
|
assert.equal(planetologistLeader(game, bg), trainer);
|
||||||
|
assert.ok(advisorGatherMatchingPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||||
|
assert.deepEqual(sourceLocations.map(source => distance(source, location)), [1, 1]);
|
||||||
|
assert.deepEqual(advisorGatherMatchingInventory(game), originalInventory);
|
||||||
|
assert.deepEqual(advisorGatherMatchingSkillInventory(game), originalSkillInventory);
|
||||||
|
const forces = { 'imperial_basin:9': 1, 'oh_gap:9': 1 };
|
||||||
|
const action: Action = { type: 'move', planetologist: 'gather', forces, territory: resident, sector: 10 };
|
||||||
|
const largerPacket: Action = { type: 'move', planetologist: 'gather',
|
||||||
|
forces: { 'imperial_basin:9': 2, 'oh_gap:9': 1 }, territory: resident, sector: 10 };
|
||||||
|
const emptyReplacement: Action = { type: 'move', planetologist: 'gather', forces,
|
||||||
|
territory: 'rim_wall_west', sector: 9 };
|
||||||
|
const ordinaryReplacement: Action = { type: 'move', from: 'imperial_basin:9', territory: resident, sector: 10, amount: 1 };
|
||||||
|
const sameTypeReplacement: Action = kind === 'mixed'
|
||||||
|
? ordinaryReplacement
|
||||||
|
: { type: 'move', from: 'imperial_basin:9', territory: 'oh_gap', sector: 9, amount: 1 };
|
||||||
|
const sourceGroups = sourceLocations.map(source => ({ source, available: player.forces[source],
|
||||||
|
selected: forces[source as keyof typeof forces], advisors: source.startsWith('imperial_basin:') && kind === 'mixed' }));
|
||||||
|
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, enableSkills, lobby, setup,
|
||||||
|
beforeStartingDeal, beforeSkillOrder, originalSkillOrder, skillOrder, skillOffers, skillChoices, afterSetup, advisorSetup,
|
||||||
|
funding, fundingAction, fundingStep: { actor: emperor, action: fundingAction } satisfies AdvisorGatherMatchingStep,
|
||||||
|
firstShipment, outsiderShipment, intrusion, beforeSecondShipment, secondShipment,
|
||||||
|
kind, bg, owner: bg, holder, atreides: holder, emperor, karama: karama.id, card: karama.id,
|
||||||
|
skill: 'planetologist' as const, trainer, resident, territory: resident, location, destination: resident, destinationLocation: location,
|
||||||
|
sourceLocations, sourceGroups, sourceCounts: { 'imperial_basin:9': 2, 'oh_gap:9': 1 }, residentCount: player.forces[location],
|
||||||
|
actor: bg, forces, group: forces, cost: 0, action,
|
||||||
|
largerPacket, largerBasinPacket: largerPacket, replacement: largerPacket,
|
||||||
|
replacements: [largerPacket, emptyReplacement, ordinaryReplacement, sameTypeReplacement],
|
||||||
|
emptyReplacement, ordinaryReplacement, ordinaryBasinToArrakeen: ordinaryReplacement, sameTypeReplacement,
|
||||||
|
incomingStances: { imperial_basin: kind === 'mixed' ? 'advisors' : 'fighters', oh_gap: 'fighters' },
|
||||||
|
matchingCount: kind === 'mixed' ? 1 : 2,
|
||||||
|
gatherStep: { actor: bg, action } satisfies AdvisorGatherMatchingStep,
|
||||||
|
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSkillInventory,
|
||||||
|
originalSpice, dealOrder, spiceOrder,
|
||||||
|
staging: [
|
||||||
|
'Actual public Advanced BG/Atreides/Emperor lobby selects only all14 Leader Skills through moduleOption, then ready/startPreview with original base33. No Homeworld, Nexus, Tech, Stronghold, Discovery or independent-card module is selected.',
|
||||||
|
'Before BG’s actual prediction and the original Skill/Treachery deals, reorder only the untouched all14 Skill Deck and three original unopened Treachery positions. The native two-card offer really contains Planetologist; BG assigns it through its real leaderSkill action to a living eligible native trainer. The other two original skill offers are actually confirmed too, and Atreides retains its originally dealt Karama.',
|
||||||
|
'Actual advisorSetup places BG’s original advisor beside Atreides’ native ten Arrakeen10 counters. Select four safe original ordinary Spice draws, then run native Storm/dual Spice/Charity/Bidding/Revival and decline original preparation. No advisor ornithopter range is claimed.',
|
||||||
|
'Emperor actually bribes one spice to BG. BG really pays four on turn1 to ship two reserves to empty Basin9, then pays two on turn2 to ship one reserve to empty Oh Gap9. The genuine one-spice transfer funds the six-spice programme without installing a wallet; both origins are physically one graph step from Arrakeen10.',
|
||||||
|
kind === 'mixed'
|
||||||
|
? 'During its original turn1 movement Emperor really pays to ship one reserve into BG’s Basin group. BG actually accepts Intrusion and all original advisorFlip responses allow the change; separate Spiritual accompaniment is declined. On turn2 this earned incoming advisor group is no longer fresh, while Oh Gap remains a fighter origin.'
|
||||||
|
: 'Emperor skips its normal turn without entering Basin. Both earned incoming groups remain fighters; the single originally held Atreides Karama can prevent a later matching check after an earlier check is allowed.',
|
||||||
|
'Stop at BG’s real turn2 movement with the original resident Arrakeen advisor, live Planetologist, paid second shipment and unused ordinary move. Return the two-origin gather, a larger original Basin packet, an empty-target alternative and a same-type ordinary replacement; no matching response is installed or answered.',
|
||||||
|
'No played phase, force, wallet, hand, Skill assignment, alliance, stance, prevention or movement receipt is installed. Every Skill assignment, source group and advisor lock follows actual original setup or gameplay actions.',
|
||||||
|
] };
|
||||||
|
}
|
||||||
|
export type AdvisorGatherMatchingFixture = ReturnType<typeof createAdvisorGatherMatchingFixture>;
|
||||||
|
|
||||||
|
export function commitAdvisorGatherMatching(source: Game, fixture: AdvisorGatherMatchingFixture,
|
||||||
|
action: Action = fixture.action, steps: AdvisorGatherMatchingStep[] = []) {
|
||||||
|
const before = structuredClone(source), start = steps.length;
|
||||||
|
const game = actAdvisorGatherMatching(source, fixture.bg, action, steps);
|
||||||
|
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
|
||||||
|
}
|
||||||
|
export function finishAdvisorGatherMatching(source: Game, steps: AdvisorGatherMatchingStep[] = []): Game {
|
||||||
|
return advanceAdvisorGatherMatching(source, game => advisorGatherMatchingClean(game), steps);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user