시간 도메인과 정지 중 메시지·베팅 경계 정리
This commit is contained in:
@@ -87,6 +87,7 @@ const destination = {
|
||||
color: '#ffffff',
|
||||
icon: '',
|
||||
};
|
||||
const requestId = 'actionable-message-request';
|
||||
|
||||
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
|
||||
id: 29,
|
||||
@@ -94,6 +95,10 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
|
||||
type: 'private',
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
actionType: action,
|
||||
actionStatus: 'PENDING',
|
||||
createdGameTick: 0n,
|
||||
expiresGameTick: null,
|
||||
message: {
|
||||
src: source,
|
||||
dest: destination,
|
||||
@@ -106,10 +111,25 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
|
||||
const buildDb = (rows: unknown[][]) => {
|
||||
const queryRaw = vi.fn(async () => rows.shift() ?? []);
|
||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const actionUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
return {
|
||||
db: { $queryRaw: queryRaw, message: { updateMany } } as unknown as GamePrisma.TransactionClient,
|
||||
db: {
|
||||
$queryRaw: queryRaw,
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
actorUserId: actor.userId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'messageRespond',
|
||||
createdAt: new Date('2026-09-03T00:00:00.000Z'),
|
||||
processingGameTick: 0n,
|
||||
})),
|
||||
},
|
||||
message: { updateMany },
|
||||
messageAction: { updateMany: actionUpdateMany },
|
||||
} as unknown as GamePrisma.TransactionClient,
|
||||
queryRaw,
|
||||
updateMany,
|
||||
actionUpdateMany,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -118,6 +138,22 @@ const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({
|
||||
});
|
||||
|
||||
describe('actionable message response', () => {
|
||||
it('rejects a response without the authoritative durable command boundary', async () => {
|
||||
const world = buildWorld();
|
||||
const { db } = buildDb([[buildRow('scout')]]);
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: 29,
|
||||
response: true,
|
||||
})
|
||||
).rejects.toThrow('durable ENGINE input event requestId');
|
||||
});
|
||||
|
||||
it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout');
|
||||
@@ -128,6 +164,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -162,6 +199,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(false),
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -173,14 +211,8 @@ describe('actionable message response', () => {
|
||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => {
|
||||
for (const row of [
|
||||
buildRow('scout', { option: { action: 'scout', used: 1 } }),
|
||||
{
|
||||
...buildRow('scout'),
|
||||
validUntil: new Date('0199-12-31T23:59:59.000Z'),
|
||||
},
|
||||
]) {
|
||||
it('treats a legacy truthy used value as an invalid scout letter', async () => {
|
||||
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
|
||||
const world = buildWorld();
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
const executor = buildExecutor();
|
||||
@@ -190,6 +222,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -201,6 +234,24 @@ describe('actionable message response', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an expired GAME_TIME action row as absent', async () => {
|
||||
const world = buildWorld();
|
||||
const { db } = buildDb([[]]);
|
||||
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: 29,
|
||||
response: true,
|
||||
})
|
||||
).resolves.toEqual({ ok: false, reason: '존재하지 않는 메시지입니다.' });
|
||||
});
|
||||
|
||||
it("keeps PHP's special string-zero used value false", async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
|
||||
@@ -212,6 +263,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -233,6 +285,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -252,6 +305,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -271,6 +325,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
|
||||
@@ -107,6 +107,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'],
|
||||
});
|
||||
const amount = finishImmediately ? 500 : 200;
|
||||
const requestedAtWall = new Date('2026-08-23T00:00:00.000Z');
|
||||
const result = await auctionBidder.bid(
|
||||
{
|
||||
type: 'auctionBid',
|
||||
@@ -114,8 +115,9 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
auctionId: 31,
|
||||
generalId: general.id,
|
||||
amount,
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
processingGameTick: 100,
|
||||
requestedAtWall,
|
||||
} as any,
|
||||
commandDb as any
|
||||
);
|
||||
await auctionBidder.close();
|
||||
@@ -125,7 +127,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
);
|
||||
const insert = statements.find((query) => query.strings.join(' ').includes('INSERT INTO auction_bid'));
|
||||
const update = statements.find((query) => query.strings.join(' ').includes('UPDATE auction'));
|
||||
return { acceptedAt, processingAt, result, insert, update };
|
||||
return { acceptedAt, processingAt, requestedAtWall, result, insert, update };
|
||||
};
|
||||
|
||||
describe('resource auction Ref compatibility', () => {
|
||||
@@ -135,21 +137,19 @@ describe('resource auction Ref compatibility', () => {
|
||||
|
||||
expect(hasAuctionClosePassed(auction, closeAt, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionClosePassed(auction, new Date(closeAt.getTime() + 1), 72_000_001)).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(false);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, new Date(closeAt.getTime() + 1), null)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the durable API acceptance tick when queue processing crosses the close boundary', () => {
|
||||
it('uses only the authoritative daemon processing tick at the close boundary', () => {
|
||||
const closeAt = new Date('0190-02-01T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
const world = {
|
||||
dateToGameTick: () => 72_000_001,
|
||||
gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)),
|
||||
};
|
||||
const processingNow = new Date(closeAt.getTime() + 1);
|
||||
|
||||
expect(hasAuctionBidClosePassed(auction, world, processingNow, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionBidClosePassed(auction, world, processingNow)).toBe(true);
|
||||
expect(hasAuctionBidClosePassed(auction, world, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionBidClosePassed(auction, world, 72_000_001)).toBe(true);
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'auction-bid-accepted-tick',
|
||||
@@ -161,23 +161,26 @@ describe('resource auction Ref compatibility', () => {
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
acceptedGameTick: 72_000_000,
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
).toMatchObject({ acceptedGameTick: 72_000_000 });
|
||||
).not.toHaveProperty('acceptedGameTick');
|
||||
});
|
||||
|
||||
it('uses the accepted logical time for delayed extension and persisted bid timestamps', async () => {
|
||||
const { acceptedAt, processingAt, result, insert, update } = await runDelayedResourceBid(false);
|
||||
const { acceptedAt, processingAt, requestedAtWall, result, insert, update } =
|
||||
await runDelayedResourceBid(false);
|
||||
|
||||
expect(result).toMatchObject({ type: 'auctionBid', ok: true });
|
||||
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
|
||||
acceptedAt.getTime() + 100_000
|
||||
);
|
||||
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([acceptedAt]);
|
||||
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([
|
||||
acceptedAt,
|
||||
requestedAtWall,
|
||||
]);
|
||||
expect(update?.values.filter((value): value is Date => value instanceof Date)).toEqual([
|
||||
new Date(acceptedAt.getTime() + 100_000),
|
||||
acceptedAt,
|
||||
acceptedAt,
|
||||
]);
|
||||
expect(update?.values).not.toContain(processingAt);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,12 @@ import {
|
||||
import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js';
|
||||
import type { TurnGeneral } from '../src/turn/types.js';
|
||||
|
||||
const withDaemonBoundary = <T extends object>(command: T, processingGameTick = 72_000_000): T =>
|
||||
Object.assign(command, {
|
||||
processingGameTick,
|
||||
requestedAtWall: new Date('2026-09-03T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
describe('unique auction inheritance log compatibility', () => {
|
||||
it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => {
|
||||
const userId = '4c2f2f6d-8a37-4f22-a4f9-1a6f5e4c22ec';
|
||||
@@ -113,19 +119,20 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
}),
|
||||
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
dateToGameTick: (date: Date) => Math.floor(date.getTime() / 1_000),
|
||||
gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
|
||||
pushLog: () => {},
|
||||
};
|
||||
|
||||
const result = await openAuction(
|
||||
{
|
||||
withDaemonBoundary({
|
||||
type: 'auctionOpen',
|
||||
userId: 'user-7',
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
generalId: general.id,
|
||||
amount: 6_000,
|
||||
itemKey: 'che_무기_12_칠성검',
|
||||
},
|
||||
}),
|
||||
world as unknown as Parameters<typeof openAuction>[1],
|
||||
db as unknown as NonNullable<Parameters<typeof openAuction>[2]>
|
||||
);
|
||||
@@ -192,6 +199,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => 72_000_000,
|
||||
gameTickToDate: () => closeAt,
|
||||
pushLog: vi.fn(),
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
@@ -201,12 +209,12 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{
|
||||
withDaemonBoundary({
|
||||
type: 'auctionFinalize',
|
||||
auctionId: 31,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
}),
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 });
|
||||
@@ -241,6 +249,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => nowTick,
|
||||
gameTickToDate: () => closeAt,
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
@@ -249,11 +258,23 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const db = commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>;
|
||||
|
||||
await expect(
|
||||
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }, db)
|
||||
finalizer.finalize(
|
||||
withDaemonBoundary(
|
||||
{ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 },
|
||||
71_999_999
|
||||
),
|
||||
db
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, reason: '경매 마감 시각이 아직 지나지 않았습니다.' });
|
||||
nowTick = 72_000_000;
|
||||
await expect(
|
||||
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 }, db)
|
||||
finalizer.finalize(
|
||||
withDaemonBoundary(
|
||||
{ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 },
|
||||
72_000_000
|
||||
),
|
||||
db
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, reason: '경매 마감 세대가 변경되었습니다.' });
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
|
||||
@@ -276,12 +297,16 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
detail: { amount: 100 },
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
closeTick: null,
|
||||
closeTick: 72_000_000n,
|
||||
},
|
||||
];
|
||||
});
|
||||
const commandDb = { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 0) };
|
||||
const world = { getGameNow: () => closeAt, dateToGameTick: () => 72_000_000 };
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => 72_000_000,
|
||||
gameTickToDate: () => closeAt,
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
@@ -289,7 +314,12 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{ type: 'auctionFinalize', auctionId: 31, expectedCloseAt: closeAt.toISOString() },
|
||||
withDaemonBoundary({
|
||||
type: 'auctionFinalize',
|
||||
auctionId: 31,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
}),
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31');
|
||||
@@ -317,6 +347,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const queueMessage = vi.fn();
|
||||
const world = {
|
||||
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
getGeneralById: (id: number) => (id === bidder.id ? bidder : id === host.id ? host : null),
|
||||
getNationById: () => ({ name: '촉', color: '#ff0000' }),
|
||||
updateGeneral,
|
||||
@@ -350,7 +381,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{ type: 'auctionFinalize', auctionId: 31 },
|
||||
withDaemonBoundary({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }),
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
|
||||
@@ -79,6 +79,7 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
revision: 1,
|
||||
});
|
||||
const generalTicks = [initialTick + 1_234, initialTick + 36_000_123];
|
||||
const reselectionTick = initialTick + 54_000_456;
|
||||
const auctionCloseTick = initialTick + 72_000_777;
|
||||
const messageOccurrenceTick = initialTick - 500;
|
||||
const messageExpiryTick = initialTick + 90_000_999;
|
||||
@@ -116,6 +117,14 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
turnTime: clock.tickToDate(turnTick),
|
||||
recentWarTick: BigInt(initialTick - 100 - index),
|
||||
recentWarTime: clock.tickToDate(initialTick - 100 - index),
|
||||
meta:
|
||||
index === 0
|
||||
? {
|
||||
next_change_tick: reselectionTick,
|
||||
next_change: clock.tickToDate(reselectionTick).toISOString(),
|
||||
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
|
||||
}
|
||||
: {},
|
||||
})),
|
||||
});
|
||||
await db.auction.create({
|
||||
@@ -138,7 +147,20 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
timeTick: BigInt(messageOccurrenceTick),
|
||||
validUntil: clock.tickToDate(messageExpiryTick),
|
||||
validUntilTick: BigInt(messageExpiryTick),
|
||||
createdAtWall: new Date('2026-01-01T12:34:56.789Z'),
|
||||
deleteUntilWall: new Date('2026-01-01T12:39:56.789Z'),
|
||||
occurredGameTick: BigInt(messageOccurrenceTick),
|
||||
message: {},
|
||||
action: {
|
||||
create: {
|
||||
actionType: 'scout',
|
||||
status: 'PENDING',
|
||||
createdGameTick: BigInt(messageOccurrenceTick),
|
||||
expiresGameTick: BigInt(messageExpiryTick),
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 7n,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.votePoll.create({
|
||||
@@ -201,17 +223,19 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
alignedTick: 236_035_000,
|
||||
});
|
||||
|
||||
const [afterWorld, generals, auction, message, vote, pool, token, ledger, outboxes] = await Promise.all([
|
||||
const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
|
||||
await Promise.all([
|
||||
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
|
||||
db.general.findMany({ orderBy: { id: 'asc' } }),
|
||||
db.auction.findFirstOrThrow(),
|
||||
db.message.findFirstOrThrow(),
|
||||
db.messageAction.findFirstOrThrow(),
|
||||
db.votePoll.findFirstOrThrow(),
|
||||
db.selectPoolEntry.findFirstOrThrow(),
|
||||
db.npcSelectionToken.findFirstOrThrow(),
|
||||
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
|
||||
db.clockProjectionOutbox.findMany(),
|
||||
]);
|
||||
]);
|
||||
const alignedTick = BigInt(reconciled.alignedTick);
|
||||
expect(afterWorld).toMatchObject({
|
||||
clockPhase: 'RECONCILING',
|
||||
@@ -223,8 +247,19 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
expect(generals.map((general) => general.turnTick! - alignedTick)).toEqual(
|
||||
generalTicks.map((tick) => BigInt(tick - initialTick))
|
||||
);
|
||||
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
|
||||
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
|
||||
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
|
||||
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
|
||||
);
|
||||
expect(auction.closeTick! - alignedTick).toBe(BigInt(auctionCloseTick - initialTick));
|
||||
expect(message.validUntilTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick));
|
||||
expect(messageAction.expiresGameTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick));
|
||||
expect(messageAction.createdGameTick).toBe(BigInt(messageOccurrenceTick));
|
||||
expect(messageAction.clockRevision).toBe(2n);
|
||||
expect(messageAction.deadlineGeneration).toBe(8n);
|
||||
expect(message.createdAtWall).toEqual(new Date('2026-01-01T12:34:56.789Z'));
|
||||
expect(message.deleteUntilWall).toEqual(new Date('2026-01-01T12:39:56.789Z'));
|
||||
expect(vote.endTick! - alignedTick).toBe(BigInt(voteEndTick - initialTick));
|
||||
expect(pool.reservedUntilTick! - alignedTick).toBe(BigInt(poolTick - initialTick));
|
||||
expect(token.validUntilTick! - alignedTick).toBe(BigInt(npcValidTick - initialTick));
|
||||
@@ -302,6 +337,21 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
await db.general.create({
|
||||
data: { id: 1, name: 'day-general', turnTick: BigInt(turnTick), turnTime: clock.tickToDate(turnTick) },
|
||||
});
|
||||
const wallMessageCreatedAt = new Date('2026-01-15T12:00:00.000Z');
|
||||
const wallMessageDeleteUntil = new Date('2026-01-15T12:05:00.000Z');
|
||||
const wallMessage = await db.message.create({
|
||||
data: {
|
||||
mailbox: 0,
|
||||
type: 'public',
|
||||
src: 1,
|
||||
dest: 0,
|
||||
time: wallMessageCreatedAt,
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
createdAtWall: wallMessageCreatedAt,
|
||||
deleteUntilWall: wallMessageDeleteUntil,
|
||||
message: { src: {}, dest: {}, text: 'wall clock survives 24h suspension', option: {} },
|
||||
},
|
||||
});
|
||||
await db.turnDaemonLease.create({
|
||||
data: {
|
||||
profile: 'clock-day-test',
|
||||
@@ -346,6 +396,10 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
});
|
||||
const shifted = await db.general.findUniqueOrThrow({ where: { id: 1 } });
|
||||
expect(shifted.turnTick! - BigInt(reconciled.alignedTick)).toBe(BigInt(turnTick - initialTick));
|
||||
await expect(db.message.findUniqueOrThrow({ where: { id: wallMessage.id } })).resolves.toMatchObject({
|
||||
createdAtWall: wallMessageCreatedAt,
|
||||
deleteUntilWall: wallMessageDeleteUntil,
|
||||
});
|
||||
|
||||
await redis.client.set('sammo:clock-day-test:clock:active-revision', '3');
|
||||
const redisThenCrash = {
|
||||
|
||||
@@ -25,7 +25,26 @@ integration('database command queue', () => {
|
||||
});
|
||||
await db.message.deleteMany({ where: { mailbox: 991_199 } });
|
||||
await db.worldState.deleteMany({
|
||||
where: { scenarioCode: { in: ['queue-clock-test', 'queue-unification-clock-test'] } },
|
||||
where: { scenarioCode: { in: ['queue-clock-base', 'queue-clock-test', 'queue-unification-clock-test'] } },
|
||||
});
|
||||
};
|
||||
|
||||
const createClockFixture = async (): Promise<void> => {
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'queue-clock-base',
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: new Date('0180-01-01T00:00:00.000Z'),
|
||||
clockTick: 123n,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'),
|
||||
lastTurnTick: 123n,
|
||||
clockPhase: 'MANUAL',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -39,7 +58,10 @@ integration('database command queue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(cleanupFixtures);
|
||||
beforeEach(async () => {
|
||||
await cleanupFixtures();
|
||||
await createClockFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupFixtures();
|
||||
@@ -63,7 +85,16 @@ integration('database command queue', () => {
|
||||
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
|
||||
const commands = firstCommands.concat(secondCommands);
|
||||
|
||||
expect(commands).toEqual([{ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }]);
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
|
||||
|
||||
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||
@@ -118,7 +149,16 @@ integration('database command queue', () => {
|
||||
await queue.initialize();
|
||||
const commands = await queue.drain();
|
||||
|
||||
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, userId: 'user-8', generalId: 8 }]);
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
type: 'vacation',
|
||||
requestId: expiredId,
|
||||
userId: 'user-8',
|
||||
generalId: 8,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
lockedBy: 'active-worker',
|
||||
@@ -146,7 +186,14 @@ integration('database command queue', () => {
|
||||
const stale = new DatabaseTurnDaemonCommandQueue(db);
|
||||
for (const attempt of [1, 2, 3]) {
|
||||
await expect(owner.drain()).resolves.toEqual([
|
||||
{ type: 'vacation', requestId, userId: 'user-10', generalId: 10 },
|
||||
{
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
userId: 'user-10',
|
||||
generalId: 10,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
await stale.publishCommandError(requestId, new Error('stale worker failure'));
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
@@ -220,7 +267,13 @@ integration('database command queue', () => {
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
const claimed = await owner.drain();
|
||||
expect(claimed).toEqual([command]);
|
||||
expect(claimed).toEqual([
|
||||
{
|
||||
...command,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||
expect(result).toMatchObject({
|
||||
type: 'commandRejected',
|
||||
@@ -301,7 +354,14 @@ integration('database command queue', () => {
|
||||
});
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
expect(await queue.drain()).toEqual([{ type: 'getStatus', requestId: statusId }]);
|
||||
expect(await queue.drain()).toEqual([
|
||||
{
|
||||
type: 'getStatus',
|
||||
requestId: statusId,
|
||||
processingGameTick: 100,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
|
||||
status: 'PENDING',
|
||||
processingClockRevision: null,
|
||||
@@ -309,7 +369,14 @@ integration('database command queue', () => {
|
||||
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } });
|
||||
|
||||
expect(await queue.drain()).toEqual([
|
||||
{ type: 'vacation', requestId: gameplayId, userId: 'user-7', generalId: 7 },
|
||||
{
|
||||
type: 'vacation',
|
||||
requestId: gameplayId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
processingGameTick: 100,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
@@ -347,6 +414,7 @@ integration('database command queue', () => {
|
||||
userId: 'user-8',
|
||||
generalId: 8,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({
|
||||
@@ -359,6 +427,103 @@ integration('database command queue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('dequeues only tournament bet accounting commands while the game clock is suspended', async () => {
|
||||
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
||||
const world = existingWorld
|
||||
? await db.worldState.update({
|
||||
where: { id: existingWorld.id },
|
||||
data: { clockPhase: 'SUSPENDED', clockRevision: 19n, deadlineGeneration: 6n, clockTick: 321n },
|
||||
})
|
||||
: await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'queue-clock-test',
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
clockPhase: 'SUSPENDED',
|
||||
clockRevision: 19n,
|
||||
deadlineGeneration: 6n,
|
||||
clockTick: 321n,
|
||||
},
|
||||
});
|
||||
const resourceId = 'integration:engine:suspended-tournament-bet-resource';
|
||||
const metaId = 'integration:engine:suspended-tournament-bet-meta';
|
||||
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
|
||||
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
requestId: resourceId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralResources',
|
||||
payload: {
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: resourceId,
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: 7, goldDelta: -100 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: metaId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralMeta',
|
||||
payload: {
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: metaId,
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: 7, metaDelta: { betgold: 100 } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: rollbackId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralResources',
|
||||
payload: {
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: rollbackId,
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: 7, goldDelta: 100 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: unrelatedId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralResources',
|
||||
payload: {
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: unrelatedId,
|
||||
reason: 'otherMutation',
|
||||
adjustments: [{ generalId: 7, goldDelta: -100 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await expect(queue.drain()).resolves.toEqual([
|
||||
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
|
||||
expect.objectContaining({ type: 'adjustGeneralMeta', requestId: metaId, reason: 'tournamentBet' }),
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: rollbackId,
|
||||
reason: 'tournamentBetRollback',
|
||||
}),
|
||||
]);
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
processingGameTick: 321n,
|
||||
processingClockRevision: 19n,
|
||||
processingDeadlineGeneration: 6n,
|
||||
});
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: unrelatedId } })).resolves.toMatchObject({
|
||||
status: 'PENDING',
|
||||
processingClockRevision: null,
|
||||
});
|
||||
|
||||
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RECONCILING' } });
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('dequeues only the invader decision while an UNIFICATION_WAIT suspension is active', async () => {
|
||||
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
||||
const world = existingWorld
|
||||
@@ -389,6 +554,37 @@ integration('database command queue', () => {
|
||||
message: { option: { action: 'raiseInvader', used: false } },
|
||||
},
|
||||
});
|
||||
await db.messageAction.create({
|
||||
data: {
|
||||
messageId: message.id,
|
||||
actionType: 'raiseInvader',
|
||||
status: 'PENDING',
|
||||
createdGameTick: 900n,
|
||||
clockRevision: 31n,
|
||||
deadlineGeneration: 7n,
|
||||
},
|
||||
});
|
||||
const scoutMessage = await db.message.create({
|
||||
data: {
|
||||
mailbox: 991_199,
|
||||
type: 'private',
|
||||
src: 7,
|
||||
dest: 991_199,
|
||||
time: new Date(),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
message: { option: { action: 'scout', used: false } },
|
||||
},
|
||||
});
|
||||
await db.messageAction.create({
|
||||
data: {
|
||||
messageId: scoutMessage.id,
|
||||
actionType: 'scout',
|
||||
status: 'PENDING',
|
||||
createdGameTick: 900n,
|
||||
clockRevision: 31n,
|
||||
deadlineGeneration: 7n,
|
||||
},
|
||||
});
|
||||
await db.clockSuspension.create({
|
||||
data: {
|
||||
id: 'integration-unification-wait',
|
||||
@@ -404,6 +600,7 @@ integration('database command queue', () => {
|
||||
},
|
||||
});
|
||||
const messageRequestId = 'integration:engine:unification-message';
|
||||
const scoutRequestId = 'integration:engine:suspended-scout-response';
|
||||
const gameplayRequestId = 'integration:engine:unification-gameplay';
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
@@ -424,6 +621,23 @@ integration('database command queue', () => {
|
||||
response: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: scoutRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'messageRespond',
|
||||
actorUserId: 'user-991199',
|
||||
acceptedGameTick: 900n,
|
||||
acceptedClockRevision: 31n,
|
||||
acceptedDeadlineGeneration: 7n,
|
||||
payload: {
|
||||
type: 'messageRespond',
|
||||
requestId: scoutRequestId,
|
||||
userId: 'user-991199',
|
||||
generalId: 991_199,
|
||||
messageId: scoutMessage.id,
|
||||
response: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: gameplayRequestId,
|
||||
target: 'ENGINE',
|
||||
@@ -451,10 +665,15 @@ integration('database command queue', () => {
|
||||
generalId: 991_199,
|
||||
messageId: message.id,
|
||||
response: true,
|
||||
processingGameTick: 900,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } })
|
||||
).resolves.toMatchObject({ status: 'PENDING' });
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: scoutRequestId } })).resolves.toMatchObject({
|
||||
status: 'PENDING',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,6 +165,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
db = connector.prisma;
|
||||
disconnect = () => connector.disconnect();
|
||||
await dropFailureConstraints();
|
||||
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||
@@ -198,6 +199,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
await hooks?.close();
|
||||
if (db) {
|
||||
await dropFailureConstraints();
|
||||
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||
@@ -284,7 +286,13 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
const assertStored = async (point: number, spent: number, logCount: number, messageCount: number) => {
|
||||
const assertStored = async (
|
||||
point: number,
|
||||
spent: number,
|
||||
logCount: number,
|
||||
messageCount: number,
|
||||
ledgerCount: number
|
||||
) => {
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: actorUserId, key: 'previous' } },
|
||||
@@ -299,6 +307,9 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
await expect(
|
||||
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
|
||||
).resolves.toBe(messageCount);
|
||||
await expect(
|
||||
db.inheritanceLedger.count({ where: { requestId: { startsWith: requestPrefix } } })
|
||||
).resolves.toBe(ledgerCount);
|
||||
};
|
||||
|
||||
const pointCommand = buildCommand('point', {
|
||||
@@ -315,7 +326,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`);
|
||||
expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 });
|
||||
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff');
|
||||
await assertStored(10_000, 17, 0, 0);
|
||||
await assertStored(10_000, 17, 0, 0, 0);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
|
||||
).resolves.toMatchObject({
|
||||
@@ -324,7 +335,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
});
|
||||
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
|
||||
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 });
|
||||
await assertStored(9_800, 217, 1, 0);
|
||||
await assertStored(9_800, 217, 1, 0, 1);
|
||||
|
||||
const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
|
||||
await createInputEvent(rankCommand);
|
||||
@@ -335,14 +346,14 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
`);
|
||||
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
|
||||
expect(world.peekDirtyState().messages).toEqual([]);
|
||||
await assertStored(9_800, 217, 1, 0);
|
||||
await assertStored(9_800, 217, 1, 0, 1);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
|
||||
await expect(execute(rankCommand)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
remainPoint: 8_800,
|
||||
ownerName: '레거시 소유자',
|
||||
});
|
||||
await assertStored(8_800, 1_217, 2, 2);
|
||||
await assertStored(8_800, 1_217, 2, 2, 2);
|
||||
|
||||
const currentLog = await db.inheritanceLog.findFirstOrThrow({
|
||||
where: { userId: actorUserId },
|
||||
@@ -358,10 +369,10 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
`);
|
||||
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
|
||||
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique');
|
||||
await assertStored(8_800, 1_217, 2, 2);
|
||||
await assertStored(8_800, 1_217, 2, 2, 2);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
|
||||
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
|
||||
await assertStored(5_800, 4_217, 3, 2);
|
||||
await assertStored(5_800, 4_217, 3, 2, 3);
|
||||
|
||||
const freeStatCommand = buildCommand('free-stat', {
|
||||
action: 'resetStat',
|
||||
@@ -372,7 +383,21 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
});
|
||||
await createInputEvent(freeStatCommand);
|
||||
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
|
||||
await assertStored(5_800, 4_217, 5, 2);
|
||||
await assertStored(5_800, 4_217, 5, 2, 4);
|
||||
|
||||
const ledgers = await db.inheritanceLedger.findMany({
|
||||
where: { requestId: { startsWith: requestPrefix } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
expect(ledgers.map(({ action, cost, status }) => ({ action, cost, status }))).toEqual([
|
||||
{ action: 'buyHiddenBuff', cost: 200, status: 'APPLIED' },
|
||||
{ action: 'checkOwner', cost: 1_000, status: 'APPLIED' },
|
||||
{ action: 'buyRandomUnique', cost: 3_000, status: 'APPLIED' },
|
||||
{ action: 'resetStat', cost: 0, status: 'APPLIED' },
|
||||
]);
|
||||
expect(ledgers.every((row) => row.consumedAtWall instanceof Date && row.createdAtWall instanceof Date)).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const messages = await db.message.findMany({
|
||||
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('input event atomicity', () => {
|
||||
ok: true,
|
||||
auctionId: 3,
|
||||
closeAt: '2026-01-01T00:10:00.000Z',
|
||||
closeTick: 3_600_000,
|
||||
};
|
||||
let resolveResponse: (() => void) | undefined;
|
||||
const responded = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -806,6 +806,8 @@ describeDb('scenario database seed', () => {
|
||||
amount: 1,
|
||||
eventId: marker,
|
||||
eventAt: new Date('2033-01-01T00:00:00.000Z'),
|
||||
occurredGameTick: 0n,
|
||||
requestedAtWall: new Date('2033-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
const bettingId = 990_731;
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('selection-pool reservation command state', () => {
|
||||
const rows = buildRows();
|
||||
const world = buildWorld(rows);
|
||||
const db = buildDb(rows);
|
||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
||||
const reserve = (userId: string, processingGameTick: number) =>
|
||||
reserveSelectionPool({
|
||||
db: db as never,
|
||||
world,
|
||||
@@ -232,7 +232,7 @@ describe('selection-pool reservation command state', () => {
|
||||
userId,
|
||||
seedOwnerIdentity: userId,
|
||||
now: acceptedAt,
|
||||
acceptedGameTick,
|
||||
processingGameTick,
|
||||
});
|
||||
|
||||
const first = await reserve('first-user', 0);
|
||||
@@ -267,7 +267,7 @@ describe('selection-pool reservation command state', () => {
|
||||
rows[1]!.reservedUntilTick = 0n;
|
||||
const world = buildWorld(rows);
|
||||
const db = buildDb(rows);
|
||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
||||
const reserve = (userId: string, processingGameTick: number) =>
|
||||
reserveSelectionPool({
|
||||
db: db as never,
|
||||
world,
|
||||
@@ -275,7 +275,7 @@ describe('selection-pool reservation command state', () => {
|
||||
userId,
|
||||
seedOwnerIdentity: userId,
|
||||
now: acceptedAt,
|
||||
acceptedGameTick,
|
||||
processingGameTick,
|
||||
});
|
||||
|
||||
const first = await reserve('first-user', 0);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
@@ -81,38 +80,12 @@ const buildDefaultUniquePoolSnapshot = (general: TurnGeneral): TurnWorldSnapshot
|
||||
});
|
||||
|
||||
describe('voteReward command', () => {
|
||||
it('keeps the wall-time fallback open at exact deadline equality', () => {
|
||||
it('fails closed when a GAME_TIME poll lost its authoritative end tick', () => {
|
||||
const deadline = new Date('0180-01-01T00:00:00.000Z');
|
||||
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, deadline, 0)).toBe(false);
|
||||
expect(
|
||||
hasVotePollDeadlinePassed(
|
||||
{ endAt: deadline, endTick: null, closedAt: null },
|
||||
new Date(deadline.getTime() + 1),
|
||||
0
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves the server-accepted game tick through durable command normalization', () => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'vote-accepted-tick',
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'voteReward',
|
||||
userId: 'user-1',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
type: 'voteReward',
|
||||
requestId: 'vote-accepted-tick',
|
||||
acceptedGameTick: 100,
|
||||
});
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, 0)).toBe(true);
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 0)).toBe(false);
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies gold, unique item, logs, and idempotency', async () => {
|
||||
@@ -290,9 +263,7 @@ describe('voteReward command', () => {
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
// Ref accepts the request at exact equality. Engine processing may
|
||||
// occur after the logical clock has advanced beyond the deadline.
|
||||
acceptedGameTick: 0,
|
||||
processingGameTick: 0,
|
||||
};
|
||||
|
||||
const writerWindowStart = Date.now();
|
||||
@@ -418,29 +389,26 @@ describe('voteReward command', () => {
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld });
|
||||
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command;
|
||||
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
} as any,
|
||||
});
|
||||
expect(legacyLateResult).toMatchObject({
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
reason: '설문조사가 종료되었습니다.',
|
||||
});
|
||||
const { processingGameTick: _processingGameTick, ...missingBoundaryCommand } = command;
|
||||
await expect(
|
||||
legacyLateHandler.handle(missingBoundaryCommand, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
} as any,
|
||||
})
|
||||
).rejects.toThrow('authoritative daemon processing game tick');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -501,8 +469,8 @@ describe('voteReward command', () => {
|
||||
voteId,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
acceptedGameTick,
|
||||
},
|
||||
processingGameTick: acceptedGameTick,
|
||||
} as any,
|
||||
{ db: commandDb as any }
|
||||
);
|
||||
|
||||
@@ -602,7 +570,8 @@ describe('voteReward command', () => {
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
},
|
||||
processingGameTick: 0,
|
||||
} as any,
|
||||
{ db: commandDb as any }
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user