472 lines
19 KiB
TypeScript
472 lines
19 KiB
TypeScript
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()
|
|
);
|
|
});
|