Enable Semuta in public Richese Skills games
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
1972622c11
commit
b2b9a024bd
9 files changed
+285
-3
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
@@ -145,6 +146,8 @@ function takeCard(game: Game, actor: string, identity: string): string {
|
||||
/** Genuine setup and original phase controls reach Movement. Only the conserved
|
||||
* board/hand position is staged: native forces return to their own reserves,
|
||||
* then five original counters per combatant and canonical cards change slots.
|
||||
* Advanced also stages one conserved Atreides reserve counter at Polar Sink;
|
||||
* its actual adjacent movement earns the immediate Hajr opportunity.
|
||||
* Wallets, printed leaders, skill training, turn, phase and module custody stay real. */
|
||||
export function createSemutaSkillsMentatFixture(advanced = false, tech = false, strongholds = false): SemutaSkillsMentatFixture {
|
||||
let game = initializeSemutaSkillsGame(advanced, tech, strongholds);
|
||||
@@ -180,6 +183,28 @@ export function createSemutaSkillsMentatFixture(advanced = false, tech = false,
|
||||
}
|
||||
const semuta = takeCard(game, 'r', SEMUTA_DRUG_ID), hajr = takeCard(game, 'a', 'hajr');
|
||||
const weapon = takeCard(game, 'e', 'Crysknife'), fallback = takeCard(game, 'e', 'Snooper');
|
||||
if (advanced) {
|
||||
const atreides = semutaSkillsPlayer(game, 'a');
|
||||
assert.ok(atreides.reserves > 0);
|
||||
atreides.reserves--;
|
||||
atreides.forces['polar_sink:0'] = 1;
|
||||
const own = viewGame(game, 'a'), seat = own.players.find(p => p.id === 'a')!;
|
||||
const from = 'polar_sink:0';
|
||||
const destination = TERRITORIES.filter(place => place.type === 'sand' &&
|
||||
!game.players.some(p => Object.keys(p.forces).some(key => key.startsWith(`${place.id}:`))) &&
|
||||
!Object.keys(game.spice).some(key => key.startsWith(`${place.id}:`) && game.spice[key] > 0))
|
||||
.flatMap(place => place.sectors.map(sector => ({territory: place.id, sector})))
|
||||
.find(place => groundMoveRouteDistance(own, seat, from, `${place.territory}:${place.sector}`) === 1 &&
|
||||
botGroundMoveAllowed(own, seat, from, `${place.territory}:${place.sector}`, 0));
|
||||
assert.ok(destination, 'The original staged force needs a clear adjacent movement.');
|
||||
game = applyAction(game, 'a', {type:'move', from, ...destination, amount:1, elite:0});
|
||||
for (let n=0; (game.phaseOpening || game.response || game.decision) && n<100; n++) {
|
||||
const next = nextRicheseSkillsTechNativeStep(game);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(semutaSkillsPlayer(game, 'a').hajrWindow?.turn, game.turn);
|
||||
assert.equal(semutaSkillsPlayer(game, 'a').hajrWindow?.move, semutaSkillsPlayer(game, 'a').moved);
|
||||
}
|
||||
assertAdvancedNativeCustody(game);
|
||||
assert.deepEqual(semutaSkillsInventory(game), cards);
|
||||
for (const player of game.players) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`public ${advanced ? 'Advanced' : 'Basic'} Richese Skills setup displays the bounded Semuta notice`, () => {
|
||||
let game = createGame('SEMUTASKILLSUI', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
game = applyAction(game, game.host, {type:'moduleOption', module:'leaderSkills', enabled:true});
|
||||
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
|
||||
game = applyAction(game, game.host, {type:'start', ...(advanced ? {advancedPreview:true} : {})});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.leaderSkills, 'The selected original Skills module is present');
|
||||
const view = viewGame(game, 'r');
|
||||
assert.equal(view.semutaPreview, true);
|
||||
const html = renderToStaticMarkup(createElement(GameTable, {
|
||||
game:view, busy:false, send:async () => {}, onExit() {},
|
||||
})).replace(/\s+/g, ' ');
|
||||
assert.match(html, /Semuta Drug development preview/);
|
||||
assert.match(html, /bounded fresh-discard reactions/);
|
||||
assert.match(html, /fresh CHOAM-deck Richese games with Leader Skills also enable supported reactions in Basic and Advanced/);
|
||||
assert.match(html, /not complete Richese or certified expansion rules/);
|
||||
assert.match(html, /fourteen original skills and native training/);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
|
||||
import { semutaSkillsInventory } from './fixture-semuta-skills';
|
||||
|
||||
const profiles = [
|
||||
{advanced:false, tech:false, strongholds:false},
|
||||
{advanced:false, tech:true, strongholds:false},
|
||||
{advanced:true, tech:false, strongholds:false},
|
||||
{advanced:true, tech:true, strongholds:false},
|
||||
{advanced:true, tech:false, strongholds:true},
|
||||
{advanced:true, tech:true, strongholds:true},
|
||||
];
|
||||
const clone = (game:Game):Game => JSON.parse(JSON.stringify(game));
|
||||
const player = (game:Game, id:string) => {
|
||||
const seat = game.players.find(candidate => candidate.id === id); assert.ok(seat); return seat;
|
||||
};
|
||||
|
||||
/** Ordinary public lobby actions and real setup, including each player's
|
||||
* actual skill offer and native legal leader selection. The optional modules
|
||||
* are chosen before anyone readies; no saved game is converted. */
|
||||
function start(profile: typeof profiles[number]): Game {
|
||||
let game = createGame('SEMUTASKILLSPUBLIC', newPlayer('r','Richese','richese'), profile.advanced, ['choam']);
|
||||
joinGame(game, newPlayer('a','Atreides','atreides'));
|
||||
joinGame(game, newPlayer('e','Emperor','emperor'));
|
||||
game = applyAction(game, game.host, {type:'moduleOption', module:'leaderSkills', enabled:true});
|
||||
if (profile.tech) game = applyAction(game, game.host, {type:'techTokens', enabled:true});
|
||||
if (profile.strongholds) game = applyAction(game, game.host, {type:'strongholdCards', enabled:true});
|
||||
for (const seat of game.players) game = applyAction(game, seat.id, {type:'ready'});
|
||||
game = applyAction(game, game.host, {type:'start', ...(profile.advanced ? {advancedPreview:true} : {})});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.leaderSkills);
|
||||
assert.equal(game.semutaPreview, true);
|
||||
assert.equal(!!game.techTokens, profile.tech);
|
||||
assert.equal(!!game.strongholdCards, profile.strongholds);
|
||||
for (let n=0; game.status === 'setup' && n<250; n++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer = game.leaderSkills!.offers[actor], own = viewGame(game,actor).leaderSkills!;
|
||||
const skill = offer.cards.find(id => !own.unavailableSkills?.[id]);
|
||||
const leader = own.eligibleLeaders[0];
|
||||
assert.ok(skill && leader);
|
||||
game = applyAction(game,actor,{type:'leaderSkill', event:offer.event, skill, leader:leader.id});
|
||||
} else {
|
||||
const next = nextRicheseSkillsTechNativeStep(game);
|
||||
game = applyAction(game,next.actor,next.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.status,'playing');
|
||||
assert.equal(game.leaderSkills?.assignments.length,game.players.length);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Reach real Movement first; only the physical cards are rearranged afterward.
|
||||
* Move each chosen card from its original deck/cache/hand/discard slot, and
|
||||
* return a recipient's prior card to the same source when its hand is full. */
|
||||
function movement(profile: typeof profiles[number]) {
|
||||
let game = start(profile);
|
||||
for (let n=0; n<1500 && !(game.phase === 5 && !game.phaseOpening && !game.response && !game.decision); n++) {
|
||||
assert.equal(game.turn,1);
|
||||
const next = nextRicheseSkillsTechNativeStep(game);
|
||||
game = applyAction(game,next.actor,next.action);
|
||||
}
|
||||
assert.equal(game.phase,5); assert.ok(!game.phaseOpening && !game.response && !game.decision);
|
||||
for (let n=0; game.active !== 'a' && n<3; n++)
|
||||
game = applyAction(game,game.active!,{type:'endMovement'});
|
||||
assert.equal(game.active,'a');
|
||||
const inventory = semutaSkillsInventory(game);
|
||||
const give = (id:string, identity:string) => {
|
||||
const target = player(game,id);
|
||||
const zones = [game.deck,game.discard,game.richeseCache!,...game.players.map(seat=>seat.hand)];
|
||||
const source = zones.find(zone => zone.some(card=>card.id === identity || card.effect === identity));
|
||||
assert.ok(source,`Original ${identity} must remain in a physical card slot`);
|
||||
const index = source.findIndex(card=>card.id === identity || card.effect === identity);
|
||||
const [card] = source.splice(index,1);
|
||||
if (target.hand.length >= 4) source.push(target.hand.pop()!);
|
||||
target.hand.push(card);
|
||||
return card.id;
|
||||
};
|
||||
const semuta = give('r',SEMUTA_DRUG_ID), hajr = give('a','hajr');
|
||||
assert.deepEqual(semutaSkillsInventory(game),inventory);
|
||||
if (profile.advanced) {
|
||||
// Earn Advanced Hajr's immediate window through an actual paid shipment
|
||||
// to Polar Sink followed by the original legal adjacent Force Movement.
|
||||
game = applyAction(game,'a',{type:'ship', territory:'polar_sink', sector:0, amount:1, elite:0, allyPayment:0});
|
||||
for (let n=0; (game.phaseOpening || game.response || game.decision) && n<100; n++) {
|
||||
const next=nextRicheseSkillsTechNativeStep(game);
|
||||
game=applyAction(game,next.actor,next.action);
|
||||
}
|
||||
assert.equal(game.phase,5); assert.equal(game.active,'a');
|
||||
const own=viewGame(game,'a'), seat=own.players.find(candidate=>candidate.id==='a')!;
|
||||
const from='polar_sink:0';
|
||||
const destination=TERRITORIES.filter(place=>place.type==='sand' &&
|
||||
!game.players.some(candidate=>Object.keys(candidate.forces).some(key=>key.startsWith(`${place.id}:`))) &&
|
||||
!Object.keys(game.spice).some(key=>key.startsWith(`${place.id}:`) && game.spice[key]>0))
|
||||
.flatMap(place=>place.sectors.map(sector=>({territory:place.id,sector})))
|
||||
.find(place=>groundMoveRouteDistance(own,seat,from,`${place.territory}:${place.sector}`)===1 &&
|
||||
botGroundMoveAllowed(own,seat,from,`${place.territory}:${place.sector}`,0));
|
||||
assert.ok(destination,'The shipped counter must have a clear adjacent sand movement.');
|
||||
game=applyAction(game,'a',{type:'move',from,...destination,amount:1,elite:0});
|
||||
for (let n=0; (game.phaseOpening || game.response || game.decision) && n<100; n++) {
|
||||
const next=nextRicheseSkillsTechNativeStep(game);
|
||||
game=applyAction(game,next.actor,next.action);
|
||||
}
|
||||
assert.equal(player(game,'a').hajrWindow?.turn,game.turn);
|
||||
assert.equal(player(game,'a').hajrWindow?.move,player(game,'a').moved);
|
||||
}
|
||||
return {game, semuta, hajr, inventory};
|
||||
}
|
||||
|
||||
for (const profile of profiles) {
|
||||
const label = `${profile.advanced?'Advanced':'Basic'}${profile.tech?' Tech':''}${profile.strongholds?' Strongholds':''}`;
|
||||
void test(`${label} public Skills start preserves the real Hajr to Semuta claim/pass continuation`, () => {
|
||||
const f = movement(profile), before = clone(f.game);
|
||||
const pending = applyAction(clone(before),'a',{type:'card',card:f.hajr});
|
||||
assert.equal(pending.pendingTreacheryDiscard?.batch.cause,'ordinary:hajr');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.equal(pending.active,'a'); assert.equal(pending.phase,5);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const claim of [false,true]) {
|
||||
let next = clone(pending);
|
||||
if (claim) next = applyAction(next,'r',{type:'semutaCommit',event});
|
||||
else for (const seat of next.players) next = applyAction(next,seat.id,{type:'semutaPass',event});
|
||||
assert.equal(next.pendingTreacheryDiscard,null);
|
||||
assert.equal(next.active,'a'); assert.equal(next.phase,5);
|
||||
assert.equal(next.hajr.filter(id=>id==='a').length,1);
|
||||
assert.equal(player(next,'r').hand.some(card=>card.id===f.hajr),claim);
|
||||
assert.equal(player(next,'r').hand.some(card=>card.id===f.semuta),!claim);
|
||||
assert.deepEqual(semutaSkillsInventory(next),f.inventory);
|
||||
assert.deepEqual(next.leaderSkills,before.leaderSkills);
|
||||
const original = clone(next);
|
||||
assert.throws(()=>applyAction(next,'r',{type:'semutaCommit',event}));
|
||||
assert.deepEqual(clone(next),original);
|
||||
}
|
||||
for (const level of DIFFICULTIES) {
|
||||
const own = viewGame(pending,'r');
|
||||
own.players.find(seat=>seat.id==='r')!.bot=level;
|
||||
const action = botActions(own).find(candidate=>candidate.type==='semutaCommit' || candidate.type==='semutaPass');
|
||||
assert.ok(action,`${level} must answer the original discard reaction legally`);
|
||||
assert.doesNotThrow(()=>applyAction(clone(pending),'r',action));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('a public Skills game without native Richese does not gain a Semuta preview', () => {
|
||||
let game = createGame('SEMUTASKILLSOTHER',newPlayer('c','CHOAM','choam'),false,['choam']);
|
||||
joinGame(game,newPlayer('a','Atreides','atreides'));
|
||||
joinGame(game,newPlayer('e','Emperor','emperor'));
|
||||
game = applyAction(game,game.host,{type:'moduleOption',module:'leaderSkills',enabled:true});
|
||||
for (const seat of game.players) game = applyAction(game,seat.id,{type:'ready'});
|
||||
game = applyAction(game,game.host,{type:'start'});
|
||||
assert.equal(game.status,'setup');
|
||||
assert.ok(game.leaderSkills);
|
||||
assert.equal(game.semutaPreview,undefined);
|
||||
});
|
||||
Reference in new issue
Block a user