Connect genuine Ix expansion setup to playable prototypes
Add an offline Ix initializer through the real setup pipeline, supporting Basic and Advanced play, base factions, Ixians and Tleilaxu with the full 47-card deck and 22-card spice deck. Preserve all normal start and publication gates. Add a reusable local prototype starter: back up the named fresh ready lobby, initialize it once by exact version/state comparison and preserve seat/recovery tables and other rooms. Update the existing checklist with a separate prototype stage and document functional scope, source interpretations and later refinement. Six new tests cover all-profile private setup, exact typed starting forces, Face Dancer/treachery custody, JSON continuation, two fixed-seed complete games and local SQLite write/rejection boundaries. Independent review caught a missing JSON version update, now fixed and covered; focused checking fixed one test typing error before the stable broad check. No new rule interpretation was introduced. Validation: types, lint and 4,211 offline tests passed (123.237s); production build passed (6.543s); all 40 HTTP tests passed (23.578s). Source-bound report: 20260913-ix-prototype-final/report.json, fingerprint 3b8f295593bcb314bb72308d81739006b11d949d1e5906dbf6ce0704b15ebd72. Browser W5LE6VFB: human Ixian + Medium Tleilaxu + Hard Atreides from genuine setup, private starting-card refresh, Weather Control, mobile stronghold placement, then owned Brutal autopilot. Atreides won on turn 1; this short complete journey is not proof of every card or faction interaction. Finished private hand restored at phone width; expanded Poison Tooth was readable. Fixed-seed offline games 2026091301 (Basic) and 2026091302 (Advanced) both completed with both Ix factions. All 64 opening room versions/hashes are unchanged. One browser room and 30 HTTP rooms were added, totaling 95. Reused the running server; no restart or automation. Aggregate goal counters: 59,524,858 to 59,976,567 at completed verification, 451,709 tokens before final Git overhead. Two bounded workhorse helpers handled inventory/tests and independent review; one stable full check/build/HTTP run. Full game scope remains active and unfinished; future work prioritizes missing functional prototypes before exhaustive refinement and final acceptance.
This commit is contained in:
1 parent
194471e906
commit
5a76ae0410
12 files changed
+736
-9
No files matched your search
@@ -72,6 +72,7 @@ function RulesReferenceContent() {
|
||||
t.title,
|
||||
t.summary,
|
||||
t.searchText ?? '',
|
||||
t.developmentStage ?? '',
|
||||
...t.steps,
|
||||
t.example ?? '',
|
||||
...(t.checklist ?? []).flatMap((item) => [
|
||||
@@ -173,6 +174,11 @@ function RulesReferenceContent() {
|
||||
>
|
||||
{topic.coverage}
|
||||
</span>
|
||||
{topic.developmentStage && (
|
||||
<span className="coverage-label">
|
||||
Development: {topic.developmentStage}
|
||||
</span>
|
||||
)}
|
||||
<p>{topic.summary}</p>
|
||||
</summary>
|
||||
<div className="rule-topic-content">
|
||||
|
||||
@@ -45,6 +45,12 @@ Likewise, the [Truthtrance readiness audit](TRUTHTRANCE_NONBATTLE_READINESS_2026
|
||||
|
||||
## Next milestone: rapid functional coverage
|
||||
|
||||
The first connected batch is the [Ixians & Tleilaxu prototype](IX_PROTOTYPE.md):
|
||||
genuine setup, both expansion factions, full Ix deck and Sandtrout, shared human/AI
|
||||
controls and saved continuation. The checklist now distinguishes its Prototyped
|
||||
development stage from Partial rules coverage. Discoveries remain missing; their
|
||||
seven spice cards and eight token faces need a verified inventory and adapters.
|
||||
|
||||
The user changed the development order on 13 September: get working prototypes
|
||||
of all remaining functions into the game, then use integration and play to find
|
||||
issues and refine them. Complete core certification no longer precedes independent
|
||||
|
||||
@@ -8,6 +8,9 @@ Start with [current status](CURRENT_STATUS.md) and [rule decisions](RULE_DECISIO
|
||||
Use [repeatable verification](VERIFICATION_WORKFLOW.md) for compact source-bound
|
||||
logs, private saved-game backups/comparisons and HTTP seat restoration.
|
||||
The [three-milestone pilot](EFFICIENCY_PILOT.md) records measurement limits.
|
||||
Use the [Ix prototype workflow](IX_PROTOTYPE.md) to connect a fresh local ready
|
||||
lobby to genuine expansion setup with a private backup and preserved sessions.
|
||||
It does not open normal mode gates or require restarting the server.
|
||||
|
||||
## Architecture map
|
||||
|
||||
|
||||
@@ -49,6 +49,15 @@ goal-steering overhead. It is an audit batch, not a new gameplay capability.
|
||||
|
||||
## User-directed rapid prototyping, 13 September 2026
|
||||
|
||||
The first batch starts at aggregate counter 59,524,858 and connects genuine Ix
|
||||
setup to the existing faction/card gameplay and saved browser table. One bounded
|
||||
helper inventoried existing work and implemented focused tests; a second reviewed
|
||||
setup privacy and the local database writer. The owner integrates the prototype
|
||||
entry, reusable backed-up starter, checklist and browser journey. Six added tests
|
||||
include two fixed-seed complete games without a calibration study. A saved-version
|
||||
review finding and a test typing error were fixed before the stable broad check;
|
||||
final results and counter endpoint are recorded in the checkpoint commit.
|
||||
|
||||
Prioritize functional coverage of the entire remaining game before exhaustive
|
||||
per-feature refinement. Use the existing checklist to distinguish missing,
|
||||
prototyped, integrated, verified and polished functions, and choose connected
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 September 2026 — Genuine Ix expansion prototype
|
||||
|
||||
Connected Basic/Advanced Ix setup with both expansion factions, the complete
|
||||
47-card Treachery Deck and Sandtrout through the existing game pipeline. A local
|
||||
prototype command backs up the named fresh lobby, starts it once with a version
|
||||
comparison, and preserves every seat and other room. The normal start gates
|
||||
remain closed. The checklist now separates Prototyped progress from Partial
|
||||
rules coverage. See [Ix prototype](IX_PROTOTYPE.md).
|
||||
|
||||
Six new focused tests cover private setup at all four profiles, exact starting
|
||||
forces, Face Dancers, cards, JSON restoration, immutable rejection and the local
|
||||
SQLite writer. Two reproducible full games use seeds 2026091301 (Basic) and
|
||||
2026091302 (Advanced), including both expansion factions and physical inventory
|
||||
checks. Review caught a saved JSON version mismatch, now corrected and covered.
|
||||
Browser room `W5LE6VFB` verifies genuine human Ixian setup, private refresh,
|
||||
Weather Control and mobile stronghold placement; owned autopilot then continues
|
||||
the same game. Final broad checks, later browser outcome and preservation results
|
||||
belong to the verified checkpoint. No complete expansion certification is claimed.
|
||||
|
||||
|
||||
## 13 September 2026 — Atreides full-plan interaction verification
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Ixians & Tleilaxu functional prototype
|
||||
|
||||
13 September 2026. This connects the existing expansion mechanics to genuine
|
||||
setup and an ordinary saved browser table. It is a **working prototype**, with
|
||||
rules coverage still partial. Normal Advanced/expansion start gates and the
|
||||
publication gate remain closed.
|
||||
|
||||
## Playable boundary
|
||||
|
||||
`initializeIxGameForAudit` uses the same staged setup as the game: prediction,
|
||||
private traitors, starting forces, private Ixian starting-card selection, then
|
||||
the automatic deal and first Storm. It accepts Basic or Advanced, two through
|
||||
six ready players, base factions plus Ixians and Tleilaxu, and exactly the Ix
|
||||
expansion. It includes the 47 physical Treachery Cards and the 22-card Spice
|
||||
Deck containing Sandtrout from the outset. It does not inject cards or change
|
||||
factions after setup. Tech Tokens, Stronghold Cards, Homeworlds, Nexus Cards and
|
||||
combined expansion decks remain separate integration work.
|
||||
|
||||
The actual player controls, private views, bot actions and saved game format
|
||||
are reused. The initializer cannot redeal a started or nonempty game, and no
|
||||
player action or HTTP endpoint dispatches it. Existing card outcomes and the
|
||||
interpretations documented in the [Ix deck](../game/reference.ts),
|
||||
[component inventory](COMPONENT_INVENTORY.md), [Ixian Cunning sources](NEXUS_IXIAN_CUNNING_RULES.md)
|
||||
and [setup contract](ADVANCED_SETUP_TEST_SEAM.md) are preserved. In particular,
|
||||
the Thumper/Sandtrout timing, repeated Harvester effects and remaining faction
|
||||
interactions are not settled by this prototype.
|
||||
|
||||
## Start a local prototype table
|
||||
|
||||
Create a **new** local browser room with the Ixians & Tleilaxu expansion selected,
|
||||
choose its human/AI factions, and mark everyone ready. Retain its room cookie.
|
||||
Use the current database version of that named lobby with:
|
||||
|
||||
```sh
|
||||
node --import tsx tools/start-prototype.ts \
|
||||
--db PATH_TO_LOCAL_D1_SQLITE \
|
||||
--room EIGHT_CHARACTER_CODE \
|
||||
--version CURRENT_LOBBY_VERSION \
|
||||
--out /private/new-prototype-checkpoint
|
||||
```
|
||||
|
||||
The output directory must be new and outside the repository. The command first
|
||||
captures an online backup and saved-room hashes, then initializes only that
|
||||
fresh ready Ix lobby. An atomic version-and-state comparison rejects a concurrent
|
||||
lobby change. It leaves every seat/recovery record and other room untouched.
|
||||
Its private result records the source revision/fingerprint and before/after
|
||||
versions. Refresh the invitation to continue through normal private setup.
|
||||
Keep the resulting game; rerunning setup on it is rejected. Reuse the running
|
||||
server—this workflow does not require a restart.
|
||||
|
||||
## Evidence and remaining work
|
||||
|
||||
`tests/ix-prototype.test.ts` exercises genuine Basic/Advanced setup at all four
|
||||
AI profiles, private Ixian choices, Face Dancer custody, starting hands and
|
||||
JSON restoration. Two complete mixed-profile games include both expansion
|
||||
factions, checking final force/card inventory and winners. These sample games
|
||||
do not prove every card was played or measure difficulty ordering.
|
||||
|
||||
`tests/prototype-room.test.ts` covers the local saved-room entry, unchanged
|
||||
sessions/other rooms, stale version and redeal rejection, and invalid lobby
|
||||
immutability. Independent review caught and fixed a missing JSON version update
|
||||
in the prototype writer; the database row and saved game now advance together.
|
||||
It uses in-memory SQLite. Browser room `W5LE6VFB` was created normally with a
|
||||
human Ixian, Medium Tleilaxu and Hard Atreides. It reached private traitor and
|
||||
starting-card choices through real setup, restored the same card offer after
|
||||
refresh, played Weather Control, placed the mobile stronghold at Sihaya Ridge 9
|
||||
and handed the owned seat to Brutal autopilot. No post-setup card/force staging
|
||||
was used. Its later outcome and final checks are recorded with the checkpoint.
|
||||
The earlier writer's version mismatch was corrected by the first ordinary
|
||||
game action; current browser state and database version were confirmed equal.
|
||||
Expand combination and rules coverage during
|
||||
the later refinement pass; continue prototyping independent missing functions.
|
||||
+14
-5
@@ -4919,7 +4919,14 @@ export function initializeNexusGameForAudit(state: Game): Game {
|
||||
'Enable Nexus cards in a fresh audit lobby first.');
|
||||
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false): Game {
|
||||
/** Offline prototype entry through real Ix setup, including both expansion factions.
|
||||
* No player action or room API bypasses the normal release gates. */
|
||||
export function initializeIxGameForAudit(state: Game): Game {
|
||||
requireRule(state.expansions.length === 1 && state.expansions[0] === 'ix',
|
||||
'The Ix prototype requires exactly the Ixians & Tleilaxu expansion.');
|
||||
return initializeSetupGameForAudit(state, false, false, true);
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false): Game {
|
||||
nexusCardsIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -4941,17 +4948,19 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
'The audit initializer requires two through six distinct ready players and an existing host.',
|
||||
);
|
||||
requireRule(
|
||||
(homeworlds || nexus || g.expansions.length === 0) &&
|
||||
(homeworlds || nexus || ix || g.expansions.length === 0) &&
|
||||
(nexus || !g.nexusCards) &&
|
||||
!g.techTokens &&
|
||||
!g.strongholdCards &&
|
||||
(homeworlds || !g.homeworlds) &&
|
||||
g.players.every((p) =>
|
||||
FACTIONS.some(
|
||||
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base'),
|
||||
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix')),
|
||||
),
|
||||
),
|
||||
homeworlds
|
||||
ix
|
||||
? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.'
|
||||
: homeworlds
|
||||
? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
|
||||
: 'The audit initializer supports base factions without expansions or optional modules.',
|
||||
);
|
||||
@@ -4979,7 +4988,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
!p.prediction &&
|
||||
!p.advisorSetup &&
|
||||
(!p.elites ||
|
||||
((homeworlds || nexus) &&
|
||||
((homeworlds || nexus || ix) &&
|
||||
p.faction === 'ixians' &&
|
||||
p.elites.reserves === 7 &&
|
||||
p.elites.tanks === 0 &&
|
||||
|
||||
+15
-4
@@ -29,6 +29,8 @@ export type RuleTopic = {
|
||||
| 'Factions'
|
||||
| 'Advanced & expansions';
|
||||
coverage: 'Implemented' | 'Partial' | 'Planned';
|
||||
/** Development progress is separate from the scope of verified rules coverage. */
|
||||
developmentStage?: 'Missing' | 'Prototyped' | 'Integrated' | 'Verified' | 'Polished';
|
||||
summary: string;
|
||||
searchText?: string;
|
||||
steps: string[];
|
||||
@@ -192,6 +194,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'The linked topics contain a five-part checklist. Implemented describes only the stated feature boundary; Partial identifies remaining work and Planned means it is not active.',
|
||||
'Verification entries identify focused regression suites. Passing those checks does not certify every interaction, browser journey or complete expansion game.',
|
||||
'Development stages distinguish Missing functions, working Prototypes, Integrated functions, Verified behavior and Polished presentation. A prototype stage does not certify complete rules coverage; check the stated limits and evidence.',
|
||||
'This checklist is being extended across the rules. Features without a checklist still have a topic-level coverage label and are not implicitly complete.',
|
||||
'Advanced and full expansion faction starts remain disabled. Development fixture coverage is not permission to start an unsupported mode.',
|
||||
],
|
||||
@@ -1382,6 +1385,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
title: 'Ixians & Tleilaxu treachery deck',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary:
|
||||
'Fourteen expansion cards join the thirty-three base cards; matching names still represent distinct physical cards.',
|
||||
steps: [
|
||||
@@ -1390,7 +1394,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The additional Shield and Snooper behave like their base copies, including the Shield’s lasgun interaction. Kull Wahad is a Worthless card and can be played in a battle plan or converted with advanced Bene Gesserit’s Karama advantage.',
|
||||
'The second Harvester is a separate card. Each available copy can be played while the fresh blow window remains open; the current implementation doubles that blow again, without multiplying pre-existing spice. Neither copy can restore a worm-destroyed blow or create spice in storm. Multiple-copy timing and multiplier interpretation remain under card-face audit.',
|
||||
'With this expansion the combined deck has forty-seven distinct card identities: five ordinary projectile weapons, five ordinary poison weapons, five ordinary Shields, five ordinary Snoopers, six Worthless cards and two Harvesters, in addition to the other base and special expansion cards.',
|
||||
'The complete inventory is assembled in the engine, with internal guidance and AI card recognition. Expansion starts remain disabled pending remaining rule and interaction audits; this is not a claim of complete expansion compliance.',
|
||||
'The local development prototype uses genuine setup with the complete deck, Sandtrout, Ixians and Tleilaxu in Basic or Advanced play. Starting cards, private choices, controls, AI and saved continuation use the normal game paths. Public expansion starts remain disabled pending complete rule and interaction acceptance.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Working prototype with the actual 47-card setup, both expansion factions and Sandtrout. Optional module combinations and remaining printed-card interpretations are unfinished.', evidence: ['game/cards.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'Prototype rooms use the existing private setup, faction, battle-card and special-card controls. Local development initialization preserves the normal release gate.', evidence: ['components/game-table.tsx', 'tools/start-prototype.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles share the existing legal private actions. Complete-game prototype runs exercise both expansion factions; comprehensive strength calibration remains later work.', evidence: ['game/bots.ts', 'tests/ix-prototype.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'The prototype setup record separates functional progress, source interpretations and final acceptance.', evidence: ['docs/IX_PROTOTYPE.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Genuine setup, complete prototype games, private views, physical-card custody and saved continuation have targeted checks. This does not certify every combination.', evidence: ['tests/ix-prototype.test.ts', 'tests/prototype-room.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'ix-battle-cards',
|
||||
@@ -1648,7 +1659,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'A pending spice-card effect that can cross phases and turns. Tested support exists; the expansion spice deck remains unavailable in live rooms.',
|
||||
'A pending spice-card effect that can cross phases and turns. It is included in local Ix prototype setup; normal expansion starts remain gated.',
|
||||
steps: [
|
||||
'Drawing Sandtrout immediately breaks existing alliances and clears pending alliance offers. It is then held aside while spice draws continue. A territory drawn before the next active Shai-Hulud receives its usual spice amount.',
|
||||
'The next active Shai-Hulud drawn is suppressed: it does not devour the previous territory, offer Fremen protection or a ride, or trigger its own Nexus. Discard the consumed Sandtrout with that suppressed worm.',
|
||||
@@ -1670,7 +1681,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'A poison weapon with an optional attack on both leaders. Engine support exists; expansion decks remain unavailable.',
|
||||
'A poison weapon with an optional attack on both leaders. It is included in the local Ix prototype deck; normal expansion starts remain gated.',
|
||||
steps: [
|
||||
'Commit Poison Tooth in your weapon slot. After both plans are revealed, its owner chooses whether to activate it before traitor decisions proceed. Committing the card does not yet activate its poison.',
|
||||
'When activated, the tooth attacks both leaders, including its owner’s leader. Chemistry in the defense slot protects that leader. Snooper and Shield Snooper do not protect against the tooth. A Chemistry card used as a weapon supplies no defense.',
|
||||
@@ -1712,7 +1723,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Voice can name one of these special cards directly or use a matching generic attack or defense type. A prohibition uses the role the card actually plays. For example, forbidding poison weapons still allows Chemistry as poison defense.',
|
||||
'Compulsion uses the default role. Requiring a projectile weapon can compel Weirding Way as a weapon. Requiring a projectile defense cannot force a player to turn Weirding Way into that defense. Likewise, requiring a poison weapon cannot force Chemistry out of its default defense role.',
|
||||
'Prescience reveals the card occupying the requested slot. With a poison weapon and Weirding Way, a weapon question reveals the poison weapon; a defense question reveals Weirding Way. The other card remains private, and the revealed slot must remain unchanged.',
|
||||
'These cards are not yet added to live room decks. Poison Tooth and Artillery Strike also have tested effects described in their own topics. Remaining Ix treachery cards, complete deck setup and expansion interactions are still in development.',
|
||||
'The local Ix prototype deals the full expansion deck through genuine setup. Poison Tooth and Artillery Strike have their own topics. Complete rules compliance and combined expansion interactions remain unfinished; prototype access does not open normal expansion starts.',
|
||||
],
|
||||
related: [
|
||||
'battle',
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import { spiceDeck, treacheryDeck } from '../game/cards';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeIxGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
|
||||
const ROSTER: [string, FactionId][] = [
|
||||
['i', 'ixians'],
|
||||
['t', 'tleilaxu'],
|
||||
['a', 'atreides'],
|
||||
['h', 'harkonnen'],
|
||||
];
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
|
||||
function generator(seed: number) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let word = Math.imul(state ^ (state >>> 15), state | 1);
|
||||
word ^= word + Math.imul(word ^ (word >>> 7), word | 61);
|
||||
return (word ^ (word >>> 14)) >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
function withEngineSeed<T>(seed: number, fn: () => T): T {
|
||||
const cryptoObject = globalThis.crypto;
|
||||
const originalRandom = Object.getOwnPropertyDescriptor(
|
||||
cryptoObject,
|
||||
'getRandomValues',
|
||||
);
|
||||
const originalUuid = Object.getOwnPropertyDescriptor(
|
||||
cryptoObject,
|
||||
'randomUUID',
|
||||
);
|
||||
const randomWord = generator(seed);
|
||||
const uuidWord = generator(seed ^ 0xa5a5a5a5);
|
||||
Object.defineProperty(cryptoObject, 'getRandomValues', {
|
||||
configurable: true,
|
||||
value: <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (!(array instanceof Uint32Array))
|
||||
throw new Error(
|
||||
`Ix prototype seed ${seed} supports only the engine Uint32Array RNG contract.`,
|
||||
);
|
||||
for (let i = 0; i < array.length; i++) array[i] = randomWord();
|
||||
return array;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(cryptoObject, 'randomUUID', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
const hex = Array.from({ length: 4 }, () =>
|
||||
uuidWord().toString(16).padStart(8, '0'),
|
||||
)
|
||||
.join('')
|
||||
.split('');
|
||||
hex[12] = '4';
|
||||
hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16);
|
||||
const id = hex.join('');
|
||||
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
|
||||
},
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (originalRandom)
|
||||
Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom);
|
||||
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
|
||||
if (originalUuid)
|
||||
Object.defineProperty(cryptoObject, 'randomUUID', originalUuid);
|
||||
else Reflect.deleteProperty(cryptoObject, 'randomUUID');
|
||||
}
|
||||
}
|
||||
|
||||
function lobby(advanced = false, roster = ROSTER): Game {
|
||||
const [first, ...rest] = roster;
|
||||
let g = createGame(
|
||||
'IXPROTOTYPE',
|
||||
newPlayer(first[0], first[0], first[1]),
|
||||
advanced,
|
||||
['ix'],
|
||||
);
|
||||
for (const [id, faction] of rest) joinGame(g, newPlayer(id, id, faction));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
return g;
|
||||
}
|
||||
|
||||
function candidates(g: Game, id: string, difficulty: Difficulty) {
|
||||
const view = viewGame(g, id);
|
||||
view.players.find((p) => p.id === id)!.bot = difficulty;
|
||||
const before = structuredClone(view);
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(view, before);
|
||||
for (const action of actions)
|
||||
assert.doesNotThrow(
|
||||
() => applyAction(g, id, action),
|
||||
`${difficulty}/${g.setupStage}/${g.decision?.kind}/${id}: ${JSON.stringify(action)}`,
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
function reachIxChoice(state: Game, difficulty: Difficulty): Game {
|
||||
let g = state;
|
||||
for (let step = 0; g.status === 'setup' && step < 24; step++) {
|
||||
if (g.decision?.kind === 'ixSetup') return g;
|
||||
let progressed = false;
|
||||
for (const p of g.players) {
|
||||
const actions = candidates(g, p.id, difficulty);
|
||||
if (!actions.length) continue;
|
||||
g = applyAction(g, p.id, actions[0]);
|
||||
progressed = true;
|
||||
break;
|
||||
}
|
||||
assert.ok(progressed, `Ix setup stalled at ${g.setupStage}.`);
|
||||
}
|
||||
assert.fail('Ix setup did not reach its private starting-card choice.');
|
||||
}
|
||||
|
||||
function assertTreacheryInventory(g: Game) {
|
||||
const cards = [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...(g.ixSetupCards ?? []),
|
||||
...(g.ixAuction?.cards ?? []),
|
||||
...(g.auction?.cards.slice(g.auction.index) ?? []),
|
||||
];
|
||||
const expected = treacheryDeck(['ix'])
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
assert.equal(cards.length, 47);
|
||||
assert.equal(new Set(cards.map((card) => card.id)).size, 47);
|
||||
assert.deepEqual(cards.map((card) => card.id).sort(), expected);
|
||||
}
|
||||
|
||||
function assertSetupResult(g: Game) {
|
||||
assert.equal(g.status, 'playing');
|
||||
assert.equal(g.setupStage ?? null, null);
|
||||
assert.equal(g.ixSetupCards, null);
|
||||
assert.ok(g.phaseOpening);
|
||||
assert.deepEqual(g.expansions, ['ix']);
|
||||
assert.equal(g.players.find((p) => p.id === 'i')!.hand.length, 1);
|
||||
assert.equal(g.players.find((p) => p.id === 't')!.hand.length, 1);
|
||||
assert.equal(g.players.find((p) => p.id === 'a')!.hand.length, 1);
|
||||
assert.equal(g.players.find((p) => p.id === 'h')!.hand.length, 2);
|
||||
const ixians = g.players.find((p) => p.id === 'i')!;
|
||||
assert.equal(ixians.spice, 10);
|
||||
assert.equal(ixians.reserves, 14);
|
||||
assert.deepEqual(ixians.forces, { [MOBILE_LOCATION]: 6 });
|
||||
assert.equal(ixians.elites?.reserves, 4);
|
||||
assert.deepEqual(ixians.elites!.forces, { [MOBILE_LOCATION]: 3 });
|
||||
assert.equal(g.mobileStronghold?.location, null);
|
||||
const tleilaxu = g.players.find((p) => p.id === 't')!;
|
||||
assert.equal(tleilaxu.spice, 5);
|
||||
assert.equal(tleilaxu.reserves, 20);
|
||||
assert.deepEqual(tleilaxu.forces, {});
|
||||
assert.equal(tleilaxu.traitors.length, 0);
|
||||
assert.equal(tleilaxu.faceDancers?.length, 3);
|
||||
assert.equal(
|
||||
new Set(tleilaxu.faceDancers?.map((card) => card.leader)).size,
|
||||
3,
|
||||
);
|
||||
for (const owner of g.players) {
|
||||
const own = viewGame(g, owner.id).players.find((p) => p.id === owner.id)!;
|
||||
assert.deepEqual(own.hand, owner.hand);
|
||||
assert.deepEqual(own.traitors, owner.traitors);
|
||||
if (owner.id === 't') assert.deepEqual(own.faceDancers, owner.faceDancers);
|
||||
for (const observer of g.players.filter((p) => p.id !== owner.id)) {
|
||||
const hidden = viewGame(g, observer.id).players.find(
|
||||
(p) => p.id === owner.id,
|
||||
)!;
|
||||
assert.equal(hidden.hand, undefined);
|
||||
assert.equal(hidden.traitors, undefined);
|
||||
assert.equal(hidden.faceDancers, undefined);
|
||||
}
|
||||
}
|
||||
const traitors = [
|
||||
...(g.traitorReserve ?? []),
|
||||
...g.players.flatMap((p) => p.traitors),
|
||||
...(tleilaxu.faceDancers ?? []).map((card) => card.leader),
|
||||
];
|
||||
assert.equal(traitors.length, 21);
|
||||
assert.equal(new Set(traitors).size, 21);
|
||||
assertTreacheryInventory(g);
|
||||
}
|
||||
|
||||
function rejectInitializer(g: Game, pattern?: RegExp) {
|
||||
const before = structuredClone(g);
|
||||
if (pattern) assert.throws(() => initializeIxGameForAudit(g), pattern);
|
||||
else assert.throws(() => initializeIxGameForAudit(g));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
function finishGame(initial: Game, seed: number): Game {
|
||||
let g = initial;
|
||||
for (let batch = 0; batch < 240 && g.status !== 'finished'; batch++) {
|
||||
g = runBots(reload(g));
|
||||
assert.ok(
|
||||
g.status === 'finished' || g.botsPending,
|
||||
`Ix prototype seed ${seed} stalled at turn ${g.turn}, phase ${g.phase}.`,
|
||||
);
|
||||
}
|
||||
assert.equal(g.status, 'finished', `Ix prototype seed ${seed}.`);
|
||||
assert.ok(g.winner.length > 0, `Ix prototype seed ${seed} has no winner.`);
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('all AI profiles complete genuine private Ix setup through JSON continuation', () => {
|
||||
for (const difficulty of DIFFICULTIES)
|
||||
for (const advanced of [false, true]) {
|
||||
const initial = lobby(advanced);
|
||||
const before = structuredClone(initial);
|
||||
let g = initializeIxGameForAudit(initial);
|
||||
assert.deepEqual(initial, before);
|
||||
assert.equal(g.status, 'setup');
|
||||
assert.equal(g.deck.length, 47);
|
||||
assert.equal(g.spiceDeck.length, 22);
|
||||
assert.equal(g.spiceDeck.filter((card) => 'sandtrout' in card).length, 1);
|
||||
assert.equal(g.spiceDeck.length, spiceDeck(true).length);
|
||||
assert.ok(g.players.every((p) => p.hand.length === 0));
|
||||
|
||||
g = reachIxChoice(g, difficulty);
|
||||
assert.equal(g.ixSetupCards?.length, ROSTER.length);
|
||||
const ids = g.ixSetupCards!.map((card) => card.id);
|
||||
assert.deepEqual(viewGame(g, 'i').ixTechnology?.setup, g.ixSetupCards);
|
||||
for (const observer of ['t', 'a', 'h']) {
|
||||
const view = viewGame(g, observer);
|
||||
assert.equal(view.ixTechnology, null);
|
||||
const serialized = JSON.stringify(view);
|
||||
for (const id of ids) assert.equal(serialized.includes(id), false);
|
||||
}
|
||||
|
||||
const rejected = structuredClone(g);
|
||||
assert.throws(() =>
|
||||
applyAction(g, 't', { type: 'decision', card: ids[0] }),
|
||||
);
|
||||
assert.deepEqual(g, rejected);
|
||||
assert.throws(() =>
|
||||
applyAction(g, 'i', { type: 'decision', card: 'missing-card' }),
|
||||
);
|
||||
assert.deepEqual(g, rejected);
|
||||
|
||||
const restored = reload(g);
|
||||
const choice = candidates(restored, 'i', difficulty)[0];
|
||||
assert.equal(choice.type, 'decision');
|
||||
g = applyAction(restored, 'i', choice);
|
||||
assertSetupResult(g);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [advanced, seed] of [
|
||||
[false, 2026091301],
|
||||
[true, 2026091302],
|
||||
] as const)
|
||||
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Ix prototype seed ${seed} completes with mixed AI and conserved pieces`, () =>
|
||||
withEngineSeed(seed, () => {
|
||||
let g = lobby(advanced);
|
||||
g.players.forEach((p, index) => {
|
||||
p.bot = DIFFICULTIES[index];
|
||||
});
|
||||
g = initializeIxGameForAudit(g);
|
||||
assert.equal(g.status, 'setup');
|
||||
assert.equal(g.deck.length, 47);
|
||||
assert.equal(g.spiceDeck.length, 22);
|
||||
g = finishGame(g, seed);
|
||||
assertTreacheryInventory(g);
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
p.reserves +
|
||||
p.tanks +
|
||||
Object.values(p.forces).reduce((sum, count) => sum + count, 0),
|
||||
20,
|
||||
`${p.id} in seed ${seed}`,
|
||||
);
|
||||
const spiceCards = [...g.spiceDeck, ...g.spiceDiscard.flat()].filter(
|
||||
(card) => !('worm' in card && card.thumper),
|
||||
);
|
||||
assert.equal(
|
||||
spiceCards.length + Number(g.sandtrout),
|
||||
22,
|
||||
`Spice deck in seed ${seed}`,
|
||||
);
|
||||
assert.equal(
|
||||
spiceCards.filter((card) => 'sandtrout' in card).length +
|
||||
Number(g.sandtrout),
|
||||
1,
|
||||
`Sandtrout in seed ${seed}`,
|
||||
);
|
||||
}));
|
||||
|
||||
void test('the Ix prototype keeps public gates and rejects unsupported or nonempty lobbies without mutation', () => {
|
||||
const publicLobby = lobby(false, [
|
||||
['a', 'atreides'],
|
||||
['h', 'harkonnen'],
|
||||
]);
|
||||
const publicBefore = structuredClone(publicLobby);
|
||||
assert.throws(
|
||||
() => applyAction(publicLobby, publicLobby.host, { type: 'start' }),
|
||||
/Expansion rules are still being implemented/,
|
||||
);
|
||||
assert.deepEqual(publicLobby, publicBefore);
|
||||
|
||||
const absent = createGame('NOIXPROTO', newPlayer('a', 'a', 'atreides'));
|
||||
absent.players.push(newPlayer('h', 'h', 'harkonnen'));
|
||||
absent.players.forEach((p) => (p.ready = true));
|
||||
rejectInitializer(absent, /requires exactly/);
|
||||
|
||||
const combined = createGame(
|
||||
'COMBINEDPROTO',
|
||||
newPlayer('a', 'a', 'atreides'),
|
||||
false,
|
||||
['ix', 'choam'],
|
||||
);
|
||||
combined.players.push(newPlayer('h', 'h', 'harkonnen'));
|
||||
combined.players.forEach((p) => (p.ready = true));
|
||||
rejectInitializer(combined, /requires exactly/);
|
||||
|
||||
const unsupported = createGame(
|
||||
'FACTIONPROTO',
|
||||
newPlayer('c', 'c', 'choam'),
|
||||
false,
|
||||
['ix'],
|
||||
);
|
||||
unsupported.players.push(newPlayer('a', 'a', 'atreides'));
|
||||
unsupported.players.forEach((p) => (p.ready = true));
|
||||
rejectInitializer(unsupported, /base, Ixian and Tleilaxu/);
|
||||
|
||||
const moduleFactories: Array<[string, (g: Game) => Game]> = [
|
||||
[
|
||||
'tech',
|
||||
(g) => applyAction(g, g.host, { type: 'techTokens', enabled: true }),
|
||||
],
|
||||
[
|
||||
'homeworld',
|
||||
(g) => applyAction(g, g.host, { type: 'homeworlds', enabled: true }),
|
||||
],
|
||||
[
|
||||
'stronghold',
|
||||
(g) => applyAction(g, g.host, { type: 'strongholdCards', enabled: true }),
|
||||
],
|
||||
['nexus', (g) => ({ ...g, nexusCards: { cards: null, phase: null } })],
|
||||
];
|
||||
for (const [name, enable] of moduleFactories) {
|
||||
let g = enable(lobby(name === 'stronghold'));
|
||||
for (const p of g.players)
|
||||
if (!p.ready) g = applyAction(g, p.id, { type: 'ready' });
|
||||
rejectInitializer(g, /without optional modules/);
|
||||
}
|
||||
|
||||
const occupied = lobby();
|
||||
occupied.deck.push(treacheryDeck(['ix'])[0]);
|
||||
rejectInitializer(occupied, /cannot redeal/);
|
||||
|
||||
const initialized = initializeIxGameForAudit(lobby());
|
||||
rejectInitializer(initialized, /fresh lobby/);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createGame, joinGame, newPlayer } from '../game/engine';
|
||||
import { startIxPrototypeRoom } from '../tools/prototype-room';
|
||||
|
||||
function fixture(t: test.TestContext) {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
t.after(() => db.close());
|
||||
db.exec(
|
||||
'CREATE TABLE rooms(code TEXT PRIMARY KEY, state TEXT, version INTEGER, updated_at INTEGER); CREATE TABLE seats(room_code TEXT, player_id TEXT, token_hash TEXT);',
|
||||
);
|
||||
const game = createGame(
|
||||
'PROTOTYP',
|
||||
newPlayer('i', 'Ix player', 'ixians'),
|
||||
true,
|
||||
);
|
||||
game.expansions = ['ix'];
|
||||
joinGame(game, newPlayer('t', 'Tleilaxu player', 'tleilaxu'));
|
||||
game.players.forEach((player) => {
|
||||
player.ready = true;
|
||||
});
|
||||
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(
|
||||
game.code,
|
||||
JSON.stringify(game),
|
||||
7,
|
||||
100,
|
||||
);
|
||||
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(
|
||||
'KEEPME00',
|
||||
'{"unchanged":true}',
|
||||
3,
|
||||
50,
|
||||
);
|
||||
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(
|
||||
game.code,
|
||||
'i',
|
||||
'unchanged-private-session',
|
||||
);
|
||||
return { db, game };
|
||||
}
|
||||
|
||||
void test('local prototype starts the named ready lobby once, retaining every seat and other room', (t) => {
|
||||
const { db } = fixture(t);
|
||||
const seats = db.prepare('SELECT * FROM seats').all();
|
||||
const other = db
|
||||
.prepare('SELECT * FROM rooms WHERE code = ?')
|
||||
.get('KEEPME00');
|
||||
const result = startIxPrototypeRoom(db, 'PROTOTYP', 7);
|
||||
assert.equal(result.version, 8);
|
||||
assert.equal(result.status, 'setup');
|
||||
const saved = db
|
||||
.prepare('SELECT state, version FROM rooms WHERE code = ?')
|
||||
.get('PROTOTYP')!;
|
||||
assert.equal(JSON.parse(saved.state as string).version, saved.version);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||
assert.deepEqual(
|
||||
db.prepare('SELECT * FROM rooms WHERE code = ?').get('KEEPME00'),
|
||||
other,
|
||||
);
|
||||
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(() => startIxPrototypeRoom(db, 'PROTOTYP', 7), /changed/);
|
||||
assert.throws(() => startIxPrototypeRoom(db, 'PROTOTYP', 8), /fresh lobby/);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
|
||||
});
|
||||
|
||||
void test('unready, wrong identity and incompatible lobbies fail without writing any room or seat', (t) => {
|
||||
const { db, game } = fixture(t);
|
||||
for (const mutate of [
|
||||
() => {
|
||||
game.players[0].ready = false;
|
||||
},
|
||||
() => {
|
||||
game.players[0].ready = true;
|
||||
game.code = 'NOTMATCH';
|
||||
},
|
||||
() => {
|
||||
game.code = 'PROTOTYP';
|
||||
game.expansions = [];
|
||||
},
|
||||
]) {
|
||||
mutate();
|
||||
db.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(
|
||||
JSON.stringify(game),
|
||||
'PROTOTYP',
|
||||
);
|
||||
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(() => startIxPrototypeRoom(db, 'PROTOTYP', 7));
|
||||
assert.deepEqual(
|
||||
db.prepare('SELECT * FROM rooms ORDER BY code').all(),
|
||||
before,
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare('SELECT count(*) AS count FROM seats').get()!.count,
|
||||
1,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { initializeIxGameForAudit, viewGame, type Game } from '../game/engine';
|
||||
|
||||
/** Local development only. This cannot create a seat, change its owner, or
|
||||
* redeal a started game. The caller backs up the database before invoking it. */
|
||||
export function startIxPrototypeRoom(
|
||||
db: DatabaseSync,
|
||||
code: string,
|
||||
expectedVersion: number,
|
||||
) {
|
||||
if (
|
||||
!/^[A-Z0-9]{8}$/.test(code) ||
|
||||
!Number.isSafeInteger(expectedVersion) ||
|
||||
expectedVersion < 0
|
||||
)
|
||||
throw new Error('Provide an eight-character room and its current version.');
|
||||
const row = db
|
||||
.prepare('SELECT state, version FROM rooms WHERE code = ?')
|
||||
.get(code);
|
||||
if (!row || typeof row.state !== 'string' || row.version !== expectedVersion)
|
||||
throw new Error(
|
||||
'The room is missing or changed. Refresh before starting the prototype.',
|
||||
);
|
||||
const initial = JSON.parse(row.state) as Game;
|
||||
if (initial.code !== code)
|
||||
throw new Error('The room identity is inconsistent.');
|
||||
const game = initializeIxGameForAudit(initial);
|
||||
// Exercise the same player projection before accepting the new saved state.
|
||||
for (const player of game.players) viewGame(game, player.id);
|
||||
const version = expectedVersion + 1;
|
||||
game.version = version;
|
||||
const result = db
|
||||
.prepare(
|
||||
'UPDATE rooms SET state = ?, version = ?, updated_at = ? WHERE code = ? AND version = ? AND state = ?',
|
||||
)
|
||||
.run(
|
||||
JSON.stringify(game),
|
||||
version,
|
||||
Date.now(),
|
||||
code,
|
||||
expectedVersion,
|
||||
row.state,
|
||||
);
|
||||
if (result.changes !== 1)
|
||||
throw new Error('The lobby changed during setup. No prototype was saved.');
|
||||
return {
|
||||
code,
|
||||
version,
|
||||
status: game.status,
|
||||
advanced: game.advanced,
|
||||
players: game.players.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { captureSavedGames } from './saved-game-verification';
|
||||
import { startIxPrototypeRoom } from './prototype-room';
|
||||
import { sourceSnapshot } from './verification';
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
db: { type: 'string' },
|
||||
room: { type: 'string' },
|
||||
version: { type: 'string' },
|
||||
out: { type: 'string' },
|
||||
help: { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: node --import tsx tools/start-prototype.ts --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready Ix expansion lobby for local prototyping. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!values.db ||
|
||||
!values.room ||
|
||||
!values.out ||
|
||||
!/^\d+$/.test(values.version ?? '')
|
||||
)
|
||||
throw new Error(
|
||||
'Provide --db, --room, --version and a new private --out directory.',
|
||||
);
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const version = Number(values.version),
|
||||
source = sourceSnapshot(root);
|
||||
const snapshot = await captureSavedGames(root, values.db, values.out, true);
|
||||
if (
|
||||
!snapshot.rooms.some(
|
||||
(room) => room.code === values.room && room.version === version,
|
||||
)
|
||||
)
|
||||
throw new Error('The requested lobby version is absent from the backup.');
|
||||
const db = new DatabaseSync(values.db);
|
||||
try {
|
||||
const result = startIxPrototypeRoom(db, values.room, version);
|
||||
writeFileSync(
|
||||
resolve(values.out, 'prototype.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
format: 1,
|
||||
startedAt: new Date().toISOString(),
|
||||
profile: 'ix',
|
||||
source,
|
||||
beforeVersion: version,
|
||||
...result,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
main().catch((error: unknown) => {
|
||||
console.error(
|
||||
error instanceof Error ? error.message : 'Prototype start failed.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user