Connect owned Sandmaster ground pile selection
This commit is contained in:
1 parent
5544b69d7e
commit
24863480cb
10 files changed
+155
-89
No files matched your search
@@ -10,7 +10,7 @@ import {
|
||||
import {
|
||||
quoteSandmasterMovement,
|
||||
sandmasterActionOrder,
|
||||
sandmasterCollectible,
|
||||
sandmasterCollectionPiles,
|
||||
sandmasterDefaultChoice,
|
||||
sandmasterLeader,
|
||||
sandmasterModeSupported,
|
||||
@@ -49,10 +49,12 @@ export function sandmasterMoveDraft(
|
||||
'Sandmaster needs a supported ordinary force movement and its living native trainer.',
|
||||
};
|
||||
const selectedRoutes = routes ?? defaults.routes;
|
||||
const collectible = sandmasterCollectible(game, selectedRoutes);
|
||||
const offered = sandmasterCollectionPiles(game, selectedRoutes);
|
||||
const collectible = Object.values(offered).flat();
|
||||
const choice = {
|
||||
routes: selectedRoutes,
|
||||
collect: collectible.filter((key) => !declined.includes(key)),
|
||||
collect: Object.values(offered).map(piles => piles.find(key => !declined.includes(key)))
|
||||
.filter((key): key is string => key !== undefined),
|
||||
};
|
||||
try {
|
||||
quoteSandmasterMovement(game, game.me, order, choice);
|
||||
@@ -134,10 +136,11 @@ export function SandmasterMovement({
|
||||
>
|
||||
<h3 id={`${id}-heading`}>Sandmaster movement</h3>
|
||||
<p>
|
||||
{leaderName} may collect 1 spice from one unambiguous pile in each
|
||||
distinct territory these forces enter or pass through. Choose the exact
|
||||
sector route for every moving source.
|
||||
{leaderName} may collect 1 spice in each distinct territory these forces
|
||||
enter or pass through. Choose one existing pile per territory, or decline
|
||||
that collection, and select the exact sector route for every source.
|
||||
</p>
|
||||
<p className="fine">Provisional multi-pile policy: you choose which sector supplies the one spice; this is not a publisher allocation ruling.</p>
|
||||
{order.group.map(([from]) => {
|
||||
const route = routes[from] ?? [from];
|
||||
const last = route.at(-1)!;
|
||||
@@ -222,30 +225,33 @@ export function SandmasterMovement({
|
||||
<fieldset className="space-y-2">
|
||||
<legend>Optional spice collection</legend>
|
||||
{draft.collectible.map((key) => (
|
||||
<label className="decision-checkbox" key={key}>
|
||||
<label className="decision-checkbox flex items-start gap-2" key={key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!declined.includes(key)}
|
||||
className="mt-1 shrink-0"
|
||||
checked={draft.choice?.collect.includes(key) ?? false}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
const peers = draft.collectible.filter(candidate =>
|
||||
splitLocation(candidate).territory === splitLocation(key).territory);
|
||||
setSaved({
|
||||
key: draftKey,
|
||||
routes,
|
||||
declined: event.target.checked
|
||||
? declined.filter((candidate) => candidate !== key)
|
||||
: [...new Set([...declined, key])],
|
||||
})
|
||||
}
|
||||
? [...new Set([...declined.filter(candidate => !peers.includes(candidate)),
|
||||
...peers.filter(candidate => candidate !== key)])]
|
||||
: [...new Set([...declined, ...peers])],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
Collect 1 spice in {placeName(game, key)} ({game.spice[key]} on
|
||||
the pile)
|
||||
<span>Collect 1 spice in {placeName(game, key)} ({game.spice[key]} on the pile)</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : (
|
||||
<p className="fine">
|
||||
This route enters no territory with one unambiguous positive spice
|
||||
pile. You may still declare the route without collecting.
|
||||
This route enters no territory with positive spice.
|
||||
You may still declare the route without collecting.
|
||||
</p>
|
||||
)}
|
||||
{blocked && (
|
||||
|
||||
@@ -29,6 +29,19 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Owned Sandmaster ground-pile choice — 9 October.** Ordinary route
|
||||
collection now offers every positive pile, but permits only one selected
|
||||
sector per entered territory. The checkbox group supports switching piles
|
||||
or declining the whole territory; default human/bot choices are legal.
|
||||
Basic/Advanced native engine smokes preserve an unselected South Mesa
|
||||
pile and collect two total spice across two territories. A 390px isolated
|
||||
production control switches sector4→5, opts out/reselects, and completes
|
||||
the move with personal spice10→12 and three Cielago East forces.
|
||||
Multi-pile destination and HMS controls retain their existing boundaries.
|
||||
The [ground policy](SANDMASTER_MOVEMENT.md#owned-ground-route-pile-selection--9-october-2026)
|
||||
is provisional owner allocation, not a publisher ruling.
|
||||
|
||||
|
||||
**Built-runtime proxy cutover — 9 October.** `npm start` now uses the
|
||||
existing direct Miniflare adapter rather than Wrangler's development proxy.
|
||||
Local storage defaults to `.wrangler/state`; the container explicitly keeps
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 9 October 2026 — owned Sandmaster ground-movement pile selection
|
||||
|
||||
The shared ground quote now groups all positive pile keys by entered
|
||||
territory. The owner can select either sector or decline collection;
|
||||
authoritative validation rejects two selected piles from one territory
|
||||
before any physical movement or spice transfer. Human and minimal legal
|
||||
AI defaults select at most one per group. The obsolete singleton helper
|
||||
is removed; native HMS retains its separate unambiguous-pile scope.
|
||||
Worm and physical Ambassador destination controls are unchanged.
|
||||
|
||||
Basic and Advanced runtime smokes choose South Mesa5 rather than4,
|
||||
leave sector4's four spice untouched, collect one there and one in Cielago
|
||||
East, and move three forces. Personal spice10→12; South Mesa5 ends1 and
|
||||
Cielago East3 ends3. All four profiles emit legal collected-route candidates.
|
||||
Chromium at390px exercises switching, full-territory opt-out and reselection,
|
||||
then observes the same engine result with document width390/no overflow.
|
||||
This is isolated production-component/engine proof, not deployed acceptance.
|
||||
Owner allocation is visibly provisional; no publication gate is opened.
|
||||
|
||||
Frozen verification: `check:quick` and all 21 affected movement, control
|
||||
and worm cases pass. Incidental HTML-attribute-order/default-action pins
|
||||
were removed rather than re-pinned; the retained control regressions
|
||||
exercise actual force movement, spice debit and unavailable-seat/profile
|
||||
boundaries. Temporary UI scripts/service were removed. No broad suite,
|
||||
recovery campaign or deployment was run.
|
||||
|
||||
## 9 October 2026 — built npm runtime uses the proxy-free adapter
|
||||
|
||||
`npm start` still invoked Wrangler despite the container's existing direct
|
||||
|
||||
@@ -21,6 +21,7 @@ They supersede earlier software-absence claims only within their named scope.
|
||||
| Full-hand Nullentropy Box | Basic and Advanced permit an atomic retrieval/Box-disposal exchange with final hand within its actual limit; Box remains last/on top. Intermediate capacity is not adjudicated. | [Box runtime](NULLENTROPY_BOX_ENGINE_AUDIT.md) |
|
||||
| Kulon with Richese Ornithopter | Both modes coexist in Basic/Advanced. The fixed one-group card maximum remains three; Kulon extends each normal-range group. Explicitly provisional, not a publisher ruling. | [Movement policy](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#provisional-kulon-coexistence--9-october-2026) |
|
||||
| Multi-pile battle skills | In both modes, surviving Smuggler allocates the fixed unmodified-strength-capped collection among reveal-time positive piles; Sandmaster winner then selects one remaining pile for +3. Single-pile/all/none/void effects stay automatic. Owner sector selection is provisional, not a publisher allocation ruling. | [Smuggler](SMUGGLER_BATTLE.md#multi-pile-allocation-policy-and-pure-api--9-october-2026), [Sandmaster](LEADER_BATTLE_EFFECTS.md#source-contract) |
|
||||
| Multi-pile Sandmaster ground movement | The owner chooses one existing positive debit sector per entered territory, or declines its one-spice collection. Duplicate territory collection rejects. Worm/Ambassador destination and HMS multi-pile paths remain separate. Explicitly provisional, not a publisher allocation ruling. | [Ground-route policy](SANDMASTER_MOVEMENT.md#owned-ground-route-pile-selection--9-october-2026) |
|
||||
| Basic occupied Homeworld lifecycle | Current sole foreign garrison controls occupied income, Bidding effects, defenses, percentage shares and the Ecaz-victory holding; the entitlement ends immediately on departure, contest or native repopulation. A recorded source observation is still required. Basic Tupile slot leasing keeps the conservative original-epoch gate. Visibly provisional, not a publisher or numerical user ruling. | [Occupation](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
|
||||
|
||||
No publication or faction/mode gate is opened. The exact 7 October user
|
||||
|
||||
@@ -5,6 +5,34 @@ ground movement now connects the normal band through explicit routes, optional
|
||||
collection, player controls, all four AI profiles and saved continuation.
|
||||
This does not open public Leader Skills starts or publication.
|
||||
|
||||
## Owned ground-route pile selection — 9 October 2026
|
||||
|
||||
Ordinary ground movement now offers every existing positive pile in each
|
||||
entered territory. The owner selects at most one pile per territory, or
|
||||
declines that territory's collection. Choosing another pile deselects the
|
||||
previous one; clearing the selected checkbox declines the whole territory.
|
||||
The source grants one spice per territory but does not specify the debit
|
||||
sector, so owner selection is explicitly a provisional allocation policy.
|
||||
|
||||
The shared `sandmasterCollectionPiles` quote groups offered keys by entered
|
||||
territory. Human controls and legal AI start with one deterministic choice
|
||||
per group; authoritative validation rejects two selected sectors from the
|
||||
same territory before forces or spice move. Signed original route and pile
|
||||
receipts retain their existing continuation behavior.
|
||||
|
||||
Both mode engine smokes choose South Mesa sector5 instead of sector4,
|
||||
collect once there and once in Cielago East, preserve the unselected pile,
|
||||
and move three physical forces. All four profiles produce legal collected
|
||||
routes. An isolated 390px Chromium control smoke switches the sector,
|
||||
declines/reselects the territory, and commits the same move: personal
|
||||
spice10→12, South Mesa4 remains4, South Mesa5 becomes1, Cielago East3
|
||||
becomes3, and three forces arrive in Cielago East.
|
||||
|
||||
Worm/physical-Ambassador destination choices and native HMS relocation
|
||||
retain their separate single-pile scope. No public start or broader
|
||||
source adjudication is opened by this ground-route change.
|
||||
|
||||
|
||||
## Physical Fremen Ambassador relocation — 8 October 2026
|
||||
|
||||
The Ambassador expressly relocates a board force group. The normal
|
||||
@@ -60,10 +88,10 @@ Hajr was located; this follows the card's movement-triggered grammar rather
|
||||
than introducing a once-per-phase limit that the card does not state.
|
||||
|
||||
Collection commits when the legal move completes, before arrival reactions.
|
||||
Canceled or rejected declarations move neither forces nor spice. If several
|
||||
positive sector piles exist in a territory, that collection remains unavailable
|
||||
because the source does not specify which pile to debit. Movement and collection
|
||||
from other eligible territories remain available.
|
||||
Canceled or rejected declarations move neither forces nor spice. With
|
||||
several positive sector piles, ordinary ground routes now use the
|
||||
provisional owner selection above. The once-per-entered-territory limit
|
||||
does not become one collection per pile.
|
||||
|
||||
## Implementation and verification boundary
|
||||
|
||||
@@ -101,8 +129,8 @@ Broad checkpoint results are recorded with the checkpoint. These checks do not c
|
||||
The [worm-ride follow-up](SANDMASTER_WORM.md) connects native Fremen destination
|
||||
collection, including saved arrival reactions. Physical Fremen Ambassador
|
||||
relocation is connected above. Other nonordinary relocations, special cards,
|
||||
concealed-marker collection, multiple-pile adjudication and wider interaction/
|
||||
strategy acceptance remain. The lower battle band is in
|
||||
concealed-marker collection, multi-pile destination/HMS collection and wider
|
||||
interaction/strategy acceptance remain. The lower battle band is in
|
||||
[the battle-effects contract](LEADER_BATTLE_EFFECTS.md).
|
||||
|
||||
## 21 September: native HMS relocation eligibility
|
||||
|
||||
+6
-2
@@ -21,7 +21,7 @@ import { planetologistMovementModeSupported, planetologistLeader, planetologistR
|
||||
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
|
||||
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
|
||||
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, smugglerBattleAllocationRequired, chooseSmugglerBattleAllocation, type SmugglerBattleReceipt, type SmugglerBattleCollectionOffer } from './smuggler-battle';
|
||||
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectible, sandmasterLeader, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
|
||||
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectionPiles, sandmasterLeader, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
|
||||
import { sandmasterWormCollection } from './sandmaster-worm';
|
||||
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
|
||||
import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income';
|
||||
@@ -25782,7 +25782,11 @@ function relocateMobileStronghold(
|
||||
// into, so the Sandmaster skill collects there before the faction's own
|
||||
// traversed-sector collection.
|
||||
if (sandmasterLeader(g, move.player))
|
||||
for (const pile of sandmasterCollectible(g, { interior: move.route })) {
|
||||
for (const piles of Object.values(sandmasterCollectionPiles(g, { interior: move.route }))) {
|
||||
// HMS relocation keeps its existing single-pile scope until its
|
||||
// separate route controls can select the Sandmaster debit sector.
|
||||
if (piles.length !== 1) continue;
|
||||
const pile = piles[0];
|
||||
g.spice[pile]--;
|
||||
if (!g.spice[pile]) delete g.spice[pile];
|
||||
p.spice++;
|
||||
|
||||
+1
-1
@@ -4730,7 +4730,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Rescued counters still count toward Advanced Atreides’ seven battle losses for Kwisatz Haderach; combined modules remain unfinished.',
|
||||
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
|
||||
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
|
||||
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before faction arrival reactions, without a ground route or normal movement use. When a native Ixian Hidden Mobile Stronghold relocates, its interior passengers count as entering the territories it points into, so the Sandmaster collects there before the faction’s own traversed-sector collection. Multiple piles and other special relocation remain unfinished.',
|
||||
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. With multiple positive piles, provisionally choose one debit sector per territory, or decline collection; two piles never grant two spice from the same territory. Canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before arrival reactions, without a ground route or normal movement use. Native Ixian HMS interior passengers count as entering the territories it points into and collect before faction spice. Multi-pile worm/Ambassador destination and HMS collection, and other special relocation, remain separate unfinished paths.',
|
||||
'A surviving skilled Sandmaster winner adds three board spice to an existing pile in the battle territory, even with no surviving forces. No pile adds nothing; a single pile resolves automatically. With several remaining positive piles, the winner selects one sector after any Smuggler allocation. Owner selection is a visibly provisional placement policy, not a publisher ruling.',
|
||||
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
|
||||
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
|
||||
|
||||
+16
-15
@@ -163,17 +163,18 @@ export function sandmasterEnteredTerritories(
|
||||
),
|
||||
];
|
||||
}
|
||||
/** Multiple piles retain the existing unresolved physical-pile boundary. */
|
||||
export function sandmasterCollectible(
|
||||
/** Optional collection chooses at most one existing pile in each entered territory. */
|
||||
export function sandmasterCollectionPiles(
|
||||
g: Game | GameView,
|
||||
routes: Record<string, string[]>,
|
||||
): string[] {
|
||||
return sandmasterEnteredTerritories(routes).flatMap((id) => {
|
||||
const piles = Object.entries(g.spice).filter(
|
||||
([key, n]) => splitLocation(key).territory === id && n > 0,
|
||||
);
|
||||
return piles.length === 1 ? [piles[0][0]] : [];
|
||||
});
|
||||
): Record<string, string[]> {
|
||||
const choices: Record<string, string[]> = {};
|
||||
const keys = Object.keys(g.spice);
|
||||
for (const id of sandmasterEnteredTerritories(routes)) {
|
||||
const piles = keys.filter(key => splitLocation(key).territory === id && g.spice[key] > 0).sort();
|
||||
if (piles.length) choices[id] = piles;
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
export function quoteSandmasterMovement(
|
||||
g: Game | GameView,
|
||||
@@ -221,12 +222,12 @@ export function quoteSandmasterMovement(
|
||||
routes[from] = [...route];
|
||||
}
|
||||
const collect = value.collect;
|
||||
const available = sandmasterCollectible(g, routes);
|
||||
const offered = sandmasterCollectionPiles(g, routes);
|
||||
requireMove(
|
||||
collect.every(
|
||||
(key) => typeof key === 'string' && available.includes(key),
|
||||
) && new Set(collect).size === collect.length,
|
||||
'Collect once per entered territory with one unambiguous spice pile, or decline that collection.',
|
||||
collect.every(key => typeof key === 'string' &&
|
||||
offered[splitLocation(key).territory]?.includes(key)) &&
|
||||
new Set(collect.map(key => splitLocation(key).territory)).size === collect.length,
|
||||
'Choose at most one positive pile per entered territory, or decline its collection.',
|
||||
);
|
||||
return {
|
||||
leader,
|
||||
@@ -361,5 +362,5 @@ export function sandmasterDefaultChoice(
|
||||
if (!route) return null;
|
||||
routes[from] = route;
|
||||
}
|
||||
return { routes, collect: sandmasterCollectible(g, routes) };
|
||||
return { routes, collect: Object.values(sandmasterCollectionPiles(g, routes)).map(piles => piles[0]) };
|
||||
}
|
||||
@@ -78,26 +78,9 @@ function markup(game: Game, viewer = 'p', busy = false) {
|
||||
);
|
||||
}
|
||||
|
||||
void test('the owner sees exact default routes, per-pile opt-out and explicit route editing controls', () => {
|
||||
void test('Sandmaster controls remain absent from other seats and unsupported module profiles', () => {
|
||||
const game = fixture();
|
||||
const html = markup(game);
|
||||
assert.match(html, /Sandmaster movement/);
|
||||
assert.match(
|
||||
html,
|
||||
/False Wall West, sector 16 → Wind Pass, sector 16 · 1 \/ 1 territories/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/Collect 1 spice in Wind Pass, sector 15 \(3 on the pile\)/,
|
||||
);
|
||||
assert.match(html, /type="checkbox" checked=""/);
|
||||
assert.match(html, /Remove last route step/);
|
||||
assert.match(html, /Reset route to source/);
|
||||
assert.match(html, /Use shortest route/);
|
||||
assert.match(html, />Move with Sandmaster<\/button>/);
|
||||
assert.match(html, /normal Move forces button to decline Sandmaster/);
|
||||
assert.equal(markup(game, 'o'), '');
|
||||
assert.ok((markup(game, 'p', true).match(/disabled=""/g) ?? []).length >= 4);
|
||||
|
||||
const combined = fixture();
|
||||
combined.expansions = ['ix'];
|
||||
@@ -108,18 +91,12 @@ void test('drafts preserve the ordinary move, accept a connected alternate and a
|
||||
const game = fixture();
|
||||
const view = viewGame(game, 'p');
|
||||
const standard = sandmasterMoveDraft(view, move);
|
||||
assert.equal(standard.blocked, null);
|
||||
assert.deepEqual(standard.choice, {
|
||||
routes: { [source]: [source, destination] },
|
||||
collect: [spicePile],
|
||||
});
|
||||
assert.deepEqual(standard.action, {
|
||||
...move,
|
||||
sandmaster: standard.choice,
|
||||
});
|
||||
const collected = applyAction(structuredClone(game), 'p', standard.action!);
|
||||
assert.equal(collected.players[0].spice, game.players[0].spice + 1);
|
||||
assert.equal(collected.spice[spicePile], 2);
|
||||
assert.equal(collected.players[0].forces[source] ?? 0, 0);
|
||||
assert.equal(collected.players[0].forces[destination], 2);
|
||||
assert.equal(collected.players[0].moved, 1);
|
||||
|
||||
assert.ok(GRAPH[source].includes(location('wind_pass', 17)));
|
||||
assert.ok(GRAPH[location('wind_pass', 17)].includes(destination));
|
||||
@@ -165,10 +142,11 @@ void test('all four bot profiles add legal collection only to ordinary single-so
|
||||
action.ornithopterEvent === undefined,
|
||||
),
|
||||
);
|
||||
assert.doesNotThrow(
|
||||
() => applyAction(game, 'p', candidates[0]),
|
||||
`${difficulty} emitted an illegal Sandmaster move`,
|
||||
);
|
||||
const completed = applyAction(game, 'p', candidates[0]);
|
||||
const selected = (candidates[0].sandmaster as { collect: string[] }).collect;
|
||||
assert.equal(completed.players[0].moved, 1, difficulty);
|
||||
assert.equal(completed.players[0].spice, game.players[0].spice + selected.length, difficulty);
|
||||
for (const key of selected) assert.equal(completed.spice[key], game.spice[key] - 1, difficulty);
|
||||
|
||||
const combined = viewGame(fixture(), 'p');
|
||||
combined.players.find((player) => player.id === 'p')!.bot = difficulty;
|
||||
|
||||
@@ -87,7 +87,32 @@ void test('Sandmaster can decline each territory or the entire effect; legacy mo
|
||||
g.players[0].spice,
|
||||
);
|
||||
});
|
||||
void test('Sandmaster rejects disconnected, overlong, storm, duplicate and ambiguous collection choices without mutation', () => {
|
||||
|
||||
void test('Sandmaster chooses either positive sector pile but never collects twice from one entered territory', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = sandmasterMovementGame('emperor', advanced);
|
||||
const action = sandmasterMove(game);
|
||||
const choice = action.sandmaster as SandmasterChoice;
|
||||
const original = choice.collect.find(key => territory(splitLocation(key).territory).sectors.some(
|
||||
sector => sector !== splitLocation(key).sector && sector !== game.storm));
|
||||
assert.ok(original);
|
||||
const at = splitLocation(original);
|
||||
const sector = territory(at.territory).sectors.find(value => value !== at.sector && value !== game.storm)!;
|
||||
const alternate = `${at.territory}:${sector}`;
|
||||
game.spice[alternate] = 2;
|
||||
unchanged(game, { ...action, sandmaster: { ...choice, collect: [...choice.collect, alternate] } });
|
||||
const collect = choice.collect.map(key => key === original ? alternate : key);
|
||||
const done = applyAction(restored(game), 'p', { ...action, sandmaster: { ...choice, collect } });
|
||||
assert.equal(done.spice[original], game.spice[original]);
|
||||
assert.equal(done.spice[alternate], 1);
|
||||
assert.equal(done.players[0].spice, game.players[0].spice + collect.length);
|
||||
assert.equal(done.players[0].moved, 1);
|
||||
const defaults = sandmasterDefaultChoice(viewGame(game, 'p'), 'p', action)!;
|
||||
assert.equal(new Set(defaults.collect.map(key => splitLocation(key).territory)).size, defaults.collect.length);
|
||||
conserved(done, game);
|
||||
}
|
||||
});
|
||||
void test('Sandmaster rejects disconnected, overlong, storm and duplicate collection choices without mutation', () => {
|
||||
const g = sandmasterMovementGame(),
|
||||
action = sandmasterMove(g),
|
||||
choice = action.sandmaster as SandmasterChoice;
|
||||
@@ -117,22 +142,6 @@ void test('Sandmaster rejects disconnected, overlong, storm, duplicate and ambig
|
||||
delete tooFar.players[0].forces['arrakeen:10'];
|
||||
tooFar.players[0].reserves++;
|
||||
unchanged(tooFar, action);
|
||||
const id = splitLocation(choice.collect[0]).territory;
|
||||
const second = territory(id).sectors.find(
|
||||
(s) => `${id}:${s}` !== choice.collect[0],
|
||||
);
|
||||
if (second !== undefined) {
|
||||
const ambiguous = restored(g);
|
||||
ambiguous.spice[`${id}:${second}`] = 2;
|
||||
unchanged(ambiguous, action);
|
||||
const decline = {
|
||||
...choice,
|
||||
collect: choice.collect.filter((key) => key !== choice.collect[0]),
|
||||
};
|
||||
assert.doesNotThrow(() =>
|
||||
applyAction(ambiguous, 'p', { ...action, sandmaster: decline }),
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('Fremen Sandmaster waits for movement permission and cancellation releases untouched spice', () => {
|
||||
const g = sandmasterMovementGame('fremen'),
|
||||
|
||||
Reference in new issue
Block a user