시간 도메인과 정지 중 메시지·베팅 경계 정리
This commit is contained in:
@@ -18,6 +18,10 @@ interface MessageRow {
|
||||
type: string;
|
||||
time: Date;
|
||||
validUntil: Date;
|
||||
actionType: string;
|
||||
actionStatus: string;
|
||||
createdGameTick: bigint;
|
||||
expiresGameTick: bigint | null;
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
@@ -98,11 +102,16 @@ const invalidateMessageIds = async (
|
||||
): Promise<void> => {
|
||||
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const resolvedGameTick = BigInt(world.dateToGameTick(now));
|
||||
await db.messageAction.updateMany({
|
||||
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick },
|
||||
});
|
||||
await db.message.updateMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
data: {
|
||||
validUntil: now,
|
||||
validUntilTick: BigInt(world.dateToGameTick(now)),
|
||||
validUntilTick: resolvedGameTick,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -113,40 +122,53 @@ const validateActor = async (options: {
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
}): Promise<Date> => {
|
||||
}): Promise<{ processingGameTick: number }> => {
|
||||
const actor = options.world.getGeneralById(options.generalId);
|
||||
if (!actor || actor.userId !== options.userId) {
|
||||
throw new Error('messageRespond general owner does not match command user.');
|
||||
}
|
||||
if (!options.requestId) return new Date();
|
||||
if (!options.requestId) {
|
||||
throw new Error('messageRespond requires a durable ENGINE input event requestId.');
|
||||
}
|
||||
const event = await options.db.inputEvent.findUnique({
|
||||
where: { requestId: options.requestId },
|
||||
select: { actorUserId: true, target: true, eventType: true, createdAt: true },
|
||||
select: { actorUserId: true, target: true, eventType: true, processingGameTick: true },
|
||||
});
|
||||
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
|
||||
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
|
||||
throw new Error('ENGINE input event actor or type does not match messageRespond.');
|
||||
}
|
||||
return event.createdAt;
|
||||
const processingGameTick = event.processingGameTick;
|
||||
if (processingGameTick === null || !Number.isSafeInteger(Number(processingGameTick))) {
|
||||
throw new Error('messageRespond requires an authoritative processing game tick.');
|
||||
}
|
||||
return { processingGameTick: Number(processingGameTick) };
|
||||
};
|
||||
|
||||
const fetchMessageForUpdate = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld,
|
||||
messageId: number,
|
||||
now: Date
|
||||
currentGameTick: number
|
||||
): Promise<MessageRow | null> => {
|
||||
const currentTick = BigInt(world.dateToGameTick(now));
|
||||
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
|
||||
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
|
||||
FROM message
|
||||
WHERE id = ${messageId}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||
)
|
||||
SELECT
|
||||
envelope.id,
|
||||
envelope.mailbox,
|
||||
envelope.type,
|
||||
envelope.time,
|
||||
envelope.valid_until AS "validUntil",
|
||||
action.action_type AS "actionType",
|
||||
action.status AS "actionStatus",
|
||||
action.created_game_tick AS "createdGameTick",
|
||||
action.expires_game_tick AS "expiresGameTick",
|
||||
envelope.message
|
||||
FROM message AS envelope
|
||||
JOIN message_action AS action ON action.message_id = envelope.id
|
||||
WHERE envelope.id = ${messageId}
|
||||
AND action.status = 'PENDING'
|
||||
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(currentGameTick)})
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
FOR UPDATE OF envelope, action
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
@@ -165,7 +187,7 @@ const respondToScout = async (options: {
|
||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
|
||||
}
|
||||
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) {
|
||||
if (row.actionStatus !== 'PENDING' || row.actionType !== 'scout' || isLegacyTruthy(asRecord(payload.option).used)) {
|
||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
||||
}
|
||||
|
||||
@@ -186,18 +208,17 @@ const respondToScout = async (options: {
|
||||
}
|
||||
|
||||
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
SELECT id
|
||||
FROM message
|
||||
WHERE mailbox = ${payload.src.generalId}
|
||||
AND type = 'private'
|
||||
AND dest = mailbox
|
||||
AND id <> ${row.id}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||
)
|
||||
AND message->'option'->>'action' = 'scout'
|
||||
FOR UPDATE
|
||||
SELECT envelope.id
|
||||
FROM message AS envelope
|
||||
JOIN message_action AS action ON action.message_id = envelope.id
|
||||
WHERE envelope.mailbox = ${payload.src.generalId}
|
||||
AND envelope.type = 'private'
|
||||
AND envelope.dest = envelope.mailbox
|
||||
AND envelope.id <> ${row.id}
|
||||
AND action.status = 'PENDING'
|
||||
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
|
||||
AND action.action_type = 'scout'
|
||||
FOR UPDATE OF envelope, action
|
||||
`);
|
||||
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
|
||||
world.queueMessage({
|
||||
@@ -327,6 +348,7 @@ const respondToRaiseInvader = async (options: {
|
||||
},
|
||||
event
|
||||
);
|
||||
await invalidateMessageIds(db, world, [row.id], world.gameTickToDate(alignment.alignedTick));
|
||||
return { ok: true, action: 'raiseInvader', reason: 'success' };
|
||||
};
|
||||
|
||||
@@ -354,13 +376,14 @@ export const respondToActionableMessage = async (options: {
|
||||
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
||||
}) => Promise<ClockReconciliationResult>;
|
||||
}): Promise<ActionableMessageResponseResult> => {
|
||||
const acceptedAt = await validateActor(options);
|
||||
const now = options.world.getGameNow(acceptedAt);
|
||||
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
|
||||
const accepted = await validateActor(options);
|
||||
const now = options.world.gameTickToDate(accepted.processingGameTick);
|
||||
const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
|
||||
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
||||
const payload = parsePayload(row.message);
|
||||
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
||||
const action = asRecord(payload.option).action;
|
||||
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
|
||||
if (action === 'scout') {
|
||||
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ const lockWorld = async (db: GamePrisma.TransactionClient): Promise<number> => {
|
||||
return rows[0]!.id;
|
||||
};
|
||||
|
||||
const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigint): Promise<void> => {
|
||||
const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigint): Promise<void> => {
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM general ORDER BY id FOR UPDATE`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM auction
|
||||
@@ -155,10 +155,18 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigin
|
||||
ORDER BY id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM message
|
||||
WHERE valid_until_tick IS NOT NULL AND valid_until_tick >= ${cutTick}
|
||||
ORDER BY id FOR UPDATE
|
||||
SELECT bid.id
|
||||
FROM auction_bid AS bid
|
||||
JOIN auction ON auction.id = bid.auction_id
|
||||
WHERE auction.status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
||||
ORDER BY bid.id FOR UPDATE OF bid
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT message_id AS id FROM message_action
|
||||
WHERE status = 'PENDING'
|
||||
ORDER BY message_id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM inheritance_ledger ORDER BY id FOR UPDATE`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
|
||||
`);
|
||||
@@ -175,7 +183,8 @@ const readParticipantSnapshots = async (
|
||||
worldStateId: number,
|
||||
cutTick: bigint
|
||||
): Promise<ParticipantSnapshot[]> => {
|
||||
const [world, generals, auctions, messages, votes, pool, npcTokens, commands] = await Promise.all([
|
||||
const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] =
|
||||
await Promise.all([
|
||||
db.worldState.findUniqueOrThrow({
|
||||
where: { id: worldStateId },
|
||||
select: {
|
||||
@@ -188,17 +197,32 @@ const readParticipantSnapshots = async (
|
||||
}),
|
||||
db.general.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, turnTick: true, recentWarTick: true },
|
||||
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
|
||||
}),
|
||||
db.auction.findMany({
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, status: true, openTick: true, closeTick: true },
|
||||
}),
|
||||
db.message.findMany({
|
||||
where: { validUntilTick: { not: null, gte: cutTick } },
|
||||
db.auctionBid.findMany({
|
||||
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, timeTick: true, validUntilTick: true },
|
||||
select: { id: true, occurredGameTick: true },
|
||||
}),
|
||||
db.messageAction.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
orderBy: { messageId: 'asc' },
|
||||
select: {
|
||||
messageId: true,
|
||||
createdGameTick: true,
|
||||
expiresGameTick: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
},
|
||||
}),
|
||||
db.inheritanceLedger.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, appliedClockRevision: true, appliedDeadlineGeneration: true },
|
||||
}),
|
||||
db.votePoll.findMany({
|
||||
where: { closedAt: null },
|
||||
@@ -219,7 +243,7 @@ const readParticipantSnapshots = async (
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true },
|
||||
}),
|
||||
]);
|
||||
]);
|
||||
const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({
|
||||
key,
|
||||
policy,
|
||||
@@ -246,6 +270,18 @@ const readParticipantSnapshots = async (
|
||||
'KEEP',
|
||||
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick }))
|
||||
),
|
||||
snapshot(
|
||||
'selection-reselection-deadline',
|
||||
'SHIFT',
|
||||
generals.flatMap(({ id, meta: generalMeta }) => {
|
||||
const raw =
|
||||
generalMeta && typeof generalMeta === 'object' && !Array.isArray(generalMeta)
|
||||
? Reflect.get(generalMeta, 'next_change_tick')
|
||||
: null;
|
||||
const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
|
||||
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
|
||||
})
|
||||
),
|
||||
snapshot(
|
||||
'auction-open-occurrence',
|
||||
'KEEP',
|
||||
@@ -256,21 +292,34 @@ const readParticipantSnapshots = async (
|
||||
'SHIFT',
|
||||
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
|
||||
),
|
||||
snapshot('auction-bid-occurrence', 'KEEP', auctionBids),
|
||||
snapshot(
|
||||
'auction-finalizing-recovery',
|
||||
'REBUILD',
|
||||
auctions.map(({ id, status }) => ({ id, status }))
|
||||
),
|
||||
snapshot(
|
||||
'message-occurrence',
|
||||
'message-action-occurrence',
|
||||
'KEEP',
|
||||
messages.map(({ id, timeTick }) => ({ id, timeTick }))
|
||||
messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick }))
|
||||
),
|
||||
snapshot(
|
||||
'message-expiry',
|
||||
'message-action-expiry',
|
||||
'SHIFT',
|
||||
messages.map(({ id, validUntilTick }) => ({ id, validUntilTick }))
|
||||
messages
|
||||
.filter(({ expiresGameTick }) => expiresGameTick !== null && expiresGameTick >= cutTick)
|
||||
.map(({ messageId, expiresGameTick }) => ({ messageId, expiresGameTick }))
|
||||
),
|
||||
snapshot(
|
||||
'message-action-clock-coordinate',
|
||||
'REBUILD',
|
||||
messages.map(({ messageId, clockRevision, deadlineGeneration }) => ({
|
||||
messageId,
|
||||
clockRevision,
|
||||
deadlineGeneration,
|
||||
}))
|
||||
),
|
||||
snapshot('inheritance-effect-coordinate', 'KEEP', inheritanceEffects),
|
||||
snapshot(
|
||||
'vote-start-occurrence',
|
||||
'KEEP',
|
||||
@@ -283,7 +332,7 @@ const readParticipantSnapshots = async (
|
||||
),
|
||||
snapshot('select-pool-reservation', 'SHIFT', pool),
|
||||
snapshot('npc-selection-window', 'SHIFT', npcTokens),
|
||||
snapshot('accepted-command-coordinate', 'KEEP', commands),
|
||||
snapshot('daemon-command-coordinate', 'KEEP', commands),
|
||||
snapshot('movable-json-rule-anchors', 'SHIFT', [
|
||||
{
|
||||
lastTurnTime: Reflect.get(meta, 'lastTurnTime'),
|
||||
@@ -429,15 +478,20 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
|
||||
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
||||
const shift = BigInt(shiftTicks);
|
||||
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
||||
const [general, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
||||
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
||||
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
|
||||
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
||||
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
|
||||
FROM general
|
||||
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
|
||||
`),
|
||||
db.auction.aggregate({
|
||||
_max: { closeTick: true },
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
|
||||
}),
|
||||
db.message.aggregate({
|
||||
_max: { validUntilTick: true },
|
||||
where: { validUntilTick: { not: null, lt: BigInt(MAX_SAFE_GAME_TICK) } },
|
||||
db.messageAction.aggregate({
|
||||
_max: { expiresGameTick: true },
|
||||
where: { status: 'PENDING', expiresGameTick: { not: null } },
|
||||
}),
|
||||
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
|
||||
db.selectPoolEntry.aggregate({
|
||||
@@ -452,8 +506,9 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
|
||||
]);
|
||||
const values: Array<[string, bigint | null]> = [
|
||||
['general.turn_tick', general._max.turnTick],
|
||||
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
|
||||
['auction.close_tick', auction._max.closeTick],
|
||||
['message.valid_until_tick', message._max.validUntilTick],
|
||||
['message_action.expires_game_tick', message._max.expiresGameTick],
|
||||
['vote_poll.end_tick', vote._max.endTick],
|
||||
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
|
||||
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
|
||||
@@ -493,6 +548,32 @@ const applyParticipantShift = async (
|
||||
WHERE turn_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'selection-reselection-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE general
|
||||
SET meta = jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
meta,
|
||||
'{next_change_tick}',
|
||||
to_jsonb((meta->>'next_change_tick')::bigint + ${shiftTicks}),
|
||||
true
|
||||
),
|
||||
'{next_change}',
|
||||
to_jsonb(((meta->>'next_change')::timestamp
|
||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
|
||||
true
|
||||
),
|
||||
'{nextChangeAt}',
|
||||
to_jsonb(((meta->>'nextChangeAt')::timestamp
|
||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
|
||||
true
|
||||
)
|
||||
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
|
||||
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'auction-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
@@ -504,14 +585,37 @@ const applyParticipantShift = async (
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'message-expiry',
|
||||
'message-action-clock-coordinate',
|
||||
(
|
||||
await db.messageAction.updateMany({
|
||||
where: { status: 'PENDING' },
|
||||
data: {
|
||||
clockRevision: targetRevision,
|
||||
deadlineGeneration: targetGeneration,
|
||||
},
|
||||
})
|
||||
).count
|
||||
);
|
||||
affected.set(
|
||||
'message-action-expiry',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE message
|
||||
SET valid_until_tick = valid_until_tick + ${shiftTicks},
|
||||
valid_until = valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE valid_until_tick IS NOT NULL
|
||||
AND valid_until_tick >= ${cutTick}
|
||||
AND valid_until_tick < ${BigInt(MAX_SAFE_GAME_TICK)}
|
||||
WITH shifted AS (
|
||||
UPDATE message_action
|
||||
SET expires_game_tick = expires_game_tick + ${shiftTicks},
|
||||
clock_revision = ${targetRevision},
|
||||
deadline_generation = ${targetGeneration},
|
||||
updated_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE status = 'PENDING'
|
||||
AND expires_game_tick IS NOT NULL
|
||||
AND expires_game_tick >= ${cutTick}
|
||||
RETURNING message_id, expires_game_tick
|
||||
)
|
||||
UPDATE message AS envelope
|
||||
SET valid_until_tick = shifted.expires_game_tick,
|
||||
valid_until = envelope.valid_until
|
||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
FROM shifted
|
||||
WHERE envelope.id = shifted.message_id
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
|
||||
@@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({
|
||||
type: z.literal('auctionFinalize'),
|
||||
auctionId: zFiniteNumber,
|
||||
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
expectedCloseTick: zSafeInteger.optional(),
|
||||
expectedCloseTick: zSafeInteger,
|
||||
});
|
||||
|
||||
const zAuctionOpen = z.object({
|
||||
@@ -60,7 +60,6 @@ const zAuctionBid = z.object({
|
||||
auctionId: zFiniteNumber,
|
||||
generalId: zFiniteNumber,
|
||||
amount: zFiniteNumber,
|
||||
acceptedGameTick: zSafeInteger.optional(),
|
||||
tryExtendCloseDate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -427,7 +426,7 @@ const zSelectPoolReserve = z
|
||||
requestId: z.string().optional(),
|
||||
userId: z.string().min(1),
|
||||
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp),
|
||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
writeReadModelChangeJournal,
|
||||
enqueuePrivateMessageWebPush,
|
||||
enqueueWebPushOutboxEvents,
|
||||
persistMessageEnvelope,
|
||||
type InputJsonValue,
|
||||
type ReadModelJournalWriteResult,
|
||||
type TurnEngineCityUpdateInput,
|
||||
@@ -1862,37 +1863,18 @@ export const createDatabaseTurnHooks = async (
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: async (draft: MessageRecordDraft) => {
|
||||
const toTickOrNull = (date: Date): bigint | null => {
|
||||
try {
|
||||
return BigInt(world.dateToGameTick(date));
|
||||
} catch {
|
||||
// Legacy messages may use year 9999 as an
|
||||
// effectively-unbounded expiry, beyond the
|
||||
// safe JavaScript tick range.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (
|
||||
mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message
|
||||
)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${toTickOrNull(draft.time)},
|
||||
${draft.validUntil},
|
||||
${toTickOrNull(draft.validUntil)},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error('Failed to persist turn message.');
|
||||
}
|
||||
const clock = world.getGameClockState();
|
||||
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
|
||||
const expiresGameTick =
|
||||
typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000
|
||||
? null
|
||||
: BigInt(world.dateToGameTick(draft.validUntil));
|
||||
const id = await persistMessageEnvelope(prisma, draft, {
|
||||
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
deadlineGeneration: BigInt(clock.deadlineGeneration),
|
||||
expiresGameTick,
|
||||
});
|
||||
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
||||
persistedMessageMailboxes.push(draft.mailbox);
|
||||
return id;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||
import { createGatewayPostgresConnector, GatewayPrisma } from '@sammo-ts/infra';
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
|
||||
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
|
||||
@@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async (
|
||||
continue;
|
||||
}
|
||||
const terminal = result.status !== 'PARTIAL';
|
||||
const updated = await prisma.gatewayRuntimeAction.updateMany({
|
||||
where: {
|
||||
id: action.id,
|
||||
status: { in: ['REQUESTED', 'PARTIAL'] },
|
||||
},
|
||||
data: {
|
||||
status: result.status,
|
||||
detail: result.detail ?? null,
|
||||
handler: 'turn-daemon',
|
||||
handledAt: terminal ? new Date() : null,
|
||||
attempts: { increment: 1 },
|
||||
nextAttemptAt: terminal
|
||||
? null
|
||||
: new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))),
|
||||
},
|
||||
});
|
||||
if (terminal && updated.count > 0) {
|
||||
const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6));
|
||||
const updated = await prisma.$queryRaw<Array<{ id: string }>>(GatewayPrisma.sql`
|
||||
UPDATE gateway_runtime_action
|
||||
SET status = ${result.status}::"GatewayRuntimeActionStatus",
|
||||
detail = ${result.detail ?? null},
|
||||
handler = 'turn-daemon',
|
||||
handled_at = CASE
|
||||
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
ELSE NULL
|
||||
END,
|
||||
attempts = attempts + 1,
|
||||
next_attempt_at = CASE
|
||||
WHEN ${terminal} THEN NULL
|
||||
ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
+ ${retryDelayMs} * INTERVAL '1 millisecond'
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${action.id}
|
||||
AND status IN ('REQUESTED'::"GatewayRuntimeActionStatus", 'PARTIAL'::"GatewayRuntimeActionStatus")
|
||||
RETURNING id
|
||||
`);
|
||||
if (terminal && updated.length > 0) {
|
||||
await options.onActionApplied?.(actionRecord, result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
|
||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||
|
||||
@@ -42,7 +44,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
||||
return {
|
||||
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
|
||||
async shouldPause(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const now = performance.now();
|
||||
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
|
||||
return cachedPause;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js';
|
||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||
import type { InMemoryTurnWorld, TurnCalendarContext } from './inMemoryWorld.js';
|
||||
@@ -50,16 +52,16 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
}
|
||||
|
||||
async run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise<TurnRunResult> {
|
||||
const startMs = Date.now();
|
||||
const startMs = performance.now();
|
||||
const deadlineMs = startMs + Math.max(0, budget.budgetMs);
|
||||
const isBudgetExpired = () => Date.now() >= deadlineMs;
|
||||
const isBudgetExpired = () => performance.now() >= deadlineMs;
|
||||
|
||||
if (isWorldUnited(this.world)) {
|
||||
return {
|
||||
lastTurnTime: this.world.getState().lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: Math.max(0, Date.now() - startMs),
|
||||
durationMs: Math.max(0, performance.now() - startMs),
|
||||
partial: false,
|
||||
checkpoint,
|
||||
};
|
||||
@@ -171,7 +173,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
lastTurnTime,
|
||||
processedGenerals,
|
||||
processedTurns,
|
||||
durationMs: Math.max(0, Date.now() - startMs),
|
||||
durationMs: Math.max(0, performance.now() - startMs),
|
||||
partial,
|
||||
checkpoint: nextCheckpoint,
|
||||
};
|
||||
|
||||
@@ -310,7 +310,7 @@ export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
|
||||
return '알수없음';
|
||||
};
|
||||
|
||||
export const executeInheritanceAction = async (options: {
|
||||
const executeInheritanceActionMutation = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
world: InMemoryTurnWorld;
|
||||
command: InheritanceActionCommand;
|
||||
@@ -668,3 +668,59 @@ export const executeInheritanceAction = async (options: {
|
||||
});
|
||||
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost };
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists the WALL_TIME inheritance receipt in the same transaction as the
|
||||
* point debit, game mutation, and input-event completion. The input_event row
|
||||
* is the durable retry/failure record and owns the authoritative GAME clock
|
||||
* coordinate; an immediate effect does not invent a separate applied tick.
|
||||
*/
|
||||
export const executeInheritanceAction = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
world: InMemoryTurnWorld;
|
||||
command: InheritanceActionCommand;
|
||||
gameNow: Date;
|
||||
}): Promise<InheritanceActionResult> => {
|
||||
const result = await executeInheritanceActionMutation(options);
|
||||
if (!result.ok || !options.command.requestId) return result;
|
||||
|
||||
const event = await options.db.inputEvent.findUnique({
|
||||
where: { requestId: options.command.requestId },
|
||||
select: {
|
||||
actorUserId: true,
|
||||
target: true,
|
||||
eventType: true,
|
||||
createdAt: true,
|
||||
processingClockRevision: true,
|
||||
processingDeadlineGeneration: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!event ||
|
||||
event.actorUserId !== options.command.userId ||
|
||||
event.target !== 'ENGINE' ||
|
||||
event.eventType !== 'inheritanceAction' ||
|
||||
event.processingClockRevision === null ||
|
||||
event.processingDeadlineGeneration === null
|
||||
) {
|
||||
throw new Error('Inheritance ledger requires the authoritative ENGINE input-event clock fence.');
|
||||
}
|
||||
const previousPoint = await lockPreviousPoint(options.db, options.command.userId);
|
||||
const cost = previousPoint - result.remainPoint;
|
||||
if (!Number.isFinite(cost) || cost < 0) {
|
||||
throw new Error(`Inheritance ledger calculated an invalid cost: ${cost}.`);
|
||||
}
|
||||
await options.db.inheritanceLedger.create({
|
||||
data: {
|
||||
requestId: options.command.requestId,
|
||||
userId: options.command.userId,
|
||||
action: result.action,
|
||||
cost,
|
||||
status: 'APPLIED',
|
||||
requestedAtWall: event.createdAt,
|
||||
appliedClockRevision: event.processingClockRevision,
|
||||
appliedDeadlineGeneration: event.processingDeadlineGeneration,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
GamePrisma,
|
||||
@@ -84,7 +84,9 @@ export interface NpcPossessionSelectionObserver {
|
||||
interface NpcSelectionTokenRow {
|
||||
ownerUserId: string;
|
||||
validUntil: Date;
|
||||
validUntilTick: bigint | null;
|
||||
pickMoreFrom: Date;
|
||||
pickMoreFromTick: bigint | null;
|
||||
pickResult: unknown;
|
||||
nonce: number;
|
||||
}
|
||||
@@ -105,8 +107,8 @@ const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTi
|
||||
export const buildNpcSelectionTokenSeed = (
|
||||
hiddenSeed: string | number,
|
||||
ownerIdentity: string | number,
|
||||
acceptedGameTick: number
|
||||
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick);
|
||||
createdGameTick: number
|
||||
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, createdGameTick);
|
||||
|
||||
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
@@ -159,15 +161,22 @@ const parsePickResult = (value: unknown): Record<string, NpcPossessionCandidate>
|
||||
};
|
||||
|
||||
const toReservation = (
|
||||
token: Pick<NpcSelectionTokenRow, 'validUntil' | 'pickMoreFrom' | 'pickResult' | 'nonce'>,
|
||||
now: Date
|
||||
token: Pick<
|
||||
NpcSelectionTokenRow,
|
||||
'validUntil' | 'validUntilTick' | 'pickMoreFrom' | 'pickMoreFromTick' | 'pickResult' | 'nonce'
|
||||
>,
|
||||
currentGameTick: number,
|
||||
ticksPerSecond: number
|
||||
): NpcPossessionReservation => {
|
||||
if (token.validUntilTick === null || token.pickMoreFromTick === null) {
|
||||
return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보의 GAME_TIME 기한이 없습니다.');
|
||||
}
|
||||
const pickResult = parsePickResult(token.pickResult);
|
||||
return {
|
||||
tokenNonce: token.nonce,
|
||||
validUntil: token.validUntil.toISOString(),
|
||||
pickMoreFrom: token.pickMoreFrom.toISOString(),
|
||||
pickMoreSeconds: Math.max(0, Math.ceil((token.pickMoreFrom.getTime() - now.getTime()) / 1000)),
|
||||
pickMoreSeconds: Math.max(0, Math.ceil((Number(token.pickMoreFromTick) - currentGameTick) / ticksPerSecond)),
|
||||
candidates: Object.values(pickResult).sort(
|
||||
(left, right) =>
|
||||
left.stats.leadership +
|
||||
@@ -289,16 +298,18 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
refresh?: boolean;
|
||||
keepIds?: number[];
|
||||
now?: Date;
|
||||
acceptedGameTick: number;
|
||||
createdGameTick: number;
|
||||
selectionObserver?: NpcPossessionSelectionObserver;
|
||||
}): Promise<NpcPossessionReservation> => {
|
||||
const { db, worldState, userId } = options;
|
||||
requireNpcPossessionWorld(worldState);
|
||||
const now = truncateToSeconds(options.now ?? new Date());
|
||||
if (!Number.isSafeInteger(options.acceptedGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.');
|
||||
if (!Number.isSafeInteger(options.createdGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 생성 tick이 올바르지 않습니다.');
|
||||
}
|
||||
await lockNpcPossession(db, userId);
|
||||
const turnTermMinutes = resolveTurnTermMinutes(worldState);
|
||||
const ticksPerSecond = GAME_TICKS_PER_TURN / (turnTermMinutes * 60);
|
||||
|
||||
if (await db.general.findFirst({ where: { userId }, select: { id: true } })) {
|
||||
fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다');
|
||||
@@ -324,14 +335,20 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
if (options.refresh) {
|
||||
fail('CONFLICT', 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.');
|
||||
}
|
||||
return toReservation(inFlightToken, now);
|
||||
return toReservation(inFlightToken, options.createdGameTick, ticksPerSecond);
|
||||
}
|
||||
if (existing && existing.validUntil.getTime() < now.getTime()) {
|
||||
if (
|
||||
existing &&
|
||||
(existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick)
|
||||
) {
|
||||
await db.npcSelectionToken.deleteMany({
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
nonce: existing.nonce,
|
||||
validUntil: { lt: now },
|
||||
OR: [
|
||||
{ validUntilTick: null },
|
||||
{ validUntilTick: { lt: BigInt(options.createdGameTick) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
existing = null;
|
||||
@@ -339,7 +356,7 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
|
||||
const kept: Record<string, NpcPossessionCandidate> = {};
|
||||
if (existing && options.refresh) {
|
||||
if (now.getTime() < existing.pickMoreFrom.getTime()) {
|
||||
if (existing.pickMoreFromTick === null || options.createdGameTick < Number(existing.pickMoreFromTick)) {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다');
|
||||
}
|
||||
const oldPick = parsePickResult(existing.pickResult);
|
||||
@@ -352,16 +369,16 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
}
|
||||
// Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다.
|
||||
if (Object.keys(kept).length === Object.keys(oldPick).length) {
|
||||
return toReservation(existing, now);
|
||||
return toReservation(existing, options.createdGameTick, ticksPerSecond);
|
||||
}
|
||||
} else if (existing) {
|
||||
return toReservation(existing, now);
|
||||
return toReservation(existing, options.createdGameTick, ticksPerSecond);
|
||||
}
|
||||
|
||||
const reservedRows = await db.npcSelectionToken.findMany({
|
||||
where: {
|
||||
ownerUserId: { not: userId },
|
||||
validUntil: { gte: now },
|
||||
validUntilTick: { gte: BigInt(options.createdGameTick) },
|
||||
},
|
||||
select: { pickResult: true },
|
||||
});
|
||||
@@ -397,16 +414,19 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
|
||||
);
|
||||
const selectionRng = new LiteHashDRBG(
|
||||
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick)
|
||||
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.createdGameTick)
|
||||
);
|
||||
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 validSeconds = Math.max(VALID_SECONDS, turnTermMinutes * 40);
|
||||
const pickMoreSeconds = Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8));
|
||||
const validUntilTick = options.createdGameTick + Math.round(validSeconds * ticksPerSecond);
|
||||
const pickMoreFromTick = options.createdGameTick + Math.round(pickMoreSeconds * ticksPerSecond);
|
||||
const validUntil = new Date(now.getTime() + validSeconds * 1000);
|
||||
const refreshedPickMoreFrom = new Date(
|
||||
now.getTime() + Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8)) * 1000
|
||||
now.getTime() + pickMoreSeconds * 1000
|
||||
);
|
||||
const nonce = randomInt(0, 0x10000000);
|
||||
|
||||
@@ -415,7 +435,9 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
where: { ownerUserId: userId, nonce: existing.nonce },
|
||||
data: {
|
||||
validUntil,
|
||||
validUntilTick: BigInt(validUntilTick),
|
||||
pickMoreFrom: refreshedPickMoreFrom,
|
||||
pickMoreFromTick: BigInt(pickMoreFromTick),
|
||||
pickResult: pickResult as GamePrisma.InputJsonValue,
|
||||
nonce,
|
||||
},
|
||||
@@ -423,7 +445,18 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
if (updated.count === 0) {
|
||||
fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요');
|
||||
}
|
||||
return toReservation({ validUntil, pickMoreFrom: refreshedPickMoreFrom, pickResult, nonce }, now);
|
||||
return toReservation(
|
||||
{
|
||||
validUntil,
|
||||
validUntilTick: BigInt(validUntilTick),
|
||||
pickMoreFrom: refreshedPickMoreFrom,
|
||||
pickMoreFromTick: BigInt(pickMoreFromTick),
|
||||
pickResult,
|
||||
nonce,
|
||||
},
|
||||
options.createdGameTick,
|
||||
ticksPerSecond
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -431,7 +464,9 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
data: {
|
||||
ownerUserId: userId,
|
||||
validUntil,
|
||||
validUntilTick: BigInt(validUntilTick),
|
||||
pickMoreFrom: FIRST_PICK_MORE_FROM,
|
||||
pickMoreFromTick: BigInt(options.createdGameTick),
|
||||
pickResult: pickResult as GamePrisma.InputJsonValue,
|
||||
nonce,
|
||||
},
|
||||
@@ -442,7 +477,18 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return toReservation({ validUntil, pickMoreFrom: FIRST_PICK_MORE_FROM, pickResult, nonce }, now);
|
||||
return toReservation(
|
||||
{
|
||||
validUntil,
|
||||
validUntilTick: BigInt(validUntilTick),
|
||||
pickMoreFrom: FIRST_PICK_MORE_FROM,
|
||||
pickMoreFromTick: BigInt(options.createdGameTick),
|
||||
pickResult,
|
||||
nonce,
|
||||
},
|
||||
options.createdGameTick,
|
||||
ticksPerSecond
|
||||
);
|
||||
};
|
||||
|
||||
export const possessNpcGeneral = async (options: {
|
||||
@@ -455,11 +501,13 @@ export const possessNpcGeneral = async (options: {
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
generalId: number;
|
||||
tokenNonce: number;
|
||||
acceptedAt: Date;
|
||||
requestedAtWall: Date;
|
||||
processingGameTick: number;
|
||||
}): Promise<{ ok: true; generalId: number }> => {
|
||||
const { db, world, worldState, userId, generalId, acceptedAt } = options;
|
||||
// queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다.
|
||||
const tokenAcceptedAt = truncateToSeconds(acceptedAt);
|
||||
const { db, world, worldState, userId, generalId, requestedAtWall } = options;
|
||||
if (!Number.isSafeInteger(options.processingGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 처리 tick이 올바르지 않습니다.');
|
||||
}
|
||||
requireNpcPossessionWorld(worldState);
|
||||
await lockNpcPossession(db, userId);
|
||||
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
|
||||
@@ -475,7 +523,7 @@ export const possessNpcGeneral = async (options: {
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
nonce: options.tokenNonce,
|
||||
validUntil: { gte: tokenAcceptedAt },
|
||||
validUntilTick: { gte: BigInt(options.processingGameTick) },
|
||||
},
|
||||
})) as NpcSelectionTokenRow | null;
|
||||
if (!token) {
|
||||
@@ -501,7 +549,7 @@ export const possessNpcGeneral = async (options: {
|
||||
return fail('NOT_FOUND', '장수 등록에 실패했습니다.');
|
||||
}
|
||||
|
||||
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, acceptedAt);
|
||||
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, requestedAtWall);
|
||||
world.updateGeneral(generalId, {
|
||||
userId,
|
||||
npcState: 1,
|
||||
@@ -522,7 +570,7 @@ export const possessNpcGeneral = async (options: {
|
||||
where: { generalId },
|
||||
update: {
|
||||
userId,
|
||||
lastRefresh: acceptedAt,
|
||||
lastRefresh: requestedAtWall,
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
@@ -531,7 +579,7 @@ export const possessNpcGeneral = async (options: {
|
||||
create: {
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: acceptedAt,
|
||||
lastRefresh: requestedAtWall,
|
||||
},
|
||||
});
|
||||
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId } });
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
GamePrisma,
|
||||
type InputJsonValue,
|
||||
type TurnEngineDatabaseClient,
|
||||
} from '@sammo-ts/infra';
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@@ -75,6 +80,7 @@ const buildTurnListFromRows = (
|
||||
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
|
||||
|
||||
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & {
|
||||
$queryRaw?<T>(query: GamePrisma.Sql): Promise<T>;
|
||||
generalTurnRevision?: Pick<
|
||||
NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>,
|
||||
'findUnique' | 'createMany' | 'updateMany'
|
||||
@@ -313,8 +319,17 @@ export class InMemoryReservedTurnStore {
|
||||
}
|
||||
}
|
||||
|
||||
private getLeaseExpiresAt(): Date {
|
||||
return new Date(Date.now() + this.leaseDurationMs);
|
||||
private getLeaseExpiresAt(nowWall: Date): Date {
|
||||
return new Date(nowWall.getTime() + this.leaseDurationMs);
|
||||
}
|
||||
|
||||
private async readDatabaseWallTime(prisma: ReservedTurnDatabaseClient = this.prisma): Promise<Date> {
|
||||
if (!prisma.$queryRaw) return new Date();
|
||||
const rows = await prisma.$queryRaw<Array<{ nowWall: Date }>>(GamePrisma.sql`
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "nowWall"
|
||||
`);
|
||||
if (!rows[0]) throw new Error('PostgreSQL did not return its authoritative wall clock.');
|
||||
return rows[0].nowWall;
|
||||
}
|
||||
|
||||
private async acquireGeneralLease(generalId: number): Promise<boolean> {
|
||||
@@ -322,7 +337,7 @@ export class InMemoryReservedTurnStore {
|
||||
if (!revisionStore) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date();
|
||||
const now = await this.readDatabaseWallTime();
|
||||
const previous = (await revisionStore.findUnique({ where: { generalId } })) as {
|
||||
leaseOwner: string | null;
|
||||
leaseExpiresAt: Date | null;
|
||||
@@ -331,7 +346,7 @@ export class InMemoryReservedTurnStore {
|
||||
previous?.leaseOwner === this.leaseOwner &&
|
||||
previous.leaseExpiresAt !== null &&
|
||||
previous.leaseExpiresAt.getTime() > now.getTime();
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where: {
|
||||
generalId,
|
||||
@@ -372,7 +387,7 @@ export class InMemoryReservedTurnStore {
|
||||
if (!revisionStore) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date();
|
||||
const now = await this.readDatabaseWallTime();
|
||||
const previous = (await revisionStore.findUnique({
|
||||
where: { nationId_officerLevel: { nationId, officerLevel } },
|
||||
})) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null;
|
||||
@@ -380,7 +395,7 @@ export class InMemoryReservedTurnStore {
|
||||
previous?.leaseOwner === this.leaseOwner &&
|
||||
previous.leaseExpiresAt !== null &&
|
||||
previous.leaseExpiresAt.getTime() > now.getTime();
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where: {
|
||||
nationId,
|
||||
@@ -651,12 +666,13 @@ export class InMemoryReservedTurnStore {
|
||||
if (!revisionStore) {
|
||||
return false;
|
||||
}
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
||||
const now = await this.readDatabaseWallTime(prisma);
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||
const where = this.leasedGeneralIds.has(generalId)
|
||||
? { generalId, leaseOwner: this.leaseOwner }
|
||||
: {
|
||||
generalId,
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
||||
};
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where,
|
||||
@@ -721,13 +737,14 @@ export class InMemoryReservedTurnStore {
|
||||
if (!revisionStore) {
|
||||
return false;
|
||||
}
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
||||
const now = await this.readDatabaseWallTime(prisma);
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||
const where = this.leasedNationKeys.has(key)
|
||||
? { nationId, officerLevel, leaseOwner: this.leaseOwner }
|
||||
: {
|
||||
nationId,
|
||||
officerLevel,
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
||||
};
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
@@ -89,8 +90,8 @@ const shiftTournamentClock = async (
|
||||
};
|
||||
const lockKey = `${stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + 2_000;
|
||||
while (performance.now() < deadline) {
|
||||
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||
if (acquired) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
@@ -158,8 +159,8 @@ const reprojectTournamentClock = async (
|
||||
};
|
||||
const lockKey = `${stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + 2_000;
|
||||
while (performance.now() < deadline) {
|
||||
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||
if (acquired) {
|
||||
try {
|
||||
|
||||
@@ -268,13 +268,10 @@ const toReservationDto = (
|
||||
world: InMemoryTurnWorld
|
||||
): Promise<SelectPoolReservationDto> => {
|
||||
const first = rows[0];
|
||||
if (!first || (first.reservedUntilTick === null && first.reservedUntil === null)) {
|
||||
if (!first || first.reservedUntilTick === null) {
|
||||
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
|
||||
}
|
||||
const expiresAt =
|
||||
first.reservedUntilTick === null
|
||||
? first.reservedUntil!
|
||||
: world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
|
||||
const expiresAt = world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
|
||||
const poolName = resolvePoolName(worldState);
|
||||
if (!poolName || !SUPPORTED_POOLS.has(poolName)) {
|
||||
throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
|
||||
@@ -355,6 +352,23 @@ const readNextChangeAt = (generalMeta: unknown): Date | null => {
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const readNextChangeTick = (generalMeta: unknown): number | null => {
|
||||
const raw = asRecord(generalMeta).next_change_tick;
|
||||
const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN;
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const assertReselectionCooldown = (generalMeta: unknown, processingGameTick: number): void => {
|
||||
const projection = readNextChangeAt(generalMeta);
|
||||
const deadline = readNextChangeTick(generalMeta);
|
||||
if (projection && deadline === null) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 재선택 cooldown의 GAME_TIME authority가 없습니다.');
|
||||
}
|
||||
if (deadline !== null && deadline > processingGameTick) {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
||||
}
|
||||
};
|
||||
|
||||
const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
const value = meta.hiddenSeed ?? meta.seed;
|
||||
@@ -371,18 +385,8 @@ const toSafeReservationTick = (value: bigint | number, uniqueName: string): numb
|
||||
return tick;
|
||||
};
|
||||
|
||||
const resolveAcceptedGameTick = (world: InMemoryTurnWorld, now: Date): number => {
|
||||
const tick = world.dateToGameTick(now);
|
||||
if (!Number.isSafeInteger(tick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
return tick;
|
||||
};
|
||||
|
||||
const isReservationActive = (row: SelectPoolRow, now: Date, nowTick: number): boolean =>
|
||||
row.reservedUntilTick !== null
|
||||
? toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick
|
||||
: row.reservedUntil !== null && row.reservedUntil.getTime() >= now.getTime();
|
||||
const isReservationActive = (row: SelectPoolRow, nowTick: number): boolean =>
|
||||
row.reservedUntilTick !== null && toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick;
|
||||
|
||||
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
|
||||
@@ -392,7 +396,6 @@ const requireSelectionToken = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
uniqueName: string,
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): Promise<SelectPoolRow> => {
|
||||
const token = await db.selectPoolEntry.findFirst({
|
||||
@@ -400,10 +403,7 @@ const requireSelectionToken = async (
|
||||
ownerUserId: userId,
|
||||
uniqueName,
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
||||
],
|
||||
reservedUntilTick: { gte: BigInt(nowTick) },
|
||||
},
|
||||
});
|
||||
if (!token) {
|
||||
@@ -438,18 +438,13 @@ export const reserveSelectionPool = async (options: {
|
||||
worldState: WorldStateRow;
|
||||
userId: string;
|
||||
now?: Date;
|
||||
acceptedGameTick?: number;
|
||||
processingGameTick?: number;
|
||||
processingGameTick: number;
|
||||
seedOwnerIdentity?: string | number;
|
||||
}): Promise<SelectPoolReservationDto> => {
|
||||
const { db, world, worldState, userId } = options;
|
||||
requirePoolWorld(worldState);
|
||||
const now = options.now ?? new Date();
|
||||
const acceptedGameTick = options.acceptedGameTick ?? resolveAcceptedGameTick(world, now);
|
||||
const processingGameTick = options.processingGameTick ?? acceptedGameTick;
|
||||
if (!Number.isSafeInteger(acceptedGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
const processingGameTick = options.processingGameTick;
|
||||
if (!Number.isSafeInteger(processingGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
@@ -459,14 +454,11 @@ export const reserveSelectionPool = async (options: {
|
||||
where: { userId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
const nextChangeAt = general ? readNextChangeAt(general.meta) : null;
|
||||
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
||||
}
|
||||
if (general) assertReselectionCooldown(general.meta, processingGameTick);
|
||||
|
||||
let currentRows = await synchronizeSelectionPoolWorld(db, world);
|
||||
const existing = currentRows.filter(
|
||||
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, now, processingGameTick)
|
||||
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, processingGameTick)
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return toReservationDto(existing, Boolean(general), worldState, world);
|
||||
@@ -477,7 +469,7 @@ export const reserveSelectionPool = async (options: {
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { lt: BigInt(processingGameTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { lt: now } },
|
||||
{ reservedUntilTick: null, reservedUntil: { not: null } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
@@ -504,7 +496,7 @@ export const reserveSelectionPool = async (options: {
|
||||
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, acceptedGameTick)
|
||||
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, processingGameTick)
|
||||
)
|
||||
);
|
||||
const poolName = resolvePoolName(worldState)!;
|
||||
@@ -578,7 +570,6 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor
|
||||
const clearUnusedReservations = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): Promise<void> => {
|
||||
await db.selectPoolEntry.updateMany({
|
||||
@@ -587,7 +578,7 @@ const clearUnusedReservations = async (
|
||||
OR: [
|
||||
{ ownerUserId: userId },
|
||||
{ reservedUntilTick: { lt: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { lt: now } },
|
||||
{ reservedUntilTick: null, reservedUntil: { not: null } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
@@ -716,6 +707,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
now?: Date;
|
||||
turnScheduleAt?: Date;
|
||||
operationalAcceptedAt: Date;
|
||||
processingGameTick: number;
|
||||
seedOwnerIdentity?: string | number;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
@@ -724,7 +716,10 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
||||
requirePoolWorld(worldState);
|
||||
const now = options.now ?? new Date();
|
||||
const nowTick = resolveAcceptedGameTick(world, now);
|
||||
const nowTick = options.processingGameTick;
|
||||
if (!Number.isSafeInteger(nowTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 생성 처리 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
await lockSelectionUser(db, userId);
|
||||
await lockSelectionMutationTables(db);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
@@ -735,7 +730,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
) {
|
||||
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.');
|
||||
}
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
|
||||
const info = parseCandidate(token);
|
||||
const poolName = resolvePoolName(worldState)!;
|
||||
const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL;
|
||||
@@ -777,9 +772,8 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
|
||||
const age = 20;
|
||||
const specialityAges = resolveSpecialityAges(worldState, age);
|
||||
const nextChangeAt = new Date(
|
||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||
);
|
||||
const nextChangeTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
|
||||
const nextChangeAt = world.gameTickToDate(nextChangeTick);
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
|
||||
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
|
||||
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
|
||||
@@ -813,6 +807,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
dex5: isCentennial ? 0 : info.dex[4],
|
||||
next_change: nextChangeAt.toISOString(),
|
||||
nextChangeAt: nextChangeAt.toISOString(),
|
||||
next_change_tick: nextChangeTick,
|
||||
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||
...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}),
|
||||
npc_org: 0,
|
||||
@@ -905,10 +900,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
id: token.id,
|
||||
ownerUserId: userId,
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
||||
],
|
||||
reservedUntilTick: { gte: BigInt(nowTick) },
|
||||
},
|
||||
data: {
|
||||
generalId,
|
||||
@@ -925,7 +917,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
update: { userId, lastRefresh: options.operationalAcceptedAt },
|
||||
create: { generalId, userId, lastRefresh: options.operationalAcceptedAt },
|
||||
});
|
||||
await clearUnusedReservations(db, userId, now, nowTick);
|
||||
await clearUnusedReservations(db, userId, nowTick);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
|
||||
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
||||
@@ -949,11 +941,15 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
ownerDisplayName: string;
|
||||
uniqueName: string;
|
||||
now?: Date;
|
||||
processingGameTick: number;
|
||||
}): Promise<{ ok: true; generalId: number }> => {
|
||||
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
||||
requirePoolWorld(worldState);
|
||||
const now = options.now ?? new Date();
|
||||
const nowTick = resolveAcceptedGameTick(world, now);
|
||||
const nowTick = options.processingGameTick;
|
||||
if (!Number.isSafeInteger(nowTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 재선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
await lockSelectionUser(db, userId);
|
||||
await lockSelectionMutationTables(db);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
@@ -968,11 +964,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
if (persistedGeneral.id !== general.id) {
|
||||
fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.');
|
||||
}
|
||||
const nextChangeAt = readNextChangeAt(general.meta);
|
||||
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
||||
}
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
|
||||
assertReselectionCooldown(general.meta, nowTick);
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
|
||||
const info = parseCandidate(token);
|
||||
const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL;
|
||||
|
||||
@@ -982,10 +975,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
id: token.id,
|
||||
ownerUserId: userId,
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
||||
],
|
||||
reservedUntilTick: { gte: BigInt(nowTick) },
|
||||
},
|
||||
data: {
|
||||
generalId: provisionalGeneralId,
|
||||
@@ -1014,9 +1004,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
|
||||
}
|
||||
|
||||
const cooldown = new Date(
|
||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||
);
|
||||
const cooldownTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
|
||||
const cooldown = world.gameTickToDate(cooldownTick);
|
||||
const centennialBaseGeneral = isCentennial
|
||||
? {
|
||||
...general,
|
||||
@@ -1046,6 +1035,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
: {}),
|
||||
next_change: cooldown.toISOString(),
|
||||
nextChangeAt: cooldown.toISOString(),
|
||||
next_change_tick: cooldownTick,
|
||||
...buildScenarioGeneralPoolClaimMeta(
|
||||
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
|
||||
now
|
||||
@@ -1074,7 +1064,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
if (!updated) {
|
||||
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
|
||||
}
|
||||
await clearUnusedReservations(db, userId, now, nowTick);
|
||||
await clearUnusedReservations(db, userId, nowTick);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
|
||||
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
|
||||
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
enqueuePrivateMessageWebPush,
|
||||
persistMessageEnvelope,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
|
||||
import {
|
||||
@@ -119,22 +123,18 @@ interface HighestUnificationBidRow {
|
||||
meta: unknown;
|
||||
}
|
||||
|
||||
const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: MessageRecordDraft): Promise<number> => {
|
||||
const rows = await transaction.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${draft.validUntil},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
|
||||
const insertMessage = async (
|
||||
transaction: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld,
|
||||
draft: MessageRecordDraft
|
||||
): Promise<number> => {
|
||||
const clock = world.getGameClockState();
|
||||
const id = await persistMessageEnvelope(transaction, draft, {
|
||||
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
deadlineGeneration: BigInt(clock.deadlineGeneration),
|
||||
expiresGameTick: null,
|
||||
});
|
||||
await enqueuePrivateMessageWebPush(transaction, draft, id);
|
||||
return id;
|
||||
};
|
||||
@@ -252,7 +252,7 @@ const cancelPendingUniqueAuctions = async (
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: async (draft) => {
|
||||
const messageId = await insertMessage(transaction, draft);
|
||||
const messageId = await insertMessage(transaction, world, draft);
|
||||
messageMailboxes.push(draft.mailbox);
|
||||
return messageId;
|
||||
},
|
||||
|
||||
@@ -278,18 +278,12 @@ const resolveSelectionCommandAcceptedAt = async (
|
||||
world: InMemoryTurnWorld,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }>
|
||||
): Promise<Date> => {
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
await resolveCommandAcceptedAt(db, command);
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) {
|
||||
return world.gameTickToDate(processingGameTick);
|
||||
}
|
||||
if (command.acceptedGameTick !== undefined) {
|
||||
return world.gameTickToDate(command.acceptedGameTick);
|
||||
}
|
||||
if (command.acceptedGameAt !== undefined) {
|
||||
return new Date(command.acceptedGameAt);
|
||||
}
|
||||
return world.getGameNow(operationalAcceptedAt);
|
||||
throw new Error(`${command.type} requires an authoritative daemon processing game tick.`);
|
||||
};
|
||||
|
||||
const resolveOperationalAcceptedAt = async (
|
||||
@@ -417,12 +411,9 @@ async function handleNpcPossessGeneral(
|
||||
}
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
const acceptedAt =
|
||||
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)
|
||||
? ctx.world.gameTickToDate(processingGameTick)
|
||||
: command.acceptedGameAt
|
||||
? new Date(command.acceptedGameAt)
|
||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('npcPossessGeneral requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
try {
|
||||
return {
|
||||
type: 'npcPossessGeneral',
|
||||
@@ -436,7 +427,8 @@ async function handleNpcPossessGeneral(
|
||||
...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}),
|
||||
generalId: command.generalId,
|
||||
tokenNonce: command.tokenNonce,
|
||||
acceptedAt,
|
||||
requestedAtWall: operationalAcceptedAt,
|
||||
processingGameTick,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -465,15 +457,11 @@ async function handleSelectPoolCreate(
|
||||
}
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
const acceptedAt =
|
||||
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)
|
||||
? ctx.world.gameTickToDate(processingGameTick)
|
||||
: command.acceptedGameTick !== undefined
|
||||
? ctx.world.gameTickToDate(command.acceptedGameTick)
|
||||
: command.acceptedGameAt !== undefined
|
||||
? new Date(command.acceptedGameAt)
|
||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
||||
const turnScheduleAt = acceptedAt;
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolCreate',
|
||||
@@ -492,6 +480,7 @@ async function handleSelectPoolCreate(
|
||||
now: acceptedAt,
|
||||
turnScheduleAt,
|
||||
operationalAcceptedAt,
|
||||
processingGameTick,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -530,10 +519,7 @@ async function handleSelectPoolReserve(
|
||||
userId: command.userId,
|
||||
seedOwnerIdentity: command.seedOwnerIdentity,
|
||||
now: acceptedAt,
|
||||
...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }),
|
||||
...(typeof Reflect.get(command, 'processingGameTick') === 'number'
|
||||
? { processingGameTick: Reflect.get(command, 'processingGameTick') as number }
|
||||
: {}),
|
||||
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -572,6 +558,7 @@ async function handleSelectPoolReselect(
|
||||
ownerDisplayName: command.ownerDisplayName,
|
||||
uniqueName: command.uniqueName,
|
||||
now: acceptedAt,
|
||||
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -2745,17 +2732,16 @@ type VotePollValidationRow = {
|
||||
|
||||
export const hasVotePollDeadlinePassed = (
|
||||
poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>,
|
||||
acceptedGameAt: Date,
|
||||
acceptedGameTick: number
|
||||
currentGameTick: number
|
||||
): boolean => {
|
||||
const endTick =
|
||||
poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick);
|
||||
return (
|
||||
poll.closedAt !== null ||
|
||||
(endTick !== null
|
||||
? endTick < BigInt(acceptedGameTick)
|
||||
: Boolean(poll.endAt && poll.endAt.getTime() < acceptedGameAt.getTime()))
|
||||
);
|
||||
if (poll.closedAt !== null) return true;
|
||||
// No projection and no tick means an intentionally unbounded poll. A
|
||||
// projection without its authoritative tick is a broken GAME deadline and
|
||||
// therefore fails closed.
|
||||
if (endTick === null) return poll.endAt !== null;
|
||||
return endTick < BigInt(currentGameTick);
|
||||
};
|
||||
|
||||
const parseVoteOptionCount = (value: unknown): number => {
|
||||
@@ -2790,17 +2776,11 @@ const validateVoteSelectionInTransaction = async (
|
||||
const poll = rows[0];
|
||||
if (!poll) return '설문조사가 없습니다.';
|
||||
|
||||
const processingNow = ctx.world.getGameNow(new Date());
|
||||
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
|
||||
const acceptedGameTick =
|
||||
typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick)
|
||||
? convertedProcessingTick
|
||||
: (command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow));
|
||||
const acceptedGameAt =
|
||||
command.acceptedGameTick === undefined && convertedProcessingTick === undefined
|
||||
? processingNow
|
||||
: ctx.world.gameTickToDate(acceptedGameTick);
|
||||
if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) {
|
||||
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
|
||||
throw new Error('voteReward requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
if (hasVotePollDeadlinePassed(poll, convertedProcessingTick)) {
|
||||
return '설문조사가 종료되었습니다.';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user