Merge branch feature/npc-possession-differential-20260731

This commit is contained in:
2026-07-31 04:39:48 +00:00
3 changed files with 570 additions and 7 deletions
@@ -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()
);
});
+67 -1
View File
@@ -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_]*)