diff --git a/app/game-engine/src/turn/npcPossessionService.ts b/app/game-engine/src/turn/npcPossessionService.ts index 3a33a83..dbd2fad 100644 --- a/app/game-engine/src/turn/npcPossessionService.ts +++ b/app/game-engine/src/turn/npcPossessionService.ts @@ -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, - rng: RandUtil + rng: RandUtil, + onDraw?: (selectedId: string) => void ): Record => { 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 => { 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( diff --git a/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts new file mode 100644 index 0000000..0cbf754 --- /dev/null +++ b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts @@ -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 => { + 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) | undefined; + let worldState: GamePrisma.WorldStateGetPayload>; + 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 => + 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 => { + 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() + ); +}); diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index 5c3287e..c10b766 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -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_]*)