831 lines
32 KiB
TypeScript
831 lines
32 KiB
TypeScript
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra';
|
|
import { isRecord } from '@sammo-ts/common';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
export interface ReservedTurnEntry {
|
|
action: string;
|
|
args: Record<string, unknown>;
|
|
}
|
|
|
|
export interface ReservedTurnStoreOptions {
|
|
databaseUrl: string;
|
|
maxGeneralTurns?: number;
|
|
maxNationTurns?: number;
|
|
leaseOwner?: string;
|
|
leaseDurationMs?: number;
|
|
}
|
|
|
|
export interface ReservedTurnStoreHandle {
|
|
store: InMemoryReservedTurnStore;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
const DEFAULT_TURN_ACTION = '휴식';
|
|
const DEFAULT_GENERAL_TURNS = 30;
|
|
const DEFAULT_NATION_TURNS = 12;
|
|
const DEFAULT_LEASE_DURATION_MS = 5 * 60_000;
|
|
|
|
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
|
|
|
const normalizeAction = (action: string | null | undefined): string =>
|
|
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
|
|
|
|
const normalizeArgs = (args: unknown): Record<string, unknown> => (isRecord(args) ? args : {});
|
|
|
|
const createDefaultEntry = (): ReservedTurnEntry => ({
|
|
action: DEFAULT_TURN_ACTION,
|
|
args: {},
|
|
});
|
|
|
|
const buildDefaultTurns = (length: number): ReservedTurnEntry[] => Array.from({ length }, () => createDefaultEntry());
|
|
|
|
const applyShift = (turns: ReservedTurnEntry[], amount: number): ReservedTurnEntry[] => {
|
|
if (amount === 0) {
|
|
return turns.slice();
|
|
}
|
|
if (amount > 0) {
|
|
const shift = Math.min(turns.length, amount);
|
|
const padding = Array.from({ length: shift }, () => createDefaultEntry());
|
|
const sliced = turns.slice(0, Math.max(0, turns.length - shift));
|
|
return padding.concat(sliced);
|
|
}
|
|
const shift = Math.min(turns.length, Math.abs(amount));
|
|
const padding = Array.from({ length: shift }, () => createDefaultEntry());
|
|
const sliced = turns.slice(shift);
|
|
return sliced.concat(padding);
|
|
};
|
|
|
|
const buildTurnListFromRows = (
|
|
rows: Array<{ turnIdx: number; actionCode: string; arg: unknown }>,
|
|
maxTurns: number
|
|
): ReservedTurnEntry[] => {
|
|
const result = buildDefaultTurns(maxTurns);
|
|
for (const row of rows) {
|
|
if (row.turnIdx < 0 || row.turnIdx >= maxTurns) {
|
|
continue;
|
|
}
|
|
result[row.turnIdx] = {
|
|
action: normalizeAction(row.actionCode),
|
|
args: normalizeArgs(row.arg),
|
|
};
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
|
|
|
|
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & {
|
|
generalTurnRevision?: Pick<
|
|
NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>,
|
|
'findUnique' | 'createMany' | 'updateMany'
|
|
>;
|
|
nationTurnRevision?: Pick<
|
|
NonNullable<TurnEngineDatabaseClient['nationTurnRevision']>,
|
|
'findUnique' | 'createMany' | 'updateMany'
|
|
>;
|
|
};
|
|
|
|
export interface ReservedTurnChanges {
|
|
generalIds: number[];
|
|
generalInitializationIds: number[];
|
|
generalLeaseIds: number[];
|
|
nationKeys: string[];
|
|
nationInitializationKeys: string[];
|
|
nationLeaseKeys: string[];
|
|
}
|
|
|
|
export interface InMemoryReservedTurnStateSnapshot {
|
|
generalTurns: Array<[number, ReservedTurnEntry[]]>;
|
|
nationTurns: Array<[string, ReservedTurnEntry[]]>;
|
|
dirtyGeneralIds: number[];
|
|
dirtyNationKeys: string[];
|
|
pendingGeneralInitializationIds: number[];
|
|
pendingNationInitializationKeys: string[];
|
|
leasedGeneralIds: number[];
|
|
leasedNationKeys: string[];
|
|
}
|
|
|
|
export class ReservedTurnLeaseConflictError extends Error {
|
|
constructor(readonly queueKey: string) {
|
|
super(`Reserved turn queue lease conflict: ${queueKey}.`);
|
|
this.name = 'ReservedTurnLeaseConflictError';
|
|
}
|
|
}
|
|
|
|
export class InMemoryReservedTurnStore {
|
|
private readonly generalTurns = new Map<number, ReservedTurnEntry[]>();
|
|
private readonly nationTurns = new Map<string, ReservedTurnEntry[]>();
|
|
private readonly dirtyGeneralIds = new Set<number>();
|
|
private readonly dirtyNationKeys = new Set<string>();
|
|
private readonly pendingGeneralInitializationIds = new Set<number>();
|
|
private readonly pendingNationInitializationKeys = new Set<string>();
|
|
private readonly leasedGeneralIds = new Set<number>();
|
|
private readonly leasedNationKeys = new Set<string>();
|
|
private readonly maxGeneralTurns: number;
|
|
private readonly maxNationTurns: number;
|
|
private readonly leaseOwner: string;
|
|
private readonly leaseDurationMs: number;
|
|
|
|
constructor(
|
|
private readonly prisma: ReservedTurnDatabaseClient,
|
|
options: {
|
|
maxGeneralTurns: number;
|
|
maxNationTurns: number;
|
|
leaseOwner?: string;
|
|
leaseDurationMs?: number;
|
|
}
|
|
) {
|
|
this.maxGeneralTurns = options.maxGeneralTurns;
|
|
this.maxNationTurns = options.maxNationTurns;
|
|
this.leaseOwner = options.leaseOwner ?? randomUUID();
|
|
this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
|
|
}
|
|
|
|
captureState(): InMemoryReservedTurnStateSnapshot {
|
|
return structuredClone({
|
|
generalTurns: Array.from(this.generalTurns.entries()),
|
|
nationTurns: Array.from(this.nationTurns.entries()),
|
|
dirtyGeneralIds: Array.from(this.dirtyGeneralIds),
|
|
dirtyNationKeys: Array.from(this.dirtyNationKeys),
|
|
pendingGeneralInitializationIds: Array.from(this.pendingGeneralInitializationIds),
|
|
pendingNationInitializationKeys: Array.from(this.pendingNationInitializationKeys),
|
|
leasedGeneralIds: Array.from(this.leasedGeneralIds),
|
|
leasedNationKeys: Array.from(this.leasedNationKeys),
|
|
} satisfies InMemoryReservedTurnStateSnapshot);
|
|
}
|
|
|
|
/**
|
|
* Hot-path transaction savepoint. Queue mutations replace complete turn
|
|
* arrays and journal sets instead of mutating captured entries in place,
|
|
* so retaining those immutable references is sufficient for rollback.
|
|
* Public inspection snapshots remain deep clones via captureState().
|
|
*/
|
|
captureTransactionState(): InMemoryReservedTurnStateSnapshot {
|
|
return {
|
|
generalTurns: Array.from(this.generalTurns.entries()),
|
|
nationTurns: Array.from(this.nationTurns.entries()),
|
|
dirtyGeneralIds: Array.from(this.dirtyGeneralIds),
|
|
dirtyNationKeys: Array.from(this.dirtyNationKeys),
|
|
pendingGeneralInitializationIds: Array.from(this.pendingGeneralInitializationIds),
|
|
pendingNationInitializationKeys: Array.from(this.pendingNationInitializationKeys),
|
|
leasedGeneralIds: Array.from(this.leasedGeneralIds),
|
|
leasedNationKeys: Array.from(this.leasedNationKeys),
|
|
};
|
|
}
|
|
|
|
restoreState(snapshot: InMemoryReservedTurnStateSnapshot): void {
|
|
const restored = structuredClone(snapshot);
|
|
this.replaceMap(this.generalTurns, restored.generalTurns);
|
|
this.replaceMap(this.nationTurns, restored.nationTurns);
|
|
this.replaceSet(this.dirtyGeneralIds, restored.dirtyGeneralIds);
|
|
this.replaceSet(this.dirtyNationKeys, restored.dirtyNationKeys);
|
|
this.replaceSet(this.pendingGeneralInitializationIds, restored.pendingGeneralInitializationIds);
|
|
this.replaceSet(this.pendingNationInitializationKeys, restored.pendingNationInitializationKeys);
|
|
this.replaceSet(this.leasedGeneralIds, restored.leasedGeneralIds);
|
|
this.replaceSet(this.leasedNationKeys, restored.leasedNationKeys);
|
|
}
|
|
|
|
inspectState(): InMemoryReservedTurnStateSnapshot {
|
|
return this.captureState();
|
|
}
|
|
|
|
async loadAll(): Promise<void> {
|
|
const [generalRows, nationRows] = await Promise.all([
|
|
this.prisma.generalTurn.findMany(),
|
|
this.prisma.nationTurn.findMany(),
|
|
]);
|
|
|
|
const generalGroups = new Map<number, typeof generalRows>();
|
|
for (const row of generalRows) {
|
|
const list = generalGroups.get(row.generalId);
|
|
if (list) {
|
|
list.push(row);
|
|
} else {
|
|
generalGroups.set(row.generalId, [row]);
|
|
}
|
|
}
|
|
for (const [generalId, rows] of generalGroups.entries()) {
|
|
this.generalTurns.set(generalId, buildTurnListFromRows(rows, this.maxGeneralTurns));
|
|
}
|
|
|
|
const nationGroups = new Map<string, typeof nationRows>();
|
|
for (const row of nationRows) {
|
|
const key = buildNationKey(row.nationId, row.officerLevel);
|
|
const list = nationGroups.get(key);
|
|
if (list) {
|
|
list.push(row);
|
|
} else {
|
|
nationGroups.set(key, [row]);
|
|
}
|
|
}
|
|
for (const [key, rows] of nationGroups.entries()) {
|
|
this.nationTurns.set(key, buildTurnListFromRows(rows, this.maxNationTurns));
|
|
}
|
|
}
|
|
|
|
private replaceMap<K, V>(target: Map<K, V>, entries: Array<[K, V]>): void {
|
|
target.clear();
|
|
for (const [key, value] of entries) {
|
|
target.set(key, value);
|
|
}
|
|
}
|
|
|
|
private replaceSet<T>(target: Set<T>, values: T[]): void {
|
|
target.clear();
|
|
for (const value of values) {
|
|
target.add(value);
|
|
}
|
|
}
|
|
|
|
private getLeaseExpiresAt(): Date {
|
|
return new Date(Date.now() + this.leaseDurationMs);
|
|
}
|
|
|
|
private async acquireGeneralLease(generalId: number): Promise<boolean> {
|
|
const revisionStore = this.prisma.generalTurnRevision;
|
|
if (!revisionStore) {
|
|
return false;
|
|
}
|
|
const now = new Date();
|
|
const previous = (await revisionStore.findUnique({ where: { generalId } })) as {
|
|
leaseOwner: string | null;
|
|
leaseExpiresAt: Date | null;
|
|
} | null;
|
|
const retainedExistingLease =
|
|
previous?.leaseOwner === this.leaseOwner &&
|
|
previous.leaseExpiresAt !== null &&
|
|
previous.leaseExpiresAt.getTime() > now.getTime();
|
|
const leaseExpiresAt = this.getLeaseExpiresAt();
|
|
let claimed = await revisionStore.updateMany({
|
|
where: {
|
|
generalId,
|
|
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
|
},
|
|
data: {
|
|
leaseOwner: this.leaseOwner,
|
|
leaseExpiresAt,
|
|
},
|
|
});
|
|
if (claimed.count === 0) {
|
|
claimed = await revisionStore.createMany({
|
|
data: [{ generalId, revision: 0, leaseOwner: this.leaseOwner, leaseExpiresAt }],
|
|
skipDuplicates: true,
|
|
});
|
|
if (claimed.count === 0) {
|
|
claimed = await revisionStore.updateMany({
|
|
where: {
|
|
generalId,
|
|
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
|
},
|
|
data: {
|
|
leaseOwner: this.leaseOwner,
|
|
leaseExpiresAt,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (claimed.count !== 1) {
|
|
throw new ReservedTurnLeaseConflictError(`general:${generalId}`);
|
|
}
|
|
this.leasedGeneralIds.add(generalId);
|
|
return !retainedExistingLease;
|
|
}
|
|
|
|
private async acquireNationLease(nationId: number, officerLevel: number): Promise<boolean> {
|
|
const revisionStore = this.prisma.nationTurnRevision;
|
|
if (!revisionStore) {
|
|
return false;
|
|
}
|
|
const now = new Date();
|
|
const previous = (await revisionStore.findUnique({
|
|
where: { nationId_officerLevel: { nationId, officerLevel } },
|
|
})) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null;
|
|
const retainedExistingLease =
|
|
previous?.leaseOwner === this.leaseOwner &&
|
|
previous.leaseExpiresAt !== null &&
|
|
previous.leaseExpiresAt.getTime() > now.getTime();
|
|
const leaseExpiresAt = this.getLeaseExpiresAt();
|
|
let claimed = await revisionStore.updateMany({
|
|
where: {
|
|
nationId,
|
|
officerLevel,
|
|
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
|
},
|
|
data: {
|
|
leaseOwner: this.leaseOwner,
|
|
leaseExpiresAt,
|
|
},
|
|
});
|
|
if (claimed.count === 0) {
|
|
claimed = await revisionStore.createMany({
|
|
data: [{ nationId, officerLevel, revision: 0, leaseOwner: this.leaseOwner, leaseExpiresAt }],
|
|
skipDuplicates: true,
|
|
});
|
|
if (claimed.count === 0) {
|
|
claimed = await revisionStore.updateMany({
|
|
where: {
|
|
nationId,
|
|
officerLevel,
|
|
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
|
},
|
|
data: {
|
|
leaseOwner: this.leaseOwner,
|
|
leaseExpiresAt,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (claimed.count !== 1) {
|
|
throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`);
|
|
}
|
|
this.leasedNationKeys.add(buildNationKey(nationId, officerLevel));
|
|
return !retainedExistingLease;
|
|
}
|
|
|
|
private async releaseGeneralLease(generalId: number): Promise<void> {
|
|
await this.prisma.generalTurnRevision?.updateMany({
|
|
where: { generalId, leaseOwner: this.leaseOwner },
|
|
data: { leaseOwner: null, leaseExpiresAt: null },
|
|
});
|
|
this.leasedGeneralIds.delete(generalId);
|
|
}
|
|
|
|
private async releaseNationLease(nationId: number, officerLevel: number): Promise<void> {
|
|
await this.prisma.nationTurnRevision?.updateMany({
|
|
where: { nationId, officerLevel, leaseOwner: this.leaseOwner },
|
|
data: { leaseOwner: null, leaseExpiresAt: null },
|
|
});
|
|
this.leasedNationKeys.delete(buildNationKey(nationId, officerLevel));
|
|
}
|
|
|
|
async prepareTurnsForExecution(
|
|
generalId: number,
|
|
nation?: { nationId: number; officerLevel: number }
|
|
): Promise<void> {
|
|
const hadGeneralLease = this.leasedGeneralIds.has(generalId);
|
|
const nationKey = nation ? buildNationKey(nation.nationId, nation.officerLevel) : null;
|
|
const hadNationLease = nationKey ? this.leasedNationKeys.has(nationKey) : false;
|
|
let acquiredFreshGeneralLease = false;
|
|
let acquiredFreshNationLease = false;
|
|
try {
|
|
acquiredFreshGeneralLease = await this.acquireGeneralLease(generalId);
|
|
if (nation) {
|
|
acquiredFreshNationLease = await this.acquireNationLease(nation.nationId, nation.officerLevel);
|
|
}
|
|
await Promise.all([
|
|
// A general queue is owned by only this execution. Once its lease
|
|
// is held, PostgreSQL is authoritative even if an interrupted
|
|
// acknowledgement left stale dirty/lease markers in memory.
|
|
// Nation queues remain conditional because multiple officers can
|
|
// consume the same shared queue in one daemon batch.
|
|
this.refreshGeneralTurns(generalId, true),
|
|
nation
|
|
? this.refreshNationTurns(nation.nationId, nation.officerLevel, acquiredFreshNationLease)
|
|
: Promise.resolve(),
|
|
]);
|
|
} catch (error) {
|
|
if (
|
|
nation &&
|
|
nationKey !== null &&
|
|
(!hadNationLease || acquiredFreshNationLease) &&
|
|
this.leasedNationKeys.has(nationKey)
|
|
) {
|
|
await this.releaseNationLease(nation.nationId, nation.officerLevel);
|
|
}
|
|
if ((!hadGeneralLease || acquiredFreshGeneralLease) && this.leasedGeneralIds.has(generalId)) {
|
|
await this.releaseGeneralLease(generalId);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async refreshGeneralTurns(generalId: number, force = false): Promise<void> {
|
|
if (!force && this.dirtyGeneralIds.has(generalId)) {
|
|
return;
|
|
}
|
|
const rows = await this.prisma.generalTurn.findMany({
|
|
where: { generalId },
|
|
orderBy: [{ turnIdx: 'asc' }],
|
|
});
|
|
this.generalTurns.set(generalId, buildTurnListFromRows(rows, this.maxGeneralTurns));
|
|
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(generalId))) {
|
|
process.stdout.write(
|
|
`RESERVED_TURN_REFRESH_TRACE ${JSON.stringify({
|
|
generalId,
|
|
force,
|
|
firstAction: rows.find((row) => row.turnIdx === 0)?.actionCode ?? DEFAULT_TURN_ACTION,
|
|
dirty: this.dirtyGeneralIds.has(generalId),
|
|
leased: this.leasedGeneralIds.has(generalId),
|
|
})}\n`
|
|
);
|
|
}
|
|
}
|
|
|
|
async prefetchGeneralTurns(generalIds: number[]): Promise<void> {
|
|
const targetIds = Array.from(new Set(generalIds)).filter((generalId) => !this.dirtyGeneralIds.has(generalId));
|
|
if (targetIds.length === 0) {
|
|
return;
|
|
}
|
|
const rows = await this.prisma.generalTurn.findMany({
|
|
where: { generalId: { in: targetIds } },
|
|
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
|
});
|
|
const grouped = new Map<number, typeof rows>();
|
|
for (const row of rows) {
|
|
const list = grouped.get(row.generalId);
|
|
if (list) {
|
|
list.push(row);
|
|
} else {
|
|
grouped.set(row.generalId, [row]);
|
|
}
|
|
}
|
|
for (const generalId of targetIds) {
|
|
const list = grouped.get(generalId) ?? [];
|
|
this.generalTurns.set(generalId, buildTurnListFromRows(list, this.maxGeneralTurns));
|
|
}
|
|
}
|
|
|
|
async refreshNationTurns(nationId: number, officerLevel: number, force = false): Promise<void> {
|
|
const key = buildNationKey(nationId, officerLevel);
|
|
if (!force && (this.dirtyNationKeys.has(key) || this.pendingNationInitializationKeys.has(key))) {
|
|
return;
|
|
}
|
|
const rows = await this.prisma.nationTurn.findMany({
|
|
where: { nationId, officerLevel },
|
|
orderBy: [{ turnIdx: 'asc' }],
|
|
});
|
|
this.nationTurns.set(key, buildTurnListFromRows(rows, this.maxNationTurns));
|
|
}
|
|
|
|
getGeneralTurns(generalId: number): ReservedTurnEntry[] {
|
|
const current = this.generalTurns.get(generalId);
|
|
if (current) {
|
|
return current;
|
|
}
|
|
const created = buildDefaultTurns(this.maxGeneralTurns);
|
|
this.generalTurns.set(generalId, created);
|
|
return created;
|
|
}
|
|
|
|
getNationTurns(nationId: number, officerLevel: number): ReservedTurnEntry[] {
|
|
const key = buildNationKey(nationId, officerLevel);
|
|
const current = this.nationTurns.get(key);
|
|
if (current) {
|
|
return current;
|
|
}
|
|
const created = buildDefaultTurns(this.maxNationTurns);
|
|
this.nationTurns.set(key, created);
|
|
return created;
|
|
}
|
|
|
|
getGeneralTurn(generalId: number, turnIdx: number): ReservedTurnEntry {
|
|
const list = this.getGeneralTurns(generalId);
|
|
return list[turnIdx] ?? createDefaultEntry();
|
|
}
|
|
|
|
getNationTurn(nationId: number, officerLevel: number, turnIdx: number): ReservedTurnEntry {
|
|
const list = this.getNationTurns(nationId, officerLevel);
|
|
return list[turnIdx] ?? createDefaultEntry();
|
|
}
|
|
|
|
ensureGeneralTurns(generalId: number): void {
|
|
this.getGeneralTurns(generalId);
|
|
this.pendingGeneralInitializationIds.add(generalId);
|
|
}
|
|
|
|
replaceGeneralTurns(generalId: number, entry: ReservedTurnEntry): void {
|
|
this.generalTurns.set(
|
|
generalId,
|
|
Array.from({ length: this.maxGeneralTurns }, () => ({
|
|
action: normalizeAction(entry.action),
|
|
args: normalizeArgs(entry.args),
|
|
}))
|
|
);
|
|
this.pendingGeneralInitializationIds.delete(generalId);
|
|
this.dirtyGeneralIds.add(generalId);
|
|
}
|
|
|
|
setGeneralTurn(generalId: number, turnIdx: number, entry: ReservedTurnEntry): void {
|
|
if (turnIdx < 0 || turnIdx >= this.maxGeneralTurns) {
|
|
return;
|
|
}
|
|
const turns = this.getGeneralTurns(generalId).slice();
|
|
turns[turnIdx] = {
|
|
action: normalizeAction(entry.action),
|
|
args: normalizeArgs(entry.args),
|
|
};
|
|
this.generalTurns.set(generalId, turns);
|
|
this.pendingGeneralInitializationIds.delete(generalId);
|
|
this.dirtyGeneralIds.add(generalId);
|
|
}
|
|
|
|
ensureNationTurns(nationId: number, officerLevel: number): void {
|
|
const key = buildNationKey(nationId, officerLevel);
|
|
this.getNationTurns(nationId, officerLevel);
|
|
this.pendingNationInitializationKeys.add(key);
|
|
}
|
|
|
|
shiftGeneralTurns(generalId: number, amount: number): void {
|
|
const list = this.getGeneralTurns(generalId);
|
|
this.generalTurns.set(generalId, applyShift(list, amount));
|
|
this.dirtyGeneralIds.add(generalId);
|
|
}
|
|
|
|
shiftNationTurns(nationId: number, officerLevel: number, amount: number): void {
|
|
const key = buildNationKey(nationId, officerLevel);
|
|
const list = this.getNationTurns(nationId, officerLevel);
|
|
this.nationTurns.set(key, applyShift(list, amount));
|
|
this.dirtyNationKeys.add(key);
|
|
}
|
|
|
|
peekDirtyState(): ReservedTurnChanges {
|
|
return {
|
|
generalIds: Array.from(this.dirtyGeneralIds),
|
|
generalInitializationIds: Array.from(this.pendingGeneralInitializationIds),
|
|
generalLeaseIds: Array.from(this.leasedGeneralIds),
|
|
nationKeys: Array.from(this.dirtyNationKeys),
|
|
nationInitializationKeys: Array.from(this.pendingNationInitializationKeys),
|
|
nationLeaseKeys: Array.from(this.leasedNationKeys),
|
|
};
|
|
}
|
|
|
|
acknowledgeDirtyState(changes: ReservedTurnChanges): void {
|
|
for (const generalId of changes.generalIds) {
|
|
this.dirtyGeneralIds.delete(generalId);
|
|
}
|
|
for (const generalId of changes.generalInitializationIds) {
|
|
this.pendingGeneralInitializationIds.delete(generalId);
|
|
}
|
|
for (const generalId of changes.generalLeaseIds) {
|
|
this.leasedGeneralIds.delete(generalId);
|
|
}
|
|
for (const key of changes.nationKeys) {
|
|
this.dirtyNationKeys.delete(key);
|
|
}
|
|
for (const key of changes.nationInitializationKeys) {
|
|
this.pendingNationInitializationKeys.delete(key);
|
|
}
|
|
for (const key of changes.nationLeaseKeys) {
|
|
this.leasedNationKeys.delete(key);
|
|
}
|
|
}
|
|
|
|
private async claimGeneralFlushLease(prisma: ReservedTurnDatabaseClient, generalId: number): Promise<boolean> {
|
|
const revisionStore = prisma.generalTurnRevision;
|
|
if (!revisionStore) {
|
|
return false;
|
|
}
|
|
const leaseExpiresAt = this.getLeaseExpiresAt();
|
|
const where = this.leasedGeneralIds.has(generalId)
|
|
? { generalId, leaseOwner: this.leaseOwner }
|
|
: {
|
|
generalId,
|
|
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
|
|
};
|
|
let claimed = await revisionStore.updateMany({
|
|
where,
|
|
data: {
|
|
leaseOwner: this.leaseOwner,
|
|
leaseExpiresAt,
|
|
},
|
|
});
|
|
if (claimed.count === 0 && !this.leasedGeneralIds.has(generalId)) {
|
|
claimed = await revisionStore.createMany({
|
|
data: [{ generalId, revision: 0, leaseOwner: this.leaseOwner, leaseExpiresAt }],
|
|
skipDuplicates: true,
|
|
});
|
|
if (claimed.count === 0) {
|
|
claimed = await revisionStore.updateMany({
|
|
where,
|
|
data: {
|
|
leaseOwner: this.leaseOwner,
|
|
leaseExpiresAt,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (claimed.count !== 1) {
|
|
throw new ReservedTurnLeaseConflictError(`general:${generalId}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private async finalizeGeneralFlush(
|
|
prisma: ReservedTurnDatabaseClient,
|
|
generalId: number,
|
|
claimedLease: boolean
|
|
): Promise<void> {
|
|
const revisionStore = prisma.generalTurnRevision;
|
|
if (!revisionStore) {
|
|
return;
|
|
}
|
|
if (!claimedLease) {
|
|
throw new ReservedTurnLeaseConflictError(`general:${generalId}`);
|
|
}
|
|
const finalized = await revisionStore.updateMany({
|
|
where: { generalId, leaseOwner: this.leaseOwner },
|
|
data: {
|
|
revision: { increment: 1 },
|
|
leaseOwner: null,
|
|
leaseExpiresAt: null,
|
|
},
|
|
});
|
|
if (finalized.count !== 1) {
|
|
throw new ReservedTurnLeaseConflictError(`general:${generalId}`);
|
|
}
|
|
}
|
|
|
|
private async claimNationFlushLease(
|
|
prisma: ReservedTurnDatabaseClient,
|
|
nationId: number,
|
|
officerLevel: number
|
|
): Promise<boolean> {
|
|
const revisionStore = prisma.nationTurnRevision;
|
|
const key = buildNationKey(nationId, officerLevel);
|
|
if (!revisionStore) {
|
|
return false;
|
|
}
|
|
const leaseExpiresAt = this.getLeaseExpiresAt();
|
|
const where = this.leasedNationKeys.has(key)
|
|
? { nationId, officerLevel, leaseOwner: this.leaseOwner }
|
|
: {
|
|
nationId,
|
|
officerLevel,
|
|
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
|
|
};
|
|
let claimed = await revisionStore.updateMany({
|
|
where,
|
|
data: {
|
|
leaseOwner: this.leaseOwner,
|
|
leaseExpiresAt,
|
|
},
|
|
});
|
|
if (claimed.count === 0 && !this.leasedNationKeys.has(key)) {
|
|
claimed = await revisionStore.createMany({
|
|
data: [{ nationId, officerLevel, revision: 0, leaseOwner: this.leaseOwner, leaseExpiresAt }],
|
|
skipDuplicates: true,
|
|
});
|
|
if (claimed.count === 0) {
|
|
claimed = await revisionStore.updateMany({
|
|
where,
|
|
data: {
|
|
leaseOwner: this.leaseOwner,
|
|
leaseExpiresAt,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (claimed.count !== 1) {
|
|
throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private async finalizeNationFlush(
|
|
prisma: ReservedTurnDatabaseClient,
|
|
nationId: number,
|
|
officerLevel: number,
|
|
claimedLease: boolean
|
|
): Promise<void> {
|
|
const revisionStore = prisma.nationTurnRevision;
|
|
if (!revisionStore) {
|
|
return;
|
|
}
|
|
if (!claimedLease) {
|
|
throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`);
|
|
}
|
|
const finalized = await revisionStore.updateMany({
|
|
where: { nationId, officerLevel, leaseOwner: this.leaseOwner },
|
|
data: {
|
|
revision: { increment: 1 },
|
|
leaseOwner: null,
|
|
leaseExpiresAt: null,
|
|
},
|
|
});
|
|
if (finalized.count !== 1) {
|
|
throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`);
|
|
}
|
|
}
|
|
|
|
async persistChanges(prisma: ReservedTurnDatabaseClient, changes: ReservedTurnChanges): Promise<void> {
|
|
for (const generalId of changes.generalIds) {
|
|
const turns = this.getGeneralTurns(generalId);
|
|
const claimedLease = await this.claimGeneralFlushLease(prisma, generalId);
|
|
await prisma.generalTurn.deleteMany({ where: { generalId } });
|
|
await prisma.generalTurn.createMany({
|
|
data: turns.map((entry, turnIdx) => ({
|
|
generalId,
|
|
turnIdx,
|
|
actionCode: normalizeAction(entry.action),
|
|
arg: asJson(normalizeArgs(entry.args)),
|
|
})),
|
|
});
|
|
await this.finalizeGeneralFlush(prisma, generalId, claimedLease);
|
|
}
|
|
|
|
for (const generalId of changes.generalInitializationIds) {
|
|
if (changes.generalIds.includes(generalId)) {
|
|
continue;
|
|
}
|
|
const turns = this.getGeneralTurns(generalId);
|
|
await prisma.generalTurn.createMany({
|
|
data: turns.map((entry, turnIdx) => ({
|
|
generalId,
|
|
turnIdx,
|
|
actionCode: normalizeAction(entry.action),
|
|
arg: asJson(normalizeArgs(entry.args)),
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
for (const key of changes.nationKeys) {
|
|
const [nationIdRaw, officerLevelRaw] = key.split(':');
|
|
const nationId = Number(nationIdRaw);
|
|
const officerLevel = Number(officerLevelRaw);
|
|
const turns = this.getNationTurns(nationId, officerLevel);
|
|
const claimedLease = await this.claimNationFlushLease(prisma, nationId, officerLevel);
|
|
await prisma.nationTurn.deleteMany({
|
|
where: { nationId, officerLevel },
|
|
});
|
|
await prisma.nationTurn.createMany({
|
|
data: turns.map((entry, turnIdx) => ({
|
|
nationId,
|
|
officerLevel,
|
|
turnIdx,
|
|
actionCode: normalizeAction(entry.action),
|
|
arg: asJson(normalizeArgs(entry.args)),
|
|
})),
|
|
});
|
|
await this.finalizeNationFlush(prisma, nationId, officerLevel, claimedLease);
|
|
}
|
|
|
|
for (const key of changes.nationInitializationKeys) {
|
|
if (changes.nationKeys.includes(key)) {
|
|
continue;
|
|
}
|
|
const [nationIdRaw, officerLevelRaw] = key.split(':');
|
|
const nationId = Number(nationIdRaw);
|
|
const officerLevel = Number(officerLevelRaw);
|
|
const turns = this.getNationTurns(nationId, officerLevel);
|
|
await prisma.nationTurn.createMany({
|
|
data: turns.map((entry, turnIdx) => ({
|
|
nationId,
|
|
officerLevel,
|
|
turnIdx,
|
|
actionCode: normalizeAction(entry.action),
|
|
arg: asJson(normalizeArgs(entry.args)),
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
for (const generalId of changes.generalLeaseIds) {
|
|
if (changes.generalIds.includes(generalId)) {
|
|
continue;
|
|
}
|
|
await prisma.generalTurnRevision?.updateMany({
|
|
where: { generalId, leaseOwner: this.leaseOwner },
|
|
data: { leaseOwner: null, leaseExpiresAt: null },
|
|
});
|
|
}
|
|
|
|
for (const key of changes.nationLeaseKeys) {
|
|
if (changes.nationKeys.includes(key)) {
|
|
continue;
|
|
}
|
|
const [nationIdRaw, officerLevelRaw] = key.split(':');
|
|
await prisma.nationTurnRevision?.updateMany({
|
|
where: {
|
|
nationId: Number(nationIdRaw),
|
|
officerLevel: Number(officerLevelRaw),
|
|
leaseOwner: this.leaseOwner,
|
|
},
|
|
data: { leaseOwner: null, leaseExpiresAt: null },
|
|
});
|
|
}
|
|
}
|
|
|
|
async flushChanges(): Promise<void> {
|
|
const changes = this.peekDirtyState();
|
|
await this.persistChanges(this.prisma, changes);
|
|
this.acknowledgeDirtyState(changes);
|
|
}
|
|
}
|
|
|
|
export const createReservedTurnStore = async (options: ReservedTurnStoreOptions): Promise<ReservedTurnStoreHandle> => {
|
|
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
|
await connector.connect();
|
|
const store = new InMemoryReservedTurnStore(connector.prisma, {
|
|
maxGeneralTurns: options.maxGeneralTurns ?? DEFAULT_GENERAL_TURNS,
|
|
maxNationTurns: options.maxNationTurns ?? DEFAULT_NATION_TURNS,
|
|
leaseOwner: options.leaseOwner,
|
|
leaseDurationMs: options.leaseDurationMs,
|
|
});
|
|
await store.loadAll();
|
|
return {
|
|
store,
|
|
close: () => connector.disconnect(),
|
|
};
|
|
};
|