merge: 최신 main을 tRPC JSON 본문 전송에 통합

This commit is contained in:
2026-08-17 11:12:09 +00:00
49 changed files with 1182 additions and 162 deletions
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { buildRefGeneralTargetOptions, type GeneralTargetSource } from '../src/turns/commandTargets.js';
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
id: 1,
name: '본인',
nationId: 1,
cityId: 10,
npcState: 0,
officerLevel: 5,
...overrides,
});
describe('Ref command general targets', () => {
const sources = [
general({}),
general({ id: 2, name: '아국유저', officerLevel: 12 }),
general({ id: 3, name: '아국NPC', npcState: 2, officerLevel: 0 }),
general({ id: 4, name: '타국유저', nationId: 2, cityId: 20, officerLevel: 0 }),
general({ id: 5, name: '타국NPC', nationId: 2, cityId: 20, npcState: 3, officerLevel: 0 }),
];
const result = buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 1,
generals: sources,
nationNames: new Map([
[1, '아국'],
[2, '타국'],
]),
cityNames: new Map([
[10, '업'],
[20, '허창'],
]),
});
const ids = (action: string) => result.generalTargets[action]?.map((entry) => entry.value);
it('includes user and NPC generals of the same nation for every Ref nation personnel command', () => {
for (const action of ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시']) {
expect(ids(action)).toEqual([1, 2, 3]);
}
});
it('preserves the distinct Ref filters for gift, abdication, recruitment, and target-based joining', () => {
expect(ids('che_증여')).toEqual([1, 2, 3]);
expect(ids('che_선양')).toEqual([2, 3]);
expect(ids('che_등용')).toEqual([4]);
expect(ids('che_장수대상임관')).toEqual([2, 3, 4, 5]);
expect(result.generals.map((entry) => entry.value)).toEqual([1, 2, 4]);
});
});
+89 -1
View File
@@ -10,7 +10,9 @@ import {
setGeneralTurn,
setGeneralTurns,
setNationTurn,
setNationTurnAtCurrentPosition,
setNationTurns,
setNationTurnsAtCurrentPositions,
shiftGeneralTurns,
shiftNationTurns,
ReservedTurnRevisionConflictError,
@@ -195,7 +197,7 @@ const buildDb = (autorunLimit: number | null = null) => {
},
} as unknown as DatabaseClient;
return { db };
return { db, nationTurns, nationRevisions };
};
describe('reservedTurns', () => {
@@ -343,6 +345,61 @@ describe('reservedTurns', () => {
expect(noOpPush).toEqual(repeated);
});
it('rebases stale nation slot input onto the current queue after a turn advances', async () => {
const { db, nationTurns, nationRevisions } = buildDb();
const seeded = await setNationTurns(
db,
6,
12,
[
{ turnIndices: [0], action: 'che_증축', args: {} },
{ turnIndices: [1], action: 'che_감축', args: {} },
{ turnIndices: [2], action: 'che_천도', args: { destCityId: 3 } },
],
0
);
expect(seeded.revision).toBe(1);
// daemon이 한 턴을 소비한 뒤의 현재 큐를 모사한다.
nationRevisions.set('6:12', 2);
nationTurns.set('6:12', [
{
id: 1,
nationId: 6,
officerLevel: 12,
turnIdx: 0,
actionCode: 'che_감축',
arg: {},
createdAt: new Date(),
},
{
id: 2,
nationId: 6,
officerLevel: 12,
turnIdx: 1,
actionCode: 'che_천도',
arg: { destCityId: 3 },
createdAt: new Date(),
},
]);
const result = await setNationTurnsAtCurrentPositions(
db,
6,
12,
[{ turnIndices: [2], action: 'che_포상', args: { destGeneralId: 77, amount: 100, isGold: true } }],
1
);
expect(result.revision).toBe(3);
expect(result.turns[0]?.action).toBe('che_감축');
expect(result.turns[1]?.action).toBe('che_천도');
expect(result.turns[2]).toMatchObject({
action: 'che_포상',
args: { destGeneralId: 77, amount: 100, isGold: true },
});
});
it('rejects an API writer while the daemon holds the queue lease without touching turns', async () => {
const deleteMany = vi.fn(async () => ({}));
const createMany = vi.fn(async () => ({}));
@@ -371,4 +428,35 @@ describe('reservedTurns', () => {
expect(deleteMany).not.toHaveBeenCalled();
expect(createMany).not.toHaveBeenCalled();
});
it('does not rebase a current-position nation write while the daemon lease holds the same revision', async () => {
const deleteMany = vi.fn(async () => ({}));
const createMany = vi.fn(async () => ({}));
const db = {
nationTurnRevision: {
updateMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
findUnique: vi.fn(async () => ({
nationId: 6,
officerLevel: 12,
revision: 4,
leaseOwner: 'daemon-1',
leaseExpiresAt: new Date(Date.now() + 60_000),
updatedAt: new Date(),
})),
},
nationTurn: {
findMany: vi.fn(async () => []),
deleteMany,
createMany,
},
} as unknown as DatabaseClient;
await expect(setNationTurnAtCurrentPosition(db, 6, 12, 2, 'che_증축', {}, 4)).rejects.toMatchObject({
expectedRevision: 4,
currentRevision: 4,
});
expect(deleteMany).not.toHaveBeenCalled();
expect(createMany).not.toHaveBeenCalled();
});
});
+16 -1
View File
@@ -1021,7 +1021,22 @@ describe('appRouter', () => {
expect(response.turns[0]?.args).toEqual({ isGold: true, amount: 1, destGeneralId: 7 });
expect(response.turns[2]?.args).toEqual({ isGold: false, amount: 2, destGeneralId: 8 });
expect(nationWrites).toHaveLength(1);
const rebased = await caller.turns.reserved.setNationBulk({
generalId: general.id,
entries: [
{
turnList: [2],
action: 'che_포상',
args: { isGold: true, amount: 3, destGeneralId: 9 },
},
],
// 첫 요청 뒤 턴이 진행한 화면의 stale revision을 그대로 보낸 상황입니다.
expectedRevision: 0,
});
expect(rebased.revision).toBe(2);
expect(rebased.turns[2]?.args).toEqual({ isGold: true, amount: 3, destGeneralId: 9 });
expect(nationWrites).toHaveLength(2);
});
it('enforces only legacy reservation permissions without applying full execution constraints', async () => {
@@ -204,6 +204,20 @@ describe('tournament router permissions and mutations', () => {
expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
const snapshot = await caller.tournament.getSnapshot();
expect(snapshot.participants).toHaveLength(1);
expect(snapshot.participants[0]).toMatchObject({
id: general.id,
groupId: expect.any(Number),
groupNo: 0,
win: 0,
draw: 0,
lose: 0,
gl: 0,
});
expect(snapshot.participants[0]!.groupId).toBeGreaterThanOrEqual(0);
expect(snapshot.participants[0]!.groupId).toBeLessThan(8);
});
it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => {
+53 -1
View File
@@ -12,7 +12,12 @@ import type {
TournamentState,
} from '../src/tournament/types.js';
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
import { buildBettingPayouts, resolveBettingCloseAt, resolveNextAt } from '../src/tournament/workerHelpers.js';
import {
assignManualApplicantGroup,
buildBettingPayouts,
resolveBettingCloseAt,
resolveNextAt,
} from '../src/tournament/workerHelpers.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
class MemoryRedis {
@@ -226,6 +231,45 @@ const runTournamentToCompletion = async (options: {
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
describe('tournament worker schedule compatibility', () => {
it('수동 참가자를 즉시 남은 예선 조의 다음 슬롯에 배치한다', () => {
const current = Array.from({ length: 63 }, (_, index): TournamentParticipantEntry => {
const groupId = index < 47 ? index % 8 : (index + 1) % 8;
const groupNo = Math.floor(index / 8);
return {
id: index + 1,
name: `참가자${index + 1}`,
leadership: 70,
strength: 70,
intel: 70,
level: 10,
groupId,
groupNo,
};
});
const groupCounts = Array.from({ length: 8 }, (_, groupId) =>
current.filter((entry) => entry.groupId === groupId).length
);
const openGroupId = groupCounts.findIndex((count) => count === 7);
expect(openGroupId).toBeGreaterThanOrEqual(0);
expect(groupCounts.filter((count) => count === 7)).toHaveLength(1);
const applicant = assignManualApplicantGroup({
state: createTournamentState(),
baseSeed: 'manual-join-seed',
current,
applicant: {
id: 100,
name: '즉시배치',
leadership: 80,
strength: 81,
intel: 82,
level: 20,
},
});
expect(applicant).toMatchObject({ groupId: openGroupId, groupNo: 7, win: 0, draw: 0, lose: 0, gl: 0 });
});
it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => {
const state = createTournamentState({
termSeconds: 600,
@@ -600,6 +644,14 @@ describe('tournament worker (in-memory)', () => {
expect(participants.some((entry) => entry.id === 99)).toBe(false);
expect(participants.some((entry) => entry.id === 1001)).toBe(true);
expect(participants.some((entry) => entry.id < 0)).toBe(true);
expect(participants.every((entry) => entry.groupId !== undefined && entry.groupNo !== undefined)).toBe(true);
expect(participants.find((entry) => entry.id === 1)).toMatchObject({ groupId: expect.any(Number) });
expect(participants.find((entry) => entry.id === 1001)).toMatchObject({ groupId: expect.any(Number) });
expect(
Array.from({ length: 8 }, (_, groupId) =>
participants.filter((entry) => entry.groupId === groupId).length
)
).toEqual(Array.from({ length: 8 }, () => 8));
await store.setState(afterJoin);
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });