토너먼트 NPC 개방 베팅을 복구하고 장수 DB 저장을 일괄 처리

This commit is contained in:
2026-09-12 08:04:48 +00:00
parent 3ad230beb3
commit 4dac08b916
9 changed files with 581 additions and 62 deletions
+21 -26
View File
@@ -1,3 +1,4 @@
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import {
acquireGameSchemaAdvisoryXactLock,
@@ -1791,32 +1792,26 @@ export const createDatabaseTurnHooks = async (
}
await Promise.all([
...generals
.filter((general) => !createdIds.has(general.id))
.map((general) =>
prisma.general.update({
where: { id: general.id },
data: buildGeneralUpdate(general),
})
),
...generals
.filter(
(general) =>
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
)
.map((general) =>
prisma.generalAccessLog.upsert({
where: { generalId: general.id },
update: {
refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0),
},
create: {
generalId: general.id,
userId: general.userId ?? null,
refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0),
},
})
),
persistGeneralUpdates(
prisma,
generals
.filter((general) => !createdIds.has(general.id))
.map((general) => ({ id: general.id, data: buildGeneralUpdate(general) }))
),
persistGeneralAccessScores(
prisma,
generals
.filter(
(general) =>
typeof general.refreshScoreTotal === 'number' &&
Number.isFinite(general.refreshScoreTotal)
)
.map((general) => ({
generalId: general.id,
userId: general.userId ?? null,
refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0),
}))
),
...cities.map((city) =>
prisma.city.update({
where: { id: city.id },
@@ -0,0 +1,100 @@
import { GamePrisma, type TurnEngineGeneralUpdateInput } from '@sammo-ts/infra';
// Fixed schema identifiers only; all row values remain bound parameters.
const columns = {
userId: 'user_id',
name: 'name',
nationId: 'nation_id',
cityId: 'city_id',
troopId: 'troop_id',
leadership: 'leadership',
strength: 'strength',
intel: 'intel',
experience: 'experience',
dedication: 'dedication',
officerLevel: 'officer_level',
injury: 'injury',
gold: 'gold',
rice: 'rice',
crew: 'crew',
crewTypeId: 'crew_type_id',
train: 'train',
atmos: 'atmos',
age: 'age',
npcState: 'npc_state',
affinity: 'affinity',
bornYear: 'born_year',
deadYear: 'dead_year',
picture: 'picture',
imageServer: 'image_server',
startAge: 'start_age',
horseCode: 'horse_code',
weaponCode: 'weapon_code',
bookCode: 'book_code',
itemCode: 'item_code',
personalCode: 'personal_code',
specialCode: 'special_code',
special2Code: 'special2_code',
lastTurn: 'last_turn',
penalty: 'penalty',
meta: 'meta',
turnTime: 'turn_time',
turnTick: 'turn_tick',
recentWarTime: 'recent_war_time',
recentWarTick: 'recent_war_tick',
} satisfies Record<keyof TurnEngineGeneralUpdateInput, string>;
export const GENERAL_UPDATE_BATCH_SIZE = 500;
export const persistGeneralUpdates = async (
database: { $executeRaw(query: GamePrisma.Sql): Promise<number> },
updates: Array<{ id: number; data: TurnEngineGeneralUpdateInput }>
): Promise<void> => {
if (new Set(updates.map((entry) => entry.id)).size !== updates.length) {
throw new Error('Duplicate general IDs in persistence batch.');
}
const assignments = Object.entries(columns).map(([key, column]) => {
const identifier = GamePrisma.raw(`"${column}"`);
// Prisma omits undefined optional birth/death years instead of clearing them.
return key === 'bornYear' || key === 'deadYear'
? GamePrisma.sql`${identifier} = COALESCE(source.${identifier}, target.${identifier})`
: GamePrisma.sql`${identifier} = source.${identifier}`;
});
for (let offset = 0; offset < updates.length; offset += GENERAL_UPDATE_BATCH_SIZE) {
const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE);
const rows = batch.map(({ id, data }) => ({
id,
...Object.fromEntries(
(Object.keys(columns) as Array<keyof typeof columns>).map((key) => [columns[key], data[key]])
),
}));
const payload = JSON.stringify(rows, (_key, value: unknown) =>
typeof value === 'bigint' ? value.toString() : value
);
const updated = await database.$executeRaw(GamePrisma.sql`
UPDATE "general" AS target
SET ${GamePrisma.join(assignments)}
FROM jsonb_populate_recordset(NULL::"general", ${payload}::jsonb) AS source
WHERE target."id" = source."id"
`);
// Preserve Prisma update's missing-row failure and transaction rollback.
if (updated !== batch.length) {
throw new Error(`General persistence batch expected ${batch.length} rows, updated ${updated}.`);
}
}
};
export const persistGeneralAccessScores = async (
database: { $executeRaw(query: GamePrisma.Sql): Promise<number> },
updates: Array<{ generalId: number; userId: string | null; refreshScoreTotal: number }>
): Promise<void> => {
for (let offset = 0; offset < updates.length; offset += GENERAL_UPDATE_BATCH_SIZE) {
const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE);
await database.$executeRaw(GamePrisma.sql`
INSERT INTO "general_access_log" ("general_id", "user_id", "refresh_score_total")
VALUES ${GamePrisma.join(batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.userId}, ${row.refreshScoreTotal})`))}
ON CONFLICT ("general_id") DO UPDATE
SET "refresh_score_total" = EXCLUDED."refresh_score_total"
`);
}
};