Expand independent-card Semuta continuations
Verify and publish checkpoint container / container (push) Successful in 11m22s
Verify and publish checkpoint container / container (push) Successful in 11m22s
This commit is contained in:
1 parent
6719245f40
commit
50c6201d33
6 files changed
+136
-4
No files matched your search
@@ -1282,7 +1282,8 @@ export function GameTable({
|
||||
<p className="notice" role="status">
|
||||
Semuta Drug preview: in this independent-card game, Semuta can react
|
||||
to another player discarding Recruits, Hajr, Ghola, Harvester,
|
||||
Weather Control, Family Atomics or cards in supported battle cleanup.
|
||||
Weather Control, Family Atomics, a completed Box search, Distrans,
|
||||
simple Truthtrance, ordinary Ornithopter or cards in supported battle cleanup.
|
||||
Other discard opportunities are still being implemented.{' '}
|
||||
<a href="/rules?topic=card-recruits#card-recruits">Recruits rules</a>
|
||||
</p>
|
||||
|
||||
@@ -16,6 +16,14 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Semuta after Richese specials and simple Truthtrance in Basic independent
|
||||
cards — 28 October. Prototyped / Partial.** The Richese/classic preview adds
|
||||
completed Box, Distrans, definite fact/freeform Truthtrance and ordinary
|
||||
Ornithopter discard offers. The recorded effect and native continuation stay
|
||||
intact. Nine focused claim/pass and AI cases pass; card acquisition was staged
|
||||
by physical custody-preserving transfer after real public setup. Other parent
|
||||
combinations remain guarded.
|
||||
|
||||
**Basic mixed-family independent-card arrival overlap — 28 October.
|
||||
Prototyped / Partial.** Selected native families with Ecaz and Moritani can
|
||||
retain the original Ambassador/Terror queue when independent Ecaz cards are
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Semuta discard producers and continuation inventory
|
||||
|
||||
## Richese specials and simple Truthtrance in Basic independent cards — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** In the existing Richese/classic independent-card
|
||||
preview, a completed Box search, Distrans transfer, definite fact/freeform
|
||||
Truthtrance answer or ordinary Ornithopter disposal can offer its used card
|
||||
to Semuta. The private search or transfer, recorded answer or completed
|
||||
movement remains committed and its original continuation resumes once.
|
||||
The tests use genuine public setup and a real Semuta purchase, with explicit
|
||||
custody-preserving transfers to give the acting player the tested card. Other
|
||||
questions, nested effects, unsupported lots and module combinations remain
|
||||
guarded. Earlier saved discards without a reaction retain their original
|
||||
automatic continuation; digital reaction ordering remains provisional.
|
||||
|
||||
## Battle cleanup in Basic independent cards — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** In the existing Richese/classic independent-card
|
||||
|
||||
+7
-2
@@ -9529,7 +9529,12 @@ function semutaOfferSupported(
|
||||
(continuation.kind === 'ordinaryCardDiscard' &&
|
||||
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) ||
|
||||
continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' ||
|
||||
continuation.kind === 'battleCleanup' && continuation.source === 'winner')) &&
|
||||
continuation.kind === 'battleCleanup' && continuation.source === 'winner' ||
|
||||
continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' ||
|
||||
continuation.kind === 'truthtranceDiscard' && continuation.promise === null &&
|
||||
(continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform') ||
|
||||
continuation.kind === 'ornithopterDiscard' &&
|
||||
(continuation.source === 'end' || continuation.source === 'move'))) &&
|
||||
g.status === 'playing' &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
entries.length > 0 &&
|
||||
@@ -41881,7 +41886,7 @@ function applyActionInner(
|
||||
`${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`,
|
||||
{ faction: p.faction, name: 'Distrans' },
|
||||
);
|
||||
if (g.semutaPreview && !g.pendingTreacheryDiscard) {
|
||||
if ((g.semutaPreview || g.semutaRecruitsPreview && basicIndependentSemutaProfile(g)) && !g.pendingTreacheryDiscard) {
|
||||
const continuation: DistransDiscardContinuation = {
|
||||
kind: 'distransDiscard', owner: p.id, recipient: recipient.id,
|
||||
transferred: result.transferred.id, card: result.discarded.id,
|
||||
|
||||
+1
-1
@@ -6606,7 +6606,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.',
|
||||
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
|
||||
'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. Used Hajr, Ghola, Harvester, Weather Control, Family Atomics and cards in supported battle cleanup can also be claimed; other Semuta discard opportunities in this combination remain unfinished.',
|
||||
'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. Used ordinary specials, supported battle cleanup, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card; other Semuta opportunities in this combination remain unfinished.',
|
||||
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
|
||||
'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.',
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { distance, location, TERRITORIES } from '../game/board';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actIndependentOrdinary as act, advanceIndependentOrdinary,
|
||||
createIndependentOrdinaryFixture, independentOrdinaryClean,
|
||||
independentOrdinaryInventory as inventory, independentOrdinaryPlayer as player,
|
||||
} from './fixture-semuta-independent-ordinary';
|
||||
import { moveSemutaEmperorCard, semutaEmperorCardId } from './fixture-semuta-emperor-share';
|
||||
|
||||
type Kind = 'box' | 'distrans' | 'truth' | 'ornithopter';
|
||||
function close(game: Game, holder: string, claim: boolean): Game {
|
||||
const batch = game.pendingTreacheryDiscard; assert.ok(batch?.reaction);
|
||||
const event = batch.batch.event;
|
||||
if (claim) return act(game, holder, {type:'semutaCommit', event});
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Real public independent-card game and Semuta acquisition. The additional
|
||||
* printed card is moved once between existing physical zones to isolate this
|
||||
* interaction; no force, money, phase or event is staged. */
|
||||
function fixture(kind: Kind) {
|
||||
const source = createIndependentOrdinaryFixture('hajr');
|
||||
let game = structuredClone(source.game);
|
||||
const effect = kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : kind;
|
||||
const card = semutaEmperorCardId(game, item => item.effect === effect);
|
||||
moveSemutaEmperorCard(game, card, source.owner);
|
||||
const initialInventory = inventory(game);
|
||||
let given: string | undefined, selected: string | undefined;
|
||||
if (kind === 'box') {
|
||||
// Use the genuinely bought Hajr to create an actual discard target.
|
||||
game = act(game, source.owner, source.play);
|
||||
game = close(game, source.holder, false);
|
||||
selected = source.card;
|
||||
game = act(game, source.owner, {type:'card', card});
|
||||
// A sole eligible discard is selected automatically by the native Box.
|
||||
if (game.pendingNullentropy)
|
||||
game = act(game, source.owner, {type:'decision', event:game.pendingNullentropy.event, card:selected});
|
||||
} else if (kind === 'distrans') {
|
||||
given = source.card;
|
||||
game = act(game, source.owner, {type:'card', card, target:'emperor', give:given});
|
||||
} else if (kind === 'truth') {
|
||||
game = act(game, source.owner, {type:'card', card});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
|
||||
game = act(game, seat.id, {type:'truthPass'});
|
||||
}
|
||||
game = act(game, source.owner, {type:'truthAsk', question:{kind:'freeform', target:'emperor',
|
||||
scope:'fact', text:'Do you intend to protect Arrakeen?'}});
|
||||
game = act(game, 'emperor', {type:'truthAnswer', answer:'yes'});
|
||||
} else {
|
||||
const open = TERRITORIES.flatMap(place => place.type === 'sand' ? place.sectors.map(sector => ({place, sector})) : [])
|
||||
.find(({place, sector}) => sector !== game.storm && game.players.every(seat =>
|
||||
!Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`))));
|
||||
assert.ok(open);
|
||||
game = act(game, source.owner, {type:'ship', territory:open.place.id, sector:open.sector, amount:1, elite:0, allyPayment:0});
|
||||
game = advanceIndependentOrdinary(game, independentOrdinaryClean);
|
||||
const from = location(open.place.id, open.sector);
|
||||
const target = TERRITORIES.flatMap(place => place.sectors.map(sector => ({place, sector})))
|
||||
.find(({place, sector}) => place.id !== open.place.id && sector !== game.storm &&
|
||||
distance(from, location(place.id, sector)) <= 1 && game.players.every(seat =>
|
||||
!Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`))));
|
||||
assert.ok(target);
|
||||
const action: Action = {type:'move', from, amount:1, elite:0, territory:target.place.id,
|
||||
sector:target.sector, movementCard:card, ornithopter:'range3'};
|
||||
game = act(game, source.owner, action);
|
||||
}
|
||||
return {game, owner:source.owner, holder:source.holder, card, given, selected, initialInventory};
|
||||
}
|
||||
|
||||
for (const kind of ['box','distrans','truth','ornithopter'] as const) for (const claim of [false,true]) {
|
||||
void test(`independent-card ${kind} Semuta ${claim ? 'claim' : 'pass'} preserves the completed effect`, () => {
|
||||
const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.card]);
|
||||
assert.equal(viewGame(f.game, f.holder).semutaReaction?.canCommit, true);
|
||||
if (kind === 'distrans') {
|
||||
assert.ok(player(f.game,'emperor').hand.some(card => card.id === f.given));
|
||||
assert.ok(!pending.batch.entries.some(entry => entry.card.id === f.given));
|
||||
}
|
||||
if (kind === 'box') assert.ok(player(f.game,f.owner).hand.some(card => card.id === f.selected));
|
||||
const before = structuredClone({players:f.game.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})),
|
||||
truthHistory:f.game.truthHistory, moved:player(f.game,f.owner).moved});
|
||||
const done = close(f.game, f.holder, claim);
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual({players:done.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})),
|
||||
truthHistory:done.truthHistory, moved:player(done,f.owner).moved}, before);
|
||||
assert.equal(player(done,f.holder).hand.some(card => card.id === f.card), claim);
|
||||
assert.equal(done.discard.some(card => card.id === f.card), !claim);
|
||||
assert.equal(done.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
|
||||
assert.deepEqual(inventory(done), f.initialInventory);
|
||||
});
|
||||
}
|
||||
|
||||
void test('all AI profiles can answer the independent-card Distrans discard', () => {
|
||||
const f = fixture('distrans');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = structuredClone(f.game); player(game,f.holder).bot = difficulty;
|
||||
const answer = botActions(viewGame(game,f.holder)).find(action => action.type === 'semutaCommit' || action.type === 'semutaPass');
|
||||
assert.ok(answer); assert.doesNotThrow(() => act(game,f.holder,answer));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user