test live sortie postgres persistence matrix
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js';
|
import { buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js';
|
||||||
import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js';
|
import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js';
|
||||||
@@ -49,10 +49,10 @@ const assertDedicatedDatabase = (rawUrl: string): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const readFixture = (): TurnCommandFixtureRequest => {
|
const readFixture = (fixtureName: string): TurnCommandFixtureRequest => {
|
||||||
const fixturePath = path.join(
|
const fixturePath = path.join(
|
||||||
workspaceRoot!,
|
workspaceRoot!,
|
||||||
'docker_compose_files/reference/fixtures/turn-differential/live-sortie-defender.json'
|
`docker_compose_files/reference/fixtures/turn-differential/${fixtureName}`
|
||||||
);
|
);
|
||||||
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as TurnCommandFixtureRequest;
|
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as TurnCommandFixtureRequest;
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +81,7 @@ const readFixture = (): TurnCommandFixtureRequest => {
|
|||||||
|
|
||||||
const cleanup = async (db: GamePrismaClient): Promise<void> => {
|
const cleanup = async (db: GamePrismaClient): Promise<void> => {
|
||||||
await db.logEntry.deleteMany();
|
await db.logEntry.deleteMany();
|
||||||
|
await db.oldNation.deleteMany();
|
||||||
await db.rankData.deleteMany();
|
await db.rankData.deleteMany();
|
||||||
await db.generalTurn.deleteMany();
|
await db.generalTurn.deleteMany();
|
||||||
await db.generalTurnRevision.deleteMany();
|
await db.generalTurnRevision.deleteMany();
|
||||||
@@ -107,275 +108,332 @@ integration('live sortie PostgreSQL persistence retry', () => {
|
|||||||
await cleanup(db);
|
await cleanup(db);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanup(db);
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await cleanup(db);
|
await cleanup(db);
|
||||||
await disconnect?.();
|
await disconnect?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rolls back a lost queue lease, retains dirty state, and flushes the same sortie exactly once on retry', async () => {
|
it.each([
|
||||||
const request = readFixture();
|
['conquest and nation collapse', 'live-sortie-conquest.json'],
|
||||||
const reference = runReferenceTurnCommandTraceRequest(
|
['single defending general', 'live-sortie-defender.json'],
|
||||||
workspaceRoot!,
|
['multiple defending generals', 'live-sortie-multiple-defenders.json'],
|
||||||
request as unknown as Record<string, unknown>
|
['supply retreat', 'live-sortie-supply-retreat.json'],
|
||||||
);
|
['noncapital conquest', 'live-sortie-noncapital-conquest.json'],
|
||||||
const expected = await runCoreTurnCommandTrace(request, reference.before);
|
['emergency capital', 'live-sortie-emergency-capital.json'],
|
||||||
const coreArgs = resolveCoreTurnCommandArgs(request);
|
['conflict arbitration', 'live-sortie-conflict-arbitration.json'],
|
||||||
const unitSet = await loadUnitSetDefinitionByName('che');
|
['tied conflict', 'live-sortie-conflict-tie.json'],
|
||||||
const map = await loadMapDefinitionByName('che');
|
])(
|
||||||
const { state, snapshot } = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map);
|
'%s rolls back a lost lease and flushes exactly once on retry',
|
||||||
|
async (_label, fixtureName) => {
|
||||||
|
const request = readFixture(fixtureName);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const expected = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
const coreArgs = resolveCoreTurnCommandArgs(request);
|
||||||
|
const unitSet = await loadUnitSetDefinitionByName('che');
|
||||||
|
const map = await loadMapDefinitionByName('che');
|
||||||
|
const { state, snapshot } = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map);
|
||||||
|
|
||||||
await db.worldState.create({
|
await db.worldState.create({
|
||||||
data: {
|
|
||||||
id: state.id,
|
|
||||||
scenarioCode: 'live-sortie-persistence',
|
|
||||||
currentYear: state.currentYear,
|
|
||||||
currentMonth: state.currentMonth,
|
|
||||||
tickSeconds: state.tickSeconds,
|
|
||||||
config: asJson(snapshot.scenarioConfig),
|
|
||||||
meta: asJson(state.meta),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await db.nation.createMany({
|
|
||||||
data: snapshot.nations.map((nation) => ({
|
|
||||||
id: nation.id,
|
|
||||||
name: nation.name,
|
|
||||||
color: nation.color,
|
|
||||||
capitalCityId: nation.capitalCityId,
|
|
||||||
chiefGeneralId: nation.chiefGeneralId,
|
|
||||||
gold: nation.gold,
|
|
||||||
rice: nation.rice,
|
|
||||||
tech: Number(nation.meta.tech ?? 0),
|
|
||||||
level: nation.level,
|
|
||||||
typeCode: nation.typeCode,
|
|
||||||
meta: asJson(nation.meta),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
await db.city.createMany({
|
|
||||||
data: snapshot.cities.map((city) => {
|
|
||||||
const definition = map.cities.find((entry) => entry.id === city.id);
|
|
||||||
return {
|
|
||||||
id: city.id,
|
|
||||||
name: city.name,
|
|
||||||
level: city.level,
|
|
||||||
nationId: city.nationId,
|
|
||||||
supplyState: city.supplyState,
|
|
||||||
frontState: city.frontState,
|
|
||||||
population: Math.round(city.population),
|
|
||||||
populationMax: city.populationMax,
|
|
||||||
agriculture: Math.round(city.agriculture),
|
|
||||||
agricultureMax: city.agricultureMax,
|
|
||||||
commerce: Math.round(city.commerce),
|
|
||||||
commerceMax: city.commerceMax,
|
|
||||||
security: Math.round(city.security),
|
|
||||||
securityMax: city.securityMax,
|
|
||||||
trust: Number(city.meta.trust ?? 0),
|
|
||||||
trade: Number(city.meta.trade ?? 100),
|
|
||||||
defence: Math.round(city.defence),
|
|
||||||
defenceMax: city.defenceMax,
|
|
||||||
wall: Math.round(city.wall),
|
|
||||||
wallMax: city.wallMax,
|
|
||||||
region: definition?.region ?? 0,
|
|
||||||
conflict: asJson(city.conflict ?? {}),
|
|
||||||
meta: asJson({ ...city.meta, state: city.state }),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
await db.troop.createMany({
|
|
||||||
data: snapshot.troops.map((troop) => ({
|
|
||||||
troopLeaderId: troop.id,
|
|
||||||
nationId: troop.nationId,
|
|
||||||
name: troop.name,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
await db.general.createMany({
|
|
||||||
data: snapshot.generals.map((general) => ({
|
|
||||||
id: general.id,
|
|
||||||
userId: general.userId,
|
|
||||||
name: general.name,
|
|
||||||
nationId: general.nationId,
|
|
||||||
cityId: general.cityId,
|
|
||||||
troopId: general.troopId,
|
|
||||||
npcState: general.npcState,
|
|
||||||
affinity: general.affinity,
|
|
||||||
bornYear: general.bornYear,
|
|
||||||
deadYear: general.deadYear,
|
|
||||||
picture: general.picture,
|
|
||||||
leadership: Math.round(general.stats.leadership),
|
|
||||||
strength: Math.round(general.stats.strength),
|
|
||||||
intel: Math.round(general.stats.intelligence),
|
|
||||||
injury: Math.round(general.injury),
|
|
||||||
experience: Math.round(general.experience),
|
|
||||||
dedication: Math.round(general.dedication),
|
|
||||||
officerLevel: general.officerLevel,
|
|
||||||
gold: Math.round(general.gold),
|
|
||||||
rice: Math.round(general.rice),
|
|
||||||
crew: Math.round(general.crew),
|
|
||||||
crewTypeId: general.crewTypeId,
|
|
||||||
train: Math.round(general.train),
|
|
||||||
atmos: Math.round(general.atmos),
|
|
||||||
age: general.age,
|
|
||||||
startAge: general.startAge,
|
|
||||||
personalCode: nullableCode(general.role.personality),
|
|
||||||
specialCode: nullableCode(general.role.specialDomestic),
|
|
||||||
special2Code: nullableCode(general.role.specialWar),
|
|
||||||
horseCode: nullableCode(general.role.items.horse),
|
|
||||||
weaponCode: nullableCode(general.role.items.weapon),
|
|
||||||
bookCode: nullableCode(general.role.items.book),
|
|
||||||
itemCode: nullableCode(general.role.items.item),
|
|
||||||
turnTime: general.turnTime,
|
|
||||||
recentWarTime: general.recentWarTime,
|
|
||||||
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
|
||||||
meta: asJson(general.meta),
|
|
||||||
penalty: asJson(general.penalty ?? {}),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
await db.rankData.createMany({
|
|
||||||
data: snapshot.generals.flatMap((general) =>
|
|
||||||
buildLegacyComparableRankRows(general).map((row) => ({
|
|
||||||
generalId: row.generalId,
|
|
||||||
nationId: row.nationId,
|
|
||||||
type: row.type,
|
|
||||||
value: row.value,
|
|
||||||
}))
|
|
||||||
),
|
|
||||||
});
|
|
||||||
await db.diplomacy.createMany({
|
|
||||||
data: snapshot.diplomacy.map((entry) => ({
|
|
||||||
srcNationId: entry.fromNationId,
|
|
||||||
destNationId: entry.toNationId,
|
|
||||||
stateCode: entry.state,
|
|
||||||
term: entry.term,
|
|
||||||
isDead: entry.dead !== 0,
|
|
||||||
meta: asJson(entry.meta),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
await db.generalTurn.createMany({
|
|
||||||
data: snapshot.generals.flatMap((general) =>
|
|
||||||
Array.from({ length: 30 }, (_, turnIdx) => ({
|
|
||||||
generalId: general.id,
|
|
||||||
turnIdx,
|
|
||||||
actionCode: general.id === request.actorGeneralId && turnIdx === 0 ? request.action : '휴식',
|
|
||||||
arg: asJson(general.id === request.actorGeneralId && turnIdx === 0 ? coreArgs : {}),
|
|
||||||
}))
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const reservedTurns = new InMemoryReservedTurnStore(db, {
|
|
||||||
maxGeneralTurns: 30,
|
|
||||||
maxNationTurns: 12,
|
|
||||||
leaseOwner,
|
|
||||||
leaseDurationMs: 60_000,
|
|
||||||
});
|
|
||||||
await reservedTurns.loadAll();
|
|
||||||
await reservedTurns.prepareTurnsForExecution(request.actorGeneralId);
|
|
||||||
|
|
||||||
let world: InMemoryTurnWorld | null = null;
|
|
||||||
const handler = await createReservedTurnHandler({
|
|
||||||
reservedTurns,
|
|
||||||
scenarioConfig: snapshot.scenarioConfig,
|
|
||||||
scenarioMeta: snapshot.scenarioMeta,
|
|
||||||
map,
|
|
||||||
unitSet,
|
|
||||||
getWorld: () => world,
|
|
||||||
commandProfile: createCoreTurnCommandProfile(request),
|
|
||||||
});
|
|
||||||
world = new InMemoryTurnWorld(state, snapshot, {
|
|
||||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
|
||||||
generalTurnHandler: handler,
|
|
||||||
});
|
|
||||||
const actor = world.getGeneralById(request.actorGeneralId);
|
|
||||||
if (!actor) {
|
|
||||||
throw new Error('fixture actor is missing');
|
|
||||||
}
|
|
||||||
world.executeGeneralTurn(actor);
|
|
||||||
|
|
||||||
const dirtyBeforeFailure = world.peekDirtyState();
|
|
||||||
expect(dirtyBeforeFailure.generals.length).toBeGreaterThan(0);
|
|
||||||
expect(dirtyBeforeFailure.logs.length).toBeGreaterThan(0);
|
|
||||||
expect(
|
|
||||||
dirtyBeforeFailure.generals.find((general) => general.id === request.actorGeneralId)?.recentWarTime
|
|
||||||
).toEqual(new Date('2026-07-26T13:38:45.000Z'));
|
|
||||||
expect(dirtyBeforeFailure.generals.find((general) => general.id === 2)?.recentWarTime).toEqual(
|
|
||||||
new Date('2026-07-26T13:38:45.000Z')
|
|
||||||
);
|
|
||||||
expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId);
|
|
||||||
|
|
||||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
|
|
||||||
try {
|
|
||||||
await db.generalTurnRevision.update({
|
|
||||||
where: { generalId: request.actorGeneralId },
|
|
||||||
data: {
|
data: {
|
||||||
leaseOwner: 'lost-to-another-writer',
|
id: state.id,
|
||||||
leaseExpiresAt: new Date(Date.now() + 60_000),
|
scenarioCode: 'live-sortie-persistence',
|
||||||
|
currentYear: state.currentYear,
|
||||||
|
currentMonth: state.currentMonth,
|
||||||
|
tickSeconds: state.tickSeconds,
|
||||||
|
config: asJson(snapshot.scenarioConfig),
|
||||||
|
meta: asJson(state.meta),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await db.nation.createMany({
|
||||||
await expect(hooks.hooks.flushChanges?.(turnRunResult)).rejects.toBeInstanceOf(
|
data: snapshot.nations.map((nation) => ({
|
||||||
ReservedTurnLeaseConflictError
|
id: nation.id,
|
||||||
);
|
name: nation.name,
|
||||||
|
color: nation.color,
|
||||||
const rolledBackActor = await db.general.findUniqueOrThrow({
|
capitalCityId: nation.capitalCityId,
|
||||||
where: { id: request.actorGeneralId },
|
chiefGeneralId: nation.chiefGeneralId,
|
||||||
|
gold: nation.gold,
|
||||||
|
rice: nation.rice,
|
||||||
|
tech: Number(nation.meta.tech ?? 0),
|
||||||
|
level: nation.level,
|
||||||
|
typeCode: nation.typeCode,
|
||||||
|
meta: asJson(nation.meta),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
const rolledBackTurns = await db.generalTurn.findMany({
|
await db.city.createMany({
|
||||||
where: { generalId: request.actorGeneralId },
|
data: snapshot.cities.map((city) => {
|
||||||
orderBy: { turnIdx: 'asc' },
|
const definition = map.cities.find((entry) => entry.id === city.id);
|
||||||
|
return {
|
||||||
|
id: city.id,
|
||||||
|
name: city.name,
|
||||||
|
level: city.level,
|
||||||
|
nationId: city.nationId,
|
||||||
|
supplyState: city.supplyState,
|
||||||
|
frontState: city.frontState,
|
||||||
|
population: Math.round(city.population),
|
||||||
|
populationMax: city.populationMax,
|
||||||
|
agriculture: Math.round(city.agriculture),
|
||||||
|
agricultureMax: city.agricultureMax,
|
||||||
|
commerce: Math.round(city.commerce),
|
||||||
|
commerceMax: city.commerceMax,
|
||||||
|
security: Math.round(city.security),
|
||||||
|
securityMax: city.securityMax,
|
||||||
|
trust: Number(city.meta.trust ?? 0),
|
||||||
|
trade: Number(city.meta.trade ?? 100),
|
||||||
|
defence: Math.round(city.defence),
|
||||||
|
defenceMax: city.defenceMax,
|
||||||
|
wall: Math.round(city.wall),
|
||||||
|
wallMax: city.wallMax,
|
||||||
|
region: definition?.region ?? 0,
|
||||||
|
conflict: asJson(city.conflict ?? {}),
|
||||||
|
meta: asJson({ ...city.meta, state: city.state }),
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
expect(rolledBackActor.turnTime.toISOString()).toBe(
|
await db.troop.createMany({
|
||||||
expected.before.generals.find((general) => general.id === request.actorGeneralId)?.turnTime
|
data: snapshot.troops.map((troop) => ({
|
||||||
);
|
troopLeaderId: troop.id,
|
||||||
expect(rolledBackActor.recentWarTime).toBeNull();
|
nationId: troop.nationId,
|
||||||
expect(rolledBackTurns[0]?.actionCode).toBe('che_출병');
|
name: troop.name,
|
||||||
expect(await db.logEntry.count()).toBe(0);
|
})),
|
||||||
expect(world.peekDirtyState().logs).toHaveLength(dirtyBeforeFailure.logs.length);
|
});
|
||||||
|
await db.general.createMany({
|
||||||
|
data: snapshot.generals.map((general) => ({
|
||||||
|
id: general.id,
|
||||||
|
userId: general.userId,
|
||||||
|
name: general.name,
|
||||||
|
nationId: general.nationId,
|
||||||
|
cityId: general.cityId,
|
||||||
|
troopId: general.troopId,
|
||||||
|
npcState: general.npcState,
|
||||||
|
affinity: general.affinity,
|
||||||
|
bornYear: general.bornYear,
|
||||||
|
deadYear: general.deadYear,
|
||||||
|
picture: general.picture,
|
||||||
|
leadership: Math.round(general.stats.leadership),
|
||||||
|
strength: Math.round(general.stats.strength),
|
||||||
|
intel: Math.round(general.stats.intelligence),
|
||||||
|
injury: Math.round(general.injury),
|
||||||
|
experience: Math.round(general.experience),
|
||||||
|
dedication: Math.round(general.dedication),
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
gold: Math.round(general.gold),
|
||||||
|
rice: Math.round(general.rice),
|
||||||
|
crew: Math.round(general.crew),
|
||||||
|
crewTypeId: general.crewTypeId,
|
||||||
|
train: Math.round(general.train),
|
||||||
|
atmos: Math.round(general.atmos),
|
||||||
|
age: general.age,
|
||||||
|
startAge: general.startAge,
|
||||||
|
personalCode: nullableCode(general.role.personality),
|
||||||
|
specialCode: nullableCode(general.role.specialDomestic),
|
||||||
|
special2Code: nullableCode(general.role.specialWar),
|
||||||
|
horseCode: nullableCode(general.role.items.horse),
|
||||||
|
weaponCode: nullableCode(general.role.items.weapon),
|
||||||
|
bookCode: nullableCode(general.role.items.book),
|
||||||
|
itemCode: nullableCode(general.role.items.item),
|
||||||
|
turnTime: general.turnTime,
|
||||||
|
recentWarTime: general.recentWarTime,
|
||||||
|
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||||
|
meta: asJson(general.meta),
|
||||||
|
penalty: asJson(general.penalty ?? {}),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await db.rankData.createMany({
|
||||||
|
data: snapshot.generals.flatMap((general) =>
|
||||||
|
buildLegacyComparableRankRows(general).map((row) => ({
|
||||||
|
generalId: row.generalId,
|
||||||
|
nationId: row.nationId,
|
||||||
|
type: row.type,
|
||||||
|
value: row.value,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await db.diplomacy.createMany({
|
||||||
|
data: snapshot.diplomacy.map((entry) => ({
|
||||||
|
srcNationId: entry.fromNationId,
|
||||||
|
destNationId: entry.toNationId,
|
||||||
|
stateCode: entry.state,
|
||||||
|
term: entry.term,
|
||||||
|
isDead: entry.dead !== 0,
|
||||||
|
meta: asJson(entry.meta),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await db.generalTurn.createMany({
|
||||||
|
data: snapshot.generals.flatMap((general) =>
|
||||||
|
Array.from({ length: 30 }, (_, turnIdx) => ({
|
||||||
|
generalId: general.id,
|
||||||
|
turnIdx,
|
||||||
|
actionCode: general.id === request.actorGeneralId && turnIdx === 0 ? request.action : '휴식',
|
||||||
|
arg: asJson(general.id === request.actorGeneralId && turnIdx === 0 ? coreArgs : {}),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reservedTurns = new InMemoryReservedTurnStore(db, {
|
||||||
|
maxGeneralTurns: 30,
|
||||||
|
maxNationTurns: 12,
|
||||||
|
leaseOwner,
|
||||||
|
leaseDurationMs: 60_000,
|
||||||
|
});
|
||||||
|
await reservedTurns.loadAll();
|
||||||
|
await reservedTurns.prepareTurnsForExecution(request.actorGeneralId);
|
||||||
|
|
||||||
|
let world: InMemoryTurnWorld | null = null;
|
||||||
|
const handler = await createReservedTurnHandler({
|
||||||
|
reservedTurns,
|
||||||
|
scenarioConfig: snapshot.scenarioConfig,
|
||||||
|
scenarioMeta: snapshot.scenarioMeta,
|
||||||
|
map,
|
||||||
|
unitSet,
|
||||||
|
getWorld: () => world,
|
||||||
|
commandProfile: createCoreTurnCommandProfile(request),
|
||||||
|
});
|
||||||
|
world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
generalTurnHandler: handler,
|
||||||
|
});
|
||||||
|
const actor = world.getGeneralById(request.actorGeneralId);
|
||||||
|
if (!actor) {
|
||||||
|
throw new Error('fixture actor is missing');
|
||||||
|
}
|
||||||
|
world.executeGeneralTurn(actor);
|
||||||
|
|
||||||
|
const dirtyBeforeFailure = world.peekDirtyState();
|
||||||
|
expect(dirtyBeforeFailure.generals.length).toBeGreaterThan(0);
|
||||||
|
expect(dirtyBeforeFailure.logs.length).toBeGreaterThan(0);
|
||||||
|
for (const dirtyGeneral of dirtyBeforeFailure.generals) {
|
||||||
|
expect(dirtyGeneral.recentWarTime?.toISOString() ?? null).toBe(
|
||||||
|
expected.after.generals.find((general) => general.id === dirtyGeneral.id)?.recentWarTime ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId);
|
expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId);
|
||||||
|
|
||||||
await db.generalTurnRevision.update({
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
|
||||||
where: { generalId: request.actorGeneralId },
|
try {
|
||||||
data: {
|
await db.generalTurnRevision.update({
|
||||||
leaseOwner,
|
|
||||||
leaseExpiresAt: new Date(Date.now() + 60_000),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await hooks.hooks.flushChanges?.(turnRunResult);
|
|
||||||
|
|
||||||
const persistedActor = await db.general.findUniqueOrThrow({
|
|
||||||
where: { id: request.actorGeneralId },
|
|
||||||
});
|
|
||||||
const persistedDefender = await db.general.findUniqueOrThrow({ where: { id: 2 } });
|
|
||||||
const persistedTurns = await db.generalTurn.findMany({
|
|
||||||
where: { generalId: request.actorGeneralId },
|
|
||||||
orderBy: { turnIdx: 'asc' },
|
|
||||||
});
|
|
||||||
const expectedActor = expected.after.generals.find((general) => general.id === request.actorGeneralId);
|
|
||||||
const expectedDefender = expected.after.generals.find((general) => general.id === 2);
|
|
||||||
expect(persistedActor.turnTime.toISOString()).toBe(expectedActor?.turnTime);
|
|
||||||
expect(persistedActor.recentWarTime?.toISOString()).toBe(expectedActor?.recentWarTime);
|
|
||||||
expect(persistedDefender.recentWarTime?.toISOString()).toBe(expectedDefender?.recentWarTime);
|
|
||||||
expect(asRecord(persistedActor.meta).myset).toBe(expectedActor?.mySet);
|
|
||||||
expect(asRecord(persistedActor.meta).killturn).toBe(expectedActor?.killTurn);
|
|
||||||
expect(asRecord(persistedActor.lastTurn).command).toBe(asRecord(expectedActor?.lastTurn).command);
|
|
||||||
expect(persistedTurns[0]?.actionCode).toBe('휴식');
|
|
||||||
expect(await db.logEntry.count()).toBe(expected.after.logs.length);
|
|
||||||
expect(world.peekDirtyState().logs).toEqual([]);
|
|
||||||
expect(reservedTurns.peekDirtyState().generalIds).toEqual([]);
|
|
||||||
|
|
||||||
const committedRevision = await db.generalTurnRevision.findUniqueOrThrow({
|
|
||||||
where: { generalId: request.actorGeneralId },
|
|
||||||
});
|
|
||||||
const logCount = await db.logEntry.count();
|
|
||||||
await hooks.hooks.flushChanges?.(turnRunResult);
|
|
||||||
expect(await db.logEntry.count()).toBe(logCount);
|
|
||||||
expect(
|
|
||||||
await db.generalTurnRevision.findUniqueOrThrow({
|
|
||||||
where: { generalId: request.actorGeneralId },
|
where: { generalId: request.actorGeneralId },
|
||||||
})
|
data: {
|
||||||
).toMatchObject({
|
leaseOwner: 'lost-to-another-writer',
|
||||||
revision: committedRevision.revision,
|
leaseExpiresAt: new Date(Date.now() + 60_000),
|
||||||
leaseOwner: committedRevision.leaseOwner,
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
await hooks.close();
|
await expect(hooks.hooks.flushChanges?.(turnRunResult)).rejects.toBeInstanceOf(
|
||||||
}
|
ReservedTurnLeaseConflictError
|
||||||
}, 120_000);
|
);
|
||||||
|
|
||||||
|
const rolledBackActor = await db.general.findUniqueOrThrow({
|
||||||
|
where: { id: request.actorGeneralId },
|
||||||
|
});
|
||||||
|
const rolledBackTurns = await db.generalTurn.findMany({
|
||||||
|
where: { generalId: request.actorGeneralId },
|
||||||
|
orderBy: { turnIdx: 'asc' },
|
||||||
|
});
|
||||||
|
expect(rolledBackActor.turnTime.toISOString()).toBe(
|
||||||
|
expected.before.generals.find((general) => general.id === request.actorGeneralId)?.turnTime
|
||||||
|
);
|
||||||
|
expect(rolledBackActor.recentWarTime).toBeNull();
|
||||||
|
expect(rolledBackTurns[0]?.actionCode).toBe('che_출병');
|
||||||
|
expect(await db.logEntry.count()).toBe(0);
|
||||||
|
expect(await db.oldNation.count()).toBe(0);
|
||||||
|
expect(world.peekDirtyState().logs).toHaveLength(dirtyBeforeFailure.logs.length);
|
||||||
|
expect(reservedTurns.peekDirtyState().generalIds).toContain(request.actorGeneralId);
|
||||||
|
|
||||||
|
await db.generalTurnRevision.update({
|
||||||
|
where: { generalId: request.actorGeneralId },
|
||||||
|
data: {
|
||||||
|
leaseOwner,
|
||||||
|
leaseExpiresAt: new Date(Date.now() + 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await hooks.hooks.flushChanges?.(turnRunResult);
|
||||||
|
|
||||||
|
const persistedTurns = await db.generalTurn.findMany({
|
||||||
|
where: { generalId: request.actorGeneralId },
|
||||||
|
orderBy: { turnIdx: 'asc' },
|
||||||
|
});
|
||||||
|
const expectedActor = expected.after.generals.find((general) => general.id === request.actorGeneralId);
|
||||||
|
const persistedActor = await db.general.findUniqueOrThrow({
|
||||||
|
where: { id: request.actorGeneralId },
|
||||||
|
});
|
||||||
|
expect(persistedActor.turnTime.toISOString()).toBe(expectedActor?.turnTime);
|
||||||
|
expect(persistedActor.recentWarTime?.toISOString()).toBe(expectedActor?.recentWarTime);
|
||||||
|
expect(asRecord(persistedActor.meta).myset).toBe(expectedActor?.mySet);
|
||||||
|
expect(asRecord(persistedActor.meta).killturn).toBe(expectedActor?.killTurn);
|
||||||
|
expect(asRecord(persistedActor.lastTurn).command).toBe(asRecord(expectedActor?.lastTurn).command);
|
||||||
|
expect(persistedTurns[0]?.actionCode).toBe('휴식');
|
||||||
|
for (const expectedGeneral of expected.after.generals) {
|
||||||
|
const persistedGeneral = await db.general.findUniqueOrThrow({
|
||||||
|
where: { id: Number(expectedGeneral.id) },
|
||||||
|
});
|
||||||
|
expect(persistedGeneral).toMatchObject({
|
||||||
|
nationId: expectedGeneral.nationId,
|
||||||
|
cityId: expectedGeneral.cityId,
|
||||||
|
crew: expectedGeneral.crew,
|
||||||
|
});
|
||||||
|
expect(persistedGeneral.turnTime.toISOString()).toBe(expectedGeneral.turnTime);
|
||||||
|
expect(persistedGeneral.recentWarTime?.toISOString() ?? null).toBe(expectedGeneral.recentWarTime);
|
||||||
|
}
|
||||||
|
for (const expectedCity of expected.after.cities) {
|
||||||
|
const persistedCity = await db.city.findUniqueOrThrow({
|
||||||
|
where: { id: Number(expectedCity.id) },
|
||||||
|
});
|
||||||
|
expect(persistedCity).toMatchObject({
|
||||||
|
nationId: expectedCity.nationId,
|
||||||
|
defence: expectedCity.defence,
|
||||||
|
conflict: expectedCity.conflict,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const beforeNation of expected.before.nations) {
|
||||||
|
const expectedNation = expected.after.nations.find((nation) => nation.id === beforeNation.id);
|
||||||
|
const persistedNation = await db.nation.findUnique({
|
||||||
|
where: { id: Number(beforeNation.id) },
|
||||||
|
});
|
||||||
|
if (!expectedNation) {
|
||||||
|
expect(persistedNation).toBeNull();
|
||||||
|
} else {
|
||||||
|
expect(persistedNation).toMatchObject({
|
||||||
|
capitalCityId: expectedNation.capitalCityId,
|
||||||
|
gold: expectedNation.gold,
|
||||||
|
rice: expectedNation.rice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deletedNationCount = expected.before.nations.filter(
|
||||||
|
(nation) => !expected.after.nations.some((afterNation) => afterNation.id === nation.id)
|
||||||
|
).length;
|
||||||
|
expect(await db.oldNation.count()).toBe(deletedNationCount);
|
||||||
|
expect(await db.logEntry.count()).toBe(expected.after.logs.length);
|
||||||
|
expect(world.peekDirtyState().logs).toEqual([]);
|
||||||
|
expect(reservedTurns.peekDirtyState().generalIds).toEqual([]);
|
||||||
|
|
||||||
|
const committedRevision = await db.generalTurnRevision.findUniqueOrThrow({
|
||||||
|
where: { generalId: request.actorGeneralId },
|
||||||
|
});
|
||||||
|
const logCount = await db.logEntry.count();
|
||||||
|
const oldNationCount = await db.oldNation.count();
|
||||||
|
await hooks.hooks.flushChanges?.(turnRunResult);
|
||||||
|
expect(await db.logEntry.count()).toBe(logCount);
|
||||||
|
expect(await db.oldNation.count()).toBe(oldNationCount);
|
||||||
|
expect(
|
||||||
|
await db.generalTurnRevision.findUniqueOrThrow({
|
||||||
|
where: { generalId: request.actorGeneralId },
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
revision: committedRevision.revision,
|
||||||
|
leaseOwner: committedRevision.leaseOwner,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await hooks.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user