test: add NPC possession reference differential
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
||||
import { GamePrisma, type DatabaseClient, type GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
||||
import {
|
||||
ActionLogger,
|
||||
@@ -71,6 +71,11 @@ export interface NpcPossessionReservation {
|
||||
candidates: NpcPossessionCandidate[];
|
||||
}
|
||||
|
||||
export interface NpcPossessionSelectionObserver {
|
||||
onRandomDraw?: (value: number) => void;
|
||||
onCandidateDraw?: (selectedId: string) => void;
|
||||
}
|
||||
|
||||
interface NpcSelectionTokenRow {
|
||||
ownerUserId: string;
|
||||
validUntil: Date;
|
||||
@@ -238,10 +243,11 @@ const buildCandidateSnapshot = async (
|
||||
};
|
||||
};
|
||||
|
||||
const chooseCandidates = (
|
||||
export const chooseNpcPossessionCandidates = (
|
||||
candidates: NpcPossessionCandidate[],
|
||||
kept: Record<string, NpcPossessionCandidate>,
|
||||
rng: RandUtil
|
||||
rng: RandUtil,
|
||||
onDraw?: (selectedId: string) => void
|
||||
): Record<string, NpcPossessionCandidate> => {
|
||||
const picked = { ...kept };
|
||||
const weights = Object.fromEntries(
|
||||
@@ -254,6 +260,7 @@ const chooseCandidates = (
|
||||
const pickLimit = Math.min(candidates.length, MAX_PICK_COUNT);
|
||||
while (Object.keys(picked).length < pickLimit) {
|
||||
const selectedId = String(rng.choiceUsingWeight(weights));
|
||||
onDraw?.(selectedId);
|
||||
if (!Object.hasOwn(picked, selectedId)) {
|
||||
const candidate = byId.get(selectedId);
|
||||
if (!candidate) {
|
||||
@@ -265,6 +272,21 @@ const chooseCandidates = (
|
||||
return picked;
|
||||
};
|
||||
|
||||
class ObservedRandUtil extends RandUtil {
|
||||
constructor(
|
||||
rng: RNG,
|
||||
private readonly onRandomDraw: (value: number) => void
|
||||
) {
|
||||
super(rng);
|
||||
}
|
||||
|
||||
public override nextFloat1(): number {
|
||||
const value = super.nextFloat1();
|
||||
this.onRandomDraw(value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export const reserveNpcPossessionCandidates = async (options: {
|
||||
db: DatabaseClient;
|
||||
worldState: WorldStateRow;
|
||||
@@ -273,6 +295,7 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
refresh?: boolean;
|
||||
keepIds?: number[];
|
||||
now?: Date;
|
||||
selectionObserver?: NpcPossessionSelectionObserver;
|
||||
}): Promise<NpcPossessionReservation> => {
|
||||
const { db, worldState, userId } = options;
|
||||
requireNpcPossessionWorld(worldState);
|
||||
@@ -375,10 +398,13 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
const candidates = await Promise.all(
|
||||
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
|
||||
);
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, now))
|
||||
const selectionRng = new LiteHashDRBG(
|
||||
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, now)
|
||||
);
|
||||
const pickResult = chooseCandidates(candidates, kept, rng);
|
||||
const rng = options.selectionObserver?.onRandomDraw
|
||||
? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw)
|
||||
: new RandUtil(selectionRng);
|
||||
const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw);
|
||||
const turnTermMinutes = resolveTurnTermMinutes(worldState);
|
||||
const validUntil = new Date(now.getTime() + Math.max(VALID_SECONDS, turnTermMinutes * 40) * 1000);
|
||||
const refreshedPickMoreFrom = new Date(
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { seedScenarioToDatabase } from '@sammo-ts/game-engine';
|
||||
import {
|
||||
buildNpcSelectionTokenSeed,
|
||||
chooseNpcPossessionCandidates,
|
||||
reserveNpcPossessionCandidates,
|
||||
type NpcPossessionCandidate,
|
||||
} from '@sammo-ts/game-engine/turn/npcPossessionService.js';
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { findTurnDifferentialWorkspaceRoot } from '../src/turn-differential/referenceSnapshot.js';
|
||||
|
||||
type FixtureCandidate = {
|
||||
id: number;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
};
|
||||
|
||||
type FixturePick = {
|
||||
id: number;
|
||||
keepCount: number;
|
||||
};
|
||||
|
||||
type FixtureCase = {
|
||||
name: string;
|
||||
reservedIds: number[];
|
||||
boundaryReservedIds?: number[];
|
||||
expiredReservedIds?: number[];
|
||||
hasPreviousToken?: boolean;
|
||||
previousPick?: FixturePick[];
|
||||
keepIds?: number[];
|
||||
};
|
||||
|
||||
type Fixture = {
|
||||
hiddenSeed: string | number;
|
||||
owner: number;
|
||||
now: string;
|
||||
candidates: FixtureCandidate[];
|
||||
cases: FixtureCase[];
|
||||
};
|
||||
|
||||
type KernelTrace = {
|
||||
name: string;
|
||||
cancelled: boolean;
|
||||
seed: string | null;
|
||||
candidateOrder: number[];
|
||||
randomDraws: number[];
|
||||
draws: number[];
|
||||
pick: FixturePick[];
|
||||
};
|
||||
|
||||
type ReferenceKernelTrace = KernelTrace & {
|
||||
selectionStateUnchanged: boolean;
|
||||
};
|
||||
|
||||
type ReferenceTrace = {
|
||||
fixtureGeneralIds: number[];
|
||||
observedSqlOrder: number[];
|
||||
cases: ReferenceKernelTrace[];
|
||||
};
|
||||
|
||||
type CoreReservationTrace = {
|
||||
pick: FixturePick[];
|
||||
randomDraws: number[];
|
||||
draws: number[];
|
||||
tokenBefore: unknown;
|
||||
tokenAfter: unknown;
|
||||
};
|
||||
|
||||
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const databaseUrl = process.env.NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL;
|
||||
const integration = describe.skipIf(!workspaceRoot || !databaseUrl || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||
const ownerUserId = 'npc-possession-differential-owner';
|
||||
const reservedOwnerUserId = 'npc-possession-differential-reserved';
|
||||
|
||||
const fixturePath = (): string =>
|
||||
process.env.NPC_POSSESSION_DIFFERENTIAL_FIXTURE ??
|
||||
path.join(workspaceRoot!, 'docker_compose_files/reference/fixtures/npc-possession-differential/selector.json');
|
||||
|
||||
const readFixture = (): Fixture => JSON.parse(fs.readFileSync(fixturePath(), 'utf8')) as Fixture;
|
||||
|
||||
const referenceSourceRoot = (): string =>
|
||||
path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot!, 'ref/sam'));
|
||||
|
||||
const referenceRunner = (): string =>
|
||||
path.join(referenceSourceRoot(), 'hwe/compare/npc_possession_selection_trace.php');
|
||||
|
||||
const referenceTimeoutMs = (): number => {
|
||||
const value = Number(process.env.NPC_POSSESSION_DIFFERENTIAL_TIMEOUT_MS ?? '60000');
|
||||
if (!Number.isSafeInteger(value) || value < 5_000 || value > 600_000) {
|
||||
throw new Error('NPC_POSSESSION_DIFFERENTIAL_TIMEOUT_MS must be an integer from 5000 to 600000');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const referenceComparisonTestTimeoutMs = (): number => referenceTimeoutMs() * 2 + 60_000;
|
||||
|
||||
const runReference = (fixture: string): ReferenceTrace => {
|
||||
const stackDirectory = path.resolve(
|
||||
process.env.TURN_DIFFERENTIAL_STACK_DIR ?? path.join(workspaceRoot!, 'docker_compose_files/reference')
|
||||
);
|
||||
const runner = referenceRunner();
|
||||
const stdout = execFileSync('./scripts/run-turn-differential-case.sh', ['-'], {
|
||||
cwd: stackDirectory,
|
||||
input: fixture,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: referenceTimeoutMs(),
|
||||
env: {
|
||||
...process.env,
|
||||
TURN_DIFFERENTIAL_RUNNER_SCRIPT: runner,
|
||||
TURN_DIFFERENTIAL_COMPARE_DIR: path.dirname(runner),
|
||||
TURN_DIFFERENTIAL_APP_DIR: referenceSourceRoot(),
|
||||
TURN_DIFFERENTIAL_RUNTIME_DIR: path.join(workspaceRoot!, 'ref/sam'),
|
||||
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
|
||||
},
|
||||
});
|
||||
return JSON.parse(stdout) as ReferenceTrace;
|
||||
};
|
||||
|
||||
const toCandidate = (candidate: FixtureCandidate, keepCount = 3): NpcPossessionCandidate => ({
|
||||
id: candidate.id,
|
||||
name: `후보${candidate.id}`,
|
||||
nation: { id: 0, name: '재야', color: '#666666' },
|
||||
stats: {
|
||||
leadership: candidate.leadership,
|
||||
strength: candidate.strength,
|
||||
intelligence: candidate.intel,
|
||||
},
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
personality: { code: 'None', name: '-', info: '없음' },
|
||||
specialDomestic: { code: 'None', name: '-', info: '없음' },
|
||||
specialWar: { code: 'None', name: '-', info: '없음' },
|
||||
keepCount,
|
||||
});
|
||||
|
||||
// Ref serializes an insertion-ordered PHP object while JavaScript enumerates
|
||||
// integer-like object keys numerically. The persisted/API pick is a set; draw
|
||||
// order is compared independently and exactly below.
|
||||
const normalizePickSet = (pick: FixturePick[]): FixturePick[] =>
|
||||
[...pick].sort((left, right) => left.id - right.id || left.keepCount - right.keepCount);
|
||||
|
||||
const acceptedAt = (fixture: Fixture): Date => new Date(`${fixture.now.replace(' ', 'T')}+09:00`);
|
||||
|
||||
const buildPreviousCandidates = (fixture: Fixture, previous: FixturePick[]): Record<string, NpcPossessionCandidate> => {
|
||||
const byId = new Map(fixture.candidates.map((candidate) => [candidate.id, candidate]));
|
||||
return Object.fromEntries(
|
||||
previous.map(({ id, keepCount }) => {
|
||||
const candidate = byId.get(id);
|
||||
if (!candidate) {
|
||||
throw new Error(`Unknown previous candidate ${id}`);
|
||||
}
|
||||
return [String(id), toCandidate(candidate, keepCount)];
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
class TracingRandUtil extends RandUtil {
|
||||
public readonly floatDraws: number[] = [];
|
||||
|
||||
public override nextFloat1(): number {
|
||||
const value = super.nextFloat1();
|
||||
this.floatDraws.push(value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase): KernelTrace => {
|
||||
const reserved = new Set([...testCase.reservedIds, ...(testCase.boundaryReservedIds ?? [])]);
|
||||
const candidates = fixture.candidates
|
||||
.filter(({ id }) => !reserved.has(id))
|
||||
.sort((left, right) => left.id - right.id)
|
||||
.map((candidate) => toCandidate(candidate));
|
||||
const previous = buildPreviousCandidates(fixture, testCase.previousPick ?? []);
|
||||
const keepIds = new Set(testCase.keepIds ?? []);
|
||||
const kept = Object.fromEntries(
|
||||
Object.entries(previous)
|
||||
.filter(([, candidate]) => keepIds.has(candidate.id) && candidate.keepCount > 0)
|
||||
.map(([id, candidate]) => [id, { ...candidate, keepCount: candidate.keepCount - 1 }])
|
||||
);
|
||||
const hasPreviousToken = testCase.hasPreviousToken === true || Object.keys(previous).length > 0;
|
||||
if (hasPreviousToken && Object.keys(kept).length === Object.keys(previous).length) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
cancelled: true,
|
||||
seed: null,
|
||||
candidateOrder: candidates.map(({ id }) => id),
|
||||
randomDraws: [],
|
||||
draws: [],
|
||||
pick: Object.values(previous).map(({ id, keepCount }) => ({ id, keepCount })),
|
||||
};
|
||||
}
|
||||
|
||||
const seed = buildNpcSelectionTokenSeed(fixture.hiddenSeed, fixture.owner, acceptedAt(fixture));
|
||||
const rng = new TracingRandUtil(new LiteHashDRBG(seed));
|
||||
const draws: number[] = [];
|
||||
const picked = chooseNpcPossessionCandidates(candidates, kept, rng, (selectedId) => {
|
||||
draws.push(Number(selectedId));
|
||||
});
|
||||
return {
|
||||
name: testCase.name,
|
||||
cancelled: false,
|
||||
seed,
|
||||
candidateOrder: candidates.map(({ id }) => id),
|
||||
randomDraws: rng.floatDraws,
|
||||
draws,
|
||||
pick: Object.values(picked).map(({ id, keepCount }) => ({ id, keepCount })),
|
||||
};
|
||||
};
|
||||
|
||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||
const schema = new URL(rawUrl).searchParams.get('schema');
|
||||
if (!schema?.endsWith('npc_possession_differential')) {
|
||||
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||
}
|
||||
if (!/^[a-z0-9_]+$/.test(schema)) {
|
||||
throw new Error(`Refusing unsafe schema name: ${schema}`);
|
||||
}
|
||||
};
|
||||
|
||||
integration('NPC possession selector Ref differential', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let worldState: GamePrisma.WorldStateGetPayload<Record<string, never>>;
|
||||
const fixture = readFixture();
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedDatabase(databaseUrl!);
|
||||
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
|
||||
process.env.INTEGRATION_WORLD_SEED = String(fixture.hiddenSeed);
|
||||
try {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 2,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: acceptedAt(fixture),
|
||||
installOptions: {
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 1,
|
||||
showImgLevel: 3,
|
||||
serverId: 'npc-possession-differential',
|
||||
season: 1,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (previousSeed === undefined) {
|
||||
delete process.env.INTEGRATION_WORLD_SEED;
|
||||
} else {
|
||||
process.env.INTEGRATION_WORLD_SEED = previousSeed;
|
||||
}
|
||||
}
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany();
|
||||
await db.npcSelectionToken.deleteMany();
|
||||
await db.general.updateMany({
|
||||
where: { userId: null, npcState: 2 },
|
||||
data: { npcState: 1 },
|
||||
});
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
await db.general.createMany({
|
||||
data: fixture.candidates.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
userId: null,
|
||||
name: `후보${candidate.id}`,
|
||||
nationId: 0,
|
||||
cityId: city.id,
|
||||
npcState: 2,
|
||||
leadership: candidate.leadership,
|
||||
strength: candidate.strength,
|
||||
intel: candidate.intel,
|
||||
turnTime: acceptedAt(fixture),
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
meta: {},
|
||||
penalty: {},
|
||||
})),
|
||||
});
|
||||
const eligibleIds = await db.general.findMany({
|
||||
where: { userId: null, npcState: 2 },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(eligibleIds.map(({ id }) => id)).toEqual(
|
||||
fixture.candidates.map(({ id }) => id).sort((left, right) => left - right)
|
||||
);
|
||||
const seededWorld = await db.worldState.findFirstOrThrow();
|
||||
const meta =
|
||||
typeof seededWorld.meta === 'object' && seededWorld.meta !== null && !Array.isArray(seededWorld.meta)
|
||||
? seededWorld.meta
|
||||
: {};
|
||||
worldState = await db.worldState.update({
|
||||
where: { id: seededWorld.id },
|
||||
data: {
|
||||
meta: { ...meta, hiddenSeed: fixture.hiddenSeed },
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
const runCoreReservationCase = async (testCase: FixtureCase): Promise<CoreReservationTrace> =>
|
||||
db.$transaction(async (transaction) => {
|
||||
await transaction.npcSelectionToken.deleteMany();
|
||||
const validUntil = new Date('2099-12-31T23:59:59.000Z');
|
||||
const pickMoreFrom = new Date('2000-01-01T01:00:00.000Z');
|
||||
const candidateById = new Map(fixture.candidates.map((candidate) => [candidate.id, candidate]));
|
||||
const insertReservedToken = async (
|
||||
ownerId: string,
|
||||
ids: number[],
|
||||
reservationValidUntil: Date
|
||||
): Promise<void> => {
|
||||
if (ids.length === 0) return;
|
||||
const reservedPick = Object.fromEntries(
|
||||
ids.map((id) => {
|
||||
const candidate = candidateById.get(id);
|
||||
if (!candidate) throw new Error(`Unknown reserved candidate ${id}`);
|
||||
return [String(id), toCandidate(candidate)];
|
||||
})
|
||||
);
|
||||
await transaction.npcSelectionToken.create({
|
||||
data: {
|
||||
ownerUserId: ownerId,
|
||||
validUntil: reservationValidUntil,
|
||||
pickMoreFrom,
|
||||
pickResult: reservedPick as GamePrisma.InputJsonValue,
|
||||
nonce: 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
await insertReservedToken(reservedOwnerUserId, testCase.reservedIds, validUntil);
|
||||
await insertReservedToken(
|
||||
`${reservedOwnerUserId}-boundary`,
|
||||
testCase.boundaryReservedIds ?? [],
|
||||
acceptedAt(fixture)
|
||||
);
|
||||
await insertReservedToken(
|
||||
`${reservedOwnerUserId}-expired`,
|
||||
testCase.expiredReservedIds ?? [],
|
||||
new Date(acceptedAt(fixture).getTime() - 1_000)
|
||||
);
|
||||
const previousPick = testCase.previousPick ?? [];
|
||||
const hasPreviousToken = testCase.hasPreviousToken === true || previousPick.length > 0;
|
||||
if (hasPreviousToken) {
|
||||
await transaction.npcSelectionToken.create({
|
||||
data: {
|
||||
ownerUserId,
|
||||
validUntil,
|
||||
pickMoreFrom,
|
||||
pickResult: buildPreviousCandidates(fixture, previousPick) as GamePrisma.InputJsonValue,
|
||||
nonce: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
const tokenBefore = await transaction.npcSelectionToken.findUnique({
|
||||
where: { ownerUserId },
|
||||
});
|
||||
const randomDraws: number[] = [];
|
||||
const draws: number[] = [];
|
||||
const reservation = await reserveNpcPossessionCandidates({
|
||||
db: transaction,
|
||||
worldState,
|
||||
userId: ownerUserId,
|
||||
ownerIdentity: fixture.owner,
|
||||
refresh: hasPreviousToken,
|
||||
keepIds: testCase.keepIds,
|
||||
now: acceptedAt(fixture),
|
||||
selectionObserver: {
|
||||
onRandomDraw: (value) => randomDraws.push(value),
|
||||
onCandidateDraw: (selectedId) => draws.push(Number(selectedId)),
|
||||
},
|
||||
});
|
||||
const tokenAfter = await transaction.npcSelectionToken.findUnique({
|
||||
where: { ownerUserId },
|
||||
});
|
||||
return {
|
||||
pick: reservation.candidates.map(({ id, keepCount }) => ({ id, keepCount })),
|
||||
randomDraws,
|
||||
draws,
|
||||
tokenBefore,
|
||||
tokenAfter,
|
||||
};
|
||||
});
|
||||
|
||||
it('rejects direct execution when the comparison guard is absent', () => {
|
||||
const result = spawnSync('php', [referenceRunner()], {
|
||||
input: fs.readFileSync(fixturePath(), 'utf8'),
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(64);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toBe('NPC possession comparison is disabled.\n');
|
||||
});
|
||||
|
||||
it(
|
||||
'matches the shared Ref kernel and the actual Core reservation path',
|
||||
async () => {
|
||||
const fixtureText = fs.readFileSync(fixturePath(), 'utf8');
|
||||
const firstReference = runReference(fixtureText);
|
||||
const secondReference = runReference(fixtureText);
|
||||
expect(secondReference).toEqual(firstReference);
|
||||
expect(firstReference.fixtureGeneralIds).toEqual(fixture.candidates.map(({ id }) => id));
|
||||
expect(firstReference.observedSqlOrder).toEqual(
|
||||
fixture.candidates.map(({ id }) => id).sort((left, right) => left - right)
|
||||
);
|
||||
expect(firstReference.cases).toHaveLength(fixture.cases.length);
|
||||
|
||||
const coreKernelCases = fixture.cases.map((testCase) => runCoreKernelCase(fixture, testCase));
|
||||
for (const [index, referenceCase] of firstReference.cases.entries()) {
|
||||
const testCase = fixture.cases[index]!;
|
||||
const coreKernel = coreKernelCases[index]!;
|
||||
const coreReservation = await runCoreReservationCase(testCase);
|
||||
expect(referenceCase.name).toBe(coreKernel.name);
|
||||
expect(referenceCase.selectionStateUnchanged).toBe(true);
|
||||
expect(referenceCase.cancelled).toBe(coreKernel.cancelled);
|
||||
expect(referenceCase.seed).toBe(coreKernel.seed);
|
||||
expect(referenceCase.candidateOrder).toEqual(coreKernel.candidateOrder);
|
||||
expect(referenceCase.randomDraws).toEqual(coreKernel.randomDraws);
|
||||
expect(referenceCase.draws).toEqual(coreKernel.draws);
|
||||
expect(referenceCase.randomDraws).toEqual(coreReservation.randomDraws);
|
||||
expect(referenceCase.draws).toEqual(coreReservation.draws);
|
||||
expect(normalizePickSet(referenceCase.pick)).toEqual(normalizePickSet(coreKernel.pick));
|
||||
expect(normalizePickSet(referenceCase.pick)).toEqual(normalizePickSet(coreReservation.pick));
|
||||
if (coreKernel.cancelled) {
|
||||
expect(coreReservation.tokenBefore).not.toBeNull();
|
||||
expect(coreReservation.tokenAfter).toEqual(coreReservation.tokenBefore);
|
||||
}
|
||||
}
|
||||
|
||||
const initial = firstReference.cases.find(({ name }) => name === 'initial-five');
|
||||
expect(initial).toBeDefined();
|
||||
expect(initial!.draws.length).toBeGreaterThan(initial!.pick.length);
|
||||
expect(firstReference.cases.find(({ name }) => name === 'all-keep-cancels-refresh')).toMatchObject({
|
||||
cancelled: true,
|
||||
seed: null,
|
||||
randomDraws: [],
|
||||
draws: [],
|
||||
selectionStateUnchanged: true,
|
||||
});
|
||||
expect(
|
||||
firstReference.cases.find(({ name }) => name === 'empty-existing-pick-cancels-refresh')
|
||||
).toMatchObject({
|
||||
cancelled: true,
|
||||
seed: null,
|
||||
randomDraws: [],
|
||||
draws: [],
|
||||
pick: [],
|
||||
selectionStateUnchanged: true,
|
||||
});
|
||||
},
|
||||
referenceComparisonTestTimeoutMs()
|
||||
);
|
||||
});
|
||||
@@ -35,8 +35,9 @@ set +a
|
||||
integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-conditional_integration}
|
||||
scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-conditional_scenario_seed}
|
||||
npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-conditional_$(date +%s)_$$_npc_possession_integration}
|
||||
npc_possession_differential_schema=conditional_$(date +%s)_$$_npc_possession_differential
|
||||
|
||||
for schema in "$integration_schema" "$scenario_schema" "$npc_possession_schema"; do
|
||||
for schema in "$integration_schema" "$scenario_schema" "$npc_possession_schema" "$npc_possession_differential_schema"; do
|
||||
case "$schema" in
|
||||
''|[!a-z_]*|*[!a-z0-9_]*)
|
||||
echo "integration schema must be a lowercase PostgreSQL identifier: $schema" >&2
|
||||
@@ -125,6 +126,71 @@ npc_possession_database_url=$(build_database_url "$npc_possession_schema")
|
||||
)
|
||||
|
||||
if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
|
||||
reference_workspace_root=${TURN_DIFFERENTIAL_WORKSPACE_ROOT:-"$workspace_root/.."}
|
||||
reference_stack=${TURN_DIFFERENTIAL_STACK_DIR:-"$reference_workspace_root/docker_compose_files/reference"}
|
||||
if [ ! -f "$reference_stack/compose.yml" ]; then
|
||||
echo "reference compose file is missing; set TURN_DIFFERENTIAL_WORKSPACE_ROOT or TURN_DIFFERENTIAL_STACK_DIR" >&2
|
||||
exit 69
|
||||
fi
|
||||
if [ ! -f "$reference_workspace_root/ref/sam/d_setting/RootDB.php" ] ||
|
||||
[ ! -f "$reference_workspace_root/ref/sam/hwe/d_setting/DB.php" ]; then
|
||||
echo "reference runtime database configuration is missing under $reference_workspace_root/ref/sam" >&2
|
||||
exit 69
|
||||
fi
|
||||
if ! docker compose -f "$reference_stack/compose.yml" ps --status running --services | grep -qx db ||
|
||||
! docker compose -f "$reference_stack/compose.yml" exec -T db healthcheck.sh --connect --innodb_initialized \
|
||||
>/dev/null 2>&1; then
|
||||
echo "TURN_DIFFERENTIAL_REFERENCE=1 requires a healthy bootstrapped reference db stack" >&2
|
||||
exit 69
|
||||
fi
|
||||
reference_tables=$(
|
||||
docker compose -f "$reference_stack/compose.yml" exec -T db sh -c '
|
||||
export MYSQL_PWD="$(cat /run/secrets/db_root_password)"
|
||||
mariadb --batch --skip-column-names -u root -e "
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = '\''$MARIADB_DATABASE'\'' AND TABLE_NAME = '\''member'\'';
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = '\''$REF_HWE_DB_NAME'\'' AND TABLE_NAME IN ('\''general'\'', '\''select_npc_token'\'');
|
||||
"
|
||||
' 2>/dev/null
|
||||
) || {
|
||||
echo "failed to inspect reference database bootstrap state" >&2
|
||||
exit 69
|
||||
}
|
||||
if [ "$reference_tables" != "$(printf '1\n2')" ]; then
|
||||
echo "reference root/HWE databases are not bootstrapped" >&2
|
||||
exit 69
|
||||
fi
|
||||
npc_possession_differential_database_url=$(build_database_url "$npc_possession_differential_schema")
|
||||
(
|
||||
cleanup_npc_possession_differential_schema() {
|
||||
printf 'DROP SCHEMA IF EXISTS "%s" CASCADE;\n' "$npc_possession_differential_schema" |
|
||||
DATABASE_URL=$npc_possession_differential_database_url \
|
||||
pnpm --filter @sammo-ts/infra exec prisma db execute --stdin >/dev/null
|
||||
}
|
||||
handle_npc_possession_differential_exit() {
|
||||
exit_status=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if ! cleanup_npc_possession_differential_schema && [ "$exit_status" -eq 0 ]; then
|
||||
exit_status=1
|
||||
fi
|
||||
exit "$exit_status"
|
||||
}
|
||||
trap handle_npc_possession_differential_exit EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
export POSTGRES_SCHEMA=$npc_possession_differential_schema
|
||||
export DATABASE_URL=$npc_possession_differential_database_url
|
||||
export NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL=$npc_possession_differential_database_url
|
||||
export TURN_DIFFERENTIAL_WORKSPACE_ROOT=$reference_workspace_root
|
||||
export TURN_DIFFERENTIAL_STACK_DIR=$reference_stack
|
||||
pnpm --filter @sammo-ts/infra prisma:db:push:game
|
||||
cd "$workspace_root/tools/integration-tests"
|
||||
pnpm exec vitest run --no-file-parallelism --maxWorkers=1 \
|
||||
test/npcPossessionSelectionReference.integration.test.ts
|
||||
)
|
||||
|
||||
live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-conditional_live_sortie_persistence}
|
||||
case "$live_sortie_schema" in
|
||||
''|[!a-z_]*|*[!a-z0-9_]*)
|
||||
|
||||
Reference in New Issue
Block a user