Support Basic allied No-Field transit
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 22:53:10 +11:00
1 parent 40d567a053
commit 9c6b2d884b
8 files changed
+171 -7

No files matched your search

+10
View File
@@ -93,6 +93,11 @@ export function RicheseAlliedNoFieldControls({
token reveals when the shipment resolves. No payment or existing
marker changes occur on decline.
</p>
{!game.advanced && <p className="notice">
Basic allied transit: if the destination is occupied by you,
the recipient must move the arriving forces out before finishing.
Under the provisional rule, new visitors left behind go to Tanks.
</p>}
{pricing.policy && <p className="notice">{pricing.policy}</p>}
{info.private.deployed && (
<p className="fine">
@@ -233,6 +238,11 @@ export function RicheseAlliedNoFieldDecision({
declining costs nothing. Any earlier concealed No-Field reveals only if
the accepted shipment proceeds.
</p>
{!game.advanced && <p className="notice">
If this destination is occupied by your ally, move the arriving forces
out before finishing. Under the provisional Basic rule, new visitors
left behind go to Tanks.
</p>}
{offer.blocked && (
<p className="notice" id={`${id}-blocked`}>
{offer.blocked} You can decline without cost.
+24 -3
View File
@@ -2,6 +2,26 @@
Audit: 7 September 2026. Read-only source and code audit, retained by the coordinating agent. Scope is classic GF9 base rules and the applicable November 2020 FAQ, not tournament or movie-game rules.
## Basic allied No-Field transit — 28 October 2026
**Prototyped / Partial.** The November 2020 FAQ p. 2 permits shipment into
an ally's territory followed by departure, subject to stronghold limits. The
printed Richese alliance ability ships its ally's off-planet forces and
immediately reveals the No-Field. Provisionally compose these rules for a
Basic offer to the Richese-occupied territory. The recipient consents, the
native cancellation and payment sequence applies, and the arriving counters
are ordinary physical forces. The pre-arrival physical baseline records only
new visitors; finishing without moving them out sends those visitors to Tanks
under the existing provisional Basic fallback. A zero-force arrival creates no
visitor receipt. The supplier's older marker reveals before this baseline is
recorded. Supplier and recipient see the fallback before offering or accepting;
minimal AI retains its existing legal empty-destination offer path.
This composition and the no-departure fallback are software interpretations,
not publisher rulings. Advanced allied No-Field transit and other transport
combinations remain separate. [FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf),
[CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf).
## Concealed No-Field at the separation deadline — 27 October 2026
A deployed marker is one force for all effects, including0, under publisher E2
@@ -118,7 +138,7 @@ Tanks. A zero marker reveals without casualties. Physical visitors use the
same pre-arrival baseline; an earlier voluntary reveal is not repeated.
The source FAQ permits allied shipment transit and the Richese rules count a
concealed marker as one force; their combination and no-exit consequence are
provisional. Allied No-Field use, paired markers and other
provisional. Paired markers and other
transport compositions remain separate.
## Basic ordinary allied shipment prototype — 8 October 2026
@@ -148,8 +168,9 @@ co-occupation deadlines still resolve separately. AI prioritizes available
native departures and does not retain a stronghold sentinel from a
visiting group.
Allied No-Field arrivals, mobile/homeworld transports and
optional arrival compositions are not expanded by the Basic prototype.
Mobile/homeworld transports and optional arrival compositions are not expanded
by the ordinary Basic prototype. Allied No-Field arrivals have their own
later scoped prototype above.
Advanced ordinary physical entry is connected in the follow-up above.
Refinement of interrupted and mixed-cohort interactions remains later work.
+9
View File
@@ -16,6 +16,15 @@ open mode gates.
## Current checkpoint and work
**Basic allied No-Field transit — 28 October. Prototyped / Partial.** Richese
may offer its ally an immediately revealed shipment into a Richese-occupied
territory. The recipient may depart or accept the existing provisional loss
of new visitors at turn end; consent, price and cancellation retain their
native sequence. Advanced and other transport compositions remain separate.
The six-file related union passes 51 cases; the new offer, departure, loss,
zero and decline cases use a staged, force-conserving Basic fixture.
[Scope](ALLIED_TRANSIT_RULES.md#basic-allied-no-field-transit--28-october-2026).
**Basic allied No-Field transit with paid companions — 28 October.
Prototyped / Partial.** High Richese Homeworld can include paid physical
reserves beside its own visiting marker. The existing temporary-visit baseline
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index
## Basic allied No-Field transit — 28 October 2026
The FAQ's allied shipment and the Richese alliance ability are provisionally
combined for an immediately revealed Basic arrival into Richese's territory.
The existing new-visitor fallback applies; the exact interaction is not a
publisher ruling. [Scope](ALLIED_TRANSIT_RULES.md#basic-allied-no-field-transit--28-october-2026).
## Fresh No-Field with native Homeworld reserves — 28 October 2026
The supplied Advanced revision permits World-to-World shipment and counts a
+7
View File
@@ -4905,6 +4905,7 @@ function alliedNoFieldQuote(
offer.sector,
false,
advisors,
!trial.advanced && isAlliedShipmentVisit(trial, trialRecipient, offer.territory),
);
const pricing = alliedNoFieldPricing(g, recipient);
const cost = territory(offer.territory).type === 'stronghold' ? pricing.stronghold : pricing.other;
@@ -38482,6 +38483,12 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
}
const owner = getPlayer(g, offer.owner);
if (owner.noField!.deployed) revealPlayerNoField(g, owner, 'beforeAlly');
// The marker reveals immediately for its ally. Record only the recipient's
// pre-arrival physical baseline, after the supplier's old marker reveals.
if (!g.advanced && n > 0) {
const visit = occupancyRule(() => makeBasicAlliedShipment(g, p, to));
if (visit) g.basicAlliedShipment = visit;
}
const result = noFieldRule(() =>
shipAlliedRicheseNoField(
owner.noField!,
+3 -3
View File
@@ -4459,14 +4459,14 @@ export const RULE_TOPICS: RuleTopic[] = [
'A concealed No-Field counts as one presence, including0. If its actual separation deadline is due, provisional destructive timing reveals it, places only available reserves, then sends your actual shared forces to Tanks. Your ally stays. A marker elsewhere, a not-yet-due Basic turn and existing exemptions retain concealment. The warning states that a marker is affected without exposing its value or quantity.',
'Complete any pending shipment, movement, arrival or response before ordinary finish controls become available. Extra-move and deferred-turn endings use the same separation rule; they do not replay the arrival or charge shipment again.',
'An ordinary physical reserve shipment may enter an allied territory before your movement on your own unused turn. Ship, then move out; stronghold capacity and storm still apply. The ending consequence covers your entire shared group, not only new visitors. No-Fields and optional Homeworld/mobile transport keep their separate legality.',
'Basic still uses its older later-player deadline and formation-turn exemption, plus its visibly provisional new-visitor consequence. Homeworlds keep their native allied-entry restrictions. The rule grants no extra shipment, movement or alliance formation window; completed turns are not reevaluated.',
'Basic still uses its older later-player deadline and formation-turn exemption, plus its visibly provisional new-visitor consequence. An accepted Richese allied No-Field offer may also arrive at Richese’s territory; its immediately revealed visitors use the same physical baseline. Homeworlds keep their native allied-entry restrictions. The rule grants no extra shipment, movement or alliance formation window; completed turns are not reevaluated.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'A shared ordinary reserve-entry quote connects native preflight and commit. Canonical separation distinguishes Basic from Advanced timing and exceptions; native ending-group custody removes all Advanced shared forces. Due concealed markers use provisional reserve-capped revelation instead of blocking Finish.', evidence: ['game/allied-separation.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Pre-shipment notice explains the mode-specific consequence; own clean-turn warnings in both modes contain public loss territories and optional marker presence only. Mandatory loss adds no confirmation and pending-control locks retain priority.', evidence: ['components/game-table.tsx', 'components/advanced-ally-separation.tsx'] },
{ area: 'Player controls', status: 'Partial', detail: 'Pre-shipment and allied No-Field offer notices explain the mode-specific consequence; own clean-turn warnings in both modes contain public loss territories and optional marker presence only. Mandatory loss adds no confirmation and pending-control locks retain priority.', evidence: ['components/game-table.tsx', 'components/advanced-ally-separation.tsx', 'components/richese-allied-no-field.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four legal policies use projected movement/ending availability. Native departure takes priority, without retaining a doomed city sentinel; a Medium typed-arrival/departure smoke passes. No separate separation calculator or strategic calibration was added.', evidence: ['game/bots.ts', 'game/bot-mobility.ts', 'tests/bot-advanced-source.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The unofficial per-player/advisor departures are distinguished from the earlier publisher/FAQ and unchanged Basic policy.', evidence: ['docs/ALLIED_TRANSIT_RULES.md', 'docs/RULE_DECISIONS.md', 'docs/BASE_ADVANCED_READINESS_20260907.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine native overlaps cover first/later newly allied ends, exact normal/elite custody, Hajr, advisor/Ecaz/Polar exceptions and unchanged Basic policy. Authenticated SQLite covers restart, races, privacy and immutable rejects. Actual phone ending moved only the ending group to Tanks and refresh retained it; wider combinations, full modes and deployment remain open.', evidence: ['tests/fixture-no-field-allied-separation.ts', 'tests/no-field-allied-separation-engine.test.ts', 'tests/no-field-allied-separation-controls.test.tsx', 'tests/allied-separation.test.ts', 'tests/advanced-source-engine.test.ts', 'tests/advanced-source-recovery.test.ts', 'tests/bot-advanced-source.test.ts', 'docs/ALLIED_TRANSIT_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine native overlaps cover first/later newly allied ends, exact normal/elite custody, Hajr, advisor/Ecaz/Polar exceptions and unchanged Basic policy. Staged allied No-Field offers exercise consent, departure, loss and zero arrivals. Authenticated SQLite covers restart, races, privacy and immutable rejects. Actual phone ending moved only the ending group to Tanks and refresh retained it; wider combinations, full modes and deployment remain open.', evidence: ['tests/allied-no-field-transit.test.ts', 'tests/fixture-no-field-allied-separation.ts', 'tests/no-field-allied-separation-engine.test.ts', 'tests/no-field-allied-separation-controls.test.tsx', 'tests/allied-separation.test.ts', 'tests/advanced-source-engine.test.ts', 'tests/advanced-source-recovery.test.ts', 'tests/bot-advanced-source.test.ts', 'docs/ALLIED_TRANSIT_RULES.md'] },
],
related: ['movement', 'spice-blow', 'advanced-advisors', 'ecaz-ambassadors', 'homeworlds'],
},
+109
View File
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { createRicheseNoField } from '../game/richese-no-field';
// Controlled Basic turn: both allies already have conserved forces in the
// destination, as can happen when they formed their alliance this turn.
function fixture() {
const game = createGame('ALLYTRANSIT', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
game.players.push(newPlayer('a', 'Emperor', 'emperor'), newPlayer('e', 'Atreides', 'atreides'));
game.status = 'playing'; game.phase = 5; game.turn = 2; game.active = 'a';
game.order = ['r', 'a', 'e']; game.movementRemaining = ['a', 'r', 'e']; game.storm = 18;
game.deck = baseDeck();
for (const p of game.players) {
p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 10; p.hand = [];
p.traitorChoices = []; p.traitors = [];
}
const [richese, recipient] = game.players;
richese.ally = 'a'; recipient.ally = 'r';
richese.allySinceTurn = 2; recipient.allySinceTurn = 2;
richese.forces['imperial_basin:11'] = 2; richese.reserves = 18;
recipient.forces['imperial_basin:11'] = 2; recipient.reserves = 18;
recipient.elites = {reserves: 4, tanks: 0, forces: {'imperial_basin:11': 1}, revived: 0};
richese.noField = createRicheseNoField(['zero', 'three', 'five']);
richese.noFieldEvent = 'original-marker-event';
return game;
}
const send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
function offer(game: Game, token = 'three') {
return send(game, 'r', {type: 'offerRicheseNoField', token,
event: game.players[0].noFieldEvent, payer: 'r', territory: 'imperial_basin', sector: 9});
}
function accept(game: Game, elite = 2) {
return send(game, 'a', {type: 'decision', event: game.richeseAllyOffer!.event, accept: true, elite});
}
function finishResponse(game: Game) {
for (let count = 0; game.response && count < 10; count++) {
const pending = game.response;
const next = game.players.find(p => !pending.passed.includes(p.id));
assert.ok(next, 'Every response must resolve after all eligible players pass.');
game = send(game, next.id, {type: 'passResponse'});
}
assert.equal(game.pendingShipment, null);
return game;
}
function physicalAt(game: Game, id: string, territory: string) {
const p = game.players.find(seat => seat.id === id)!;
return Object.entries(p.forces).reduce((sum, [key, n]) => sum + (key.startsWith(`${territory}:`) ? n : 0), 0);
}
void test('Basic allied No-Field physical visitors may leave after the real offer and acceptance', () => {
const initial = fixture();
let game = offer(initial);
assert.equal(game.decision?.kind, 'richeseAllyShipment');
assert.equal(game.players[0].spice, 10);
game = finishResponse(accept(game));
assert.equal(game.players[0].spice, 8);
assert.equal(game.players[0].noField!.lastShipped, 'three');
assert.equal(game.players[0].noField!.deployed, null);
assert.equal(game.players[1].shipped, true);
assert.equal(game.players[1].reserves, 15);
assert.equal(game.players[1].forces['imperial_basin:9'], 3);
assert.deepEqual(viewGame(game, 'a').basicAlliedShipment, {territory: 'Imperial Basin', normal: 1, elite: 2});
game = send(game, 'a', {type: 'move', forces: {'imperial_basin:9': 3},
eliteForces: {'imperial_basin:9': 2}, territory: 'polar_sink', sector: 0});
assert.equal(viewGame(game, 'a').basicAlliedShipment, null);
game = send(game, 'a', {type: 'endMovement'});
assert.equal(physicalAt(game, 'a', 'imperial_basin'), 2);
assert.equal(game.players[1].forces['polar_sink:0'], 3);
assert.equal(game.players[1].tanks, 0);
assert.equal(game.players[0].forces['imperial_basin:11'], 2);
});
void test('Basic allied No-Field visitors left in the territory lose only their physical and elite excess', () => {
let game = finishResponse(accept(offer(fixture())));
game = send(game, 'a', {type: 'endMovement'});
const recipient = game.players[1];
assert.equal(physicalAt(game, 'a', 'imperial_basin'), 2);
assert.equal(Object.values(recipient.elites!.forces).reduce((sum, n) => sum + n, 0), 1);
assert.equal(recipient.tanks, 3);
assert.equal(recipient.elites!.tanks, 2);
assert.equal(recipient.reserves, 15);
assert.equal(game.players[0].forces['imperial_basin:11'], 2);
assert.equal(viewGame(game, 'a').basicAlliedShipment, null);
});
void test('Basic allied No-Field zero does not create visitor casualties or disturb the elite baseline', () => {
let game = finishResponse(accept(offer(fixture(), 'zero'), 0));
assert.equal(game.players[0].noField!.lastShipped, 'zero');
assert.equal(game.players[1].shipped, true);
assert.equal(game.players[1].reserves, 18);
assert.equal(viewGame(game, 'a').basicAlliedShipment, null);
game = send(game, 'a', {type: 'endMovement'});
assert.equal(physicalAt(game, 'a', 'imperial_basin'), 2);
assert.equal(game.players[1].elites!.forces['imperial_basin:11'], 1);
assert.equal(game.players[1].tanks, 0);
assert.equal(game.players[0].forces['imperial_basin:11'], 2);
});
void test('declining the allied No-Field visit keeps payment, reserves and visitor receipt untouched', () => {
let game = offer(fixture());
game = send(game, 'a', {type: 'decision', event: game.richeseAllyOffer!.event, decline: true});
assert.equal(game.players[0].spice, 10);
assert.equal(game.players[0].noField!.lastShipped, null);
assert.equal(game.players[1].shipped, false);
assert.equal(game.players[1].reserves, 18);
assert.ok(game.basicAlliedShipment == null);
});
+2 -1
View File
@@ -272,7 +272,7 @@ void test('stale acceptance and changed pending reserve allocation cannot cause
assert.ok(k);
conserve(g);
});
void test('invalid payer, occupancy, repeated token, wrong turn and on-planet Fremen reserves reject atomically', () => {
void test('invalid payer, Advanced allied occupancy, wrong turn and on-planet Fremen reserves reject atomically', () => {
const g = fixture(),
before = structuredClone(g);
assert.throws(() => offer(g, 'five', 'e'), /ally payer/);
@@ -285,6 +285,7 @@ void test('invalid payer, occupancy, repeated token, wrong turn and on-planet Fr
g.active = 'a';
g.players[0].forces = { 'arrakeen:10': 1 };
g.players[0].reserves = 19;
g.advanced = true;
assert.throws(() => offer(g), /occupied by your ally/);
});