merge: 최신 main을 비용 기반 갱신 점수에 통합
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isAvailableNationTraitKey } from '@sammo-ts/logic';
|
||||
|
||||
import type { BattleSimRequestPayload } from './types.js';
|
||||
|
||||
const zBattleSimGeneral = z.object({
|
||||
@@ -71,7 +73,7 @@ const zBattleSimCity = z.object({
|
||||
});
|
||||
|
||||
const zBattleSimNation = z.object({
|
||||
type: z.string().min(1),
|
||||
type: z.string().refine(isAvailableNationTraitKey),
|
||||
tech: z.number().min(0),
|
||||
level: z.number().int().min(0),
|
||||
capital: z.number().int().min(0),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AVAILABLE_NATION_TRAIT_KEYS,
|
||||
ITEM_KEYS,
|
||||
EVENT_DOMESTIC_TRAIT_KEYS,
|
||||
loadEventDomesticTraitModules,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
loadNationTraitModules,
|
||||
loadPersonalityTraitModules,
|
||||
loadWarTraitModules,
|
||||
NATION_TRAIT_KEYS,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
WAR_TRAIT_KEYS,
|
||||
type ItemModule,
|
||||
@@ -110,7 +110,7 @@ export const loadBattleSimTraitOptions = async (): Promise<{
|
||||
}> => {
|
||||
if (!cachedTraitOptions) {
|
||||
cachedTraitOptions = Promise.all([
|
||||
loadNationTraitModules([...NATION_TRAIT_KEYS]),
|
||||
loadNationTraitModules([...AVAILABLE_NATION_TRAIT_KEYS]),
|
||||
loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]),
|
||||
loadWarTraitModules([...WAR_TRAIT_KEYS]),
|
||||
loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
import { TournamentStore } from '../../tournament/store.js';
|
||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
||||
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
@@ -412,52 +413,31 @@ export const tournamentRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const settingResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
generalId: general.id,
|
||||
settings: { tnmt: 1 },
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
const applicant = assignManualApplicantGroup({
|
||||
state,
|
||||
baseSeed: String(asRecord(worldState?.meta).hiddenSeed ?? 'tournament'),
|
||||
current: participants,
|
||||
applicant: {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intel: general.intel,
|
||||
level,
|
||||
},
|
||||
});
|
||||
if (!settingResult || settingResult.type !== 'setMySetting' || !settingResult.ok) {
|
||||
const next = participants.concat(applicant);
|
||||
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
settingResult && settingResult.type === 'setMySetting'
|
||||
? (settingResult.reason ?? '요청에 실패했습니다.')
|
||||
: 'Unexpected response',
|
||||
});
|
||||
}
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
const next = participants.concat({
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intel: general.intel,
|
||||
level,
|
||||
});
|
||||
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
generalId: general.id,
|
||||
settings: { tnmt: 0 },
|
||||
}),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, count: next.length };
|
||||
|
||||
@@ -28,13 +28,14 @@ import {
|
||||
repeatNationTurns,
|
||||
setGeneralTurn,
|
||||
setGeneralTurns,
|
||||
setNationTurn,
|
||||
setNationTurns,
|
||||
setNationTurnAtCurrentPosition,
|
||||
setNationTurnsAtCurrentPositions,
|
||||
shiftGeneralTurns,
|
||||
shiftNationTurns,
|
||||
} from '../../turns/reservedTurns.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js';
|
||||
import { buildRefGeneralTargetOptions } from '../../turns/commandTargets.js';
|
||||
|
||||
const zPushAmount = z
|
||||
.number()
|
||||
@@ -181,9 +182,15 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.general.findMany({
|
||||
where: { npcState: { lt: 2 } },
|
||||
select: { id: true, name: true, nationId: true, cityId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
npcState: true,
|
||||
officerLevel: true,
|
||||
},
|
||||
orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
environmentPromise,
|
||||
loadBattleSimTraitOptions(),
|
||||
@@ -192,7 +199,13 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
]);
|
||||
|
||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
|
||||
const generalTargetOptions = buildRefGeneralTargetOptions({
|
||||
actorId: general.id,
|
||||
actorNationId: general.nationId,
|
||||
generals,
|
||||
nationNames: new Map(nations.map((entry) => [entry.id, entry.name])),
|
||||
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
|
||||
});
|
||||
const items: TurnCommandInputOptions['items'] = {
|
||||
horse: [{ value: 'None', label: '판매/해제' }],
|
||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||
@@ -226,12 +239,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
label: entry.name,
|
||||
color: entry.color,
|
||||
})),
|
||||
generals: generals.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${
|
||||
cityById.get(entry.cityId)?.name ?? '재야'
|
||||
})`,
|
||||
})),
|
||||
generals: generalTargetOptions.generals,
|
||||
generalTargets: generalTargetOptions.generalTargets,
|
||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
||||
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||
@@ -446,7 +455,7 @@ export const turnsRouter = router({
|
||||
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
||||
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
setNationTurn(
|
||||
setNationTurnAtCurrentPosition(
|
||||
ctx.db,
|
||||
general.nationId,
|
||||
general.officerLevel,
|
||||
@@ -559,7 +568,13 @@ export const turnsRouter = router({
|
||||
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
||||
}
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
setNationTurns(ctx.db, general.nationId, general.officerLevel, updates, input.expectedRevision)
|
||||
setNationTurnsAtCurrentPositions(
|
||||
ctx.db,
|
||||
general.nationId,
|
||||
general.officerLevel,
|
||||
updates,
|
||||
input.expectedRevision
|
||||
)
|
||||
);
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
|
||||
@@ -155,6 +155,60 @@ export const assignGroupSlots = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Ref assigns a manual applicant to one uniformly selected non-full preliminary
|
||||
* group as part of the join request. Keeping that assignment in the persisted
|
||||
* participant projection lets the applicant see the group immediately while
|
||||
* the later participant-fill pass can still balance automatic applicants.
|
||||
*/
|
||||
export const assignManualApplicantGroup = (options: {
|
||||
state: TournamentState;
|
||||
baseSeed: string;
|
||||
current: TournamentParticipantEntry[];
|
||||
applicant: TournamentParticipantEntry;
|
||||
groupCount?: number;
|
||||
groupSize?: number;
|
||||
}): TournamentParticipantEntry => {
|
||||
const groupCount = options.groupCount ?? 8;
|
||||
const groupSize = options.groupSize ?? 8;
|
||||
const groupCounts = Array.from({ length: groupCount }, () => 0);
|
||||
|
||||
for (const participant of options.current) {
|
||||
const groupId = participant.groupId;
|
||||
if (groupId !== undefined && groupId >= 0 && groupId < groupCount) {
|
||||
groupCounts[groupId] = (groupCounts[groupId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const openGroupIds = groupCounts.flatMap((count, groupId) => (count < groupSize ? [groupId] : []));
|
||||
if (openGroupIds.length === 0) {
|
||||
throw new Error('참가 인원이 가득 찼습니다.');
|
||||
}
|
||||
|
||||
const rng = createTournamentRng(options.baseSeed, {
|
||||
openYear: options.state.openYear,
|
||||
openMonth: options.state.openMonth,
|
||||
stage: 1,
|
||||
phase: options.state.phase,
|
||||
matchIndex: options.applicant.id,
|
||||
participantIndex: options.current.length,
|
||||
extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`,
|
||||
});
|
||||
const groupId = rng.choice(openGroupIds);
|
||||
|
||||
return {
|
||||
...options.applicant,
|
||||
groupId,
|
||||
groupNo: groupCounts[groupId] ?? 0,
|
||||
win: 0,
|
||||
draw: 0,
|
||||
lose: 0,
|
||||
gl: 0,
|
||||
seedRank: 0,
|
||||
finalRank: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const selectWeighted = <T>(rng: ReturnType<typeof createTournamentRng>, pool: Array<{ item: T; weight: number }>): T =>
|
||||
rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface TurnCommandInputOptions {
|
||||
cities: TurnCommandOption[];
|
||||
nations: TurnCommandOption[];
|
||||
generals: TurnCommandOption[];
|
||||
generalTargets?: Record<string, TurnCommandOption[]>;
|
||||
crewTypes: TurnCommandOption[];
|
||||
armTypes: TurnCommandOption[];
|
||||
nationTypes: TurnCommandOption[];
|
||||
|
||||
@@ -771,6 +771,7 @@ export const buildTurnCommandTable = async (options: {
|
||||
cities: [],
|
||||
nations: [],
|
||||
generals: [],
|
||||
generalTargets: {},
|
||||
crewTypes: [],
|
||||
armTypes: [],
|
||||
nationTypes: [],
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { TurnCommandOption } from './commandInput.js';
|
||||
|
||||
export interface GeneralTargetSource {
|
||||
id: number;
|
||||
name: string;
|
||||
nationId: number;
|
||||
cityId: number;
|
||||
npcState: number;
|
||||
officerLevel: number;
|
||||
}
|
||||
|
||||
export interface RefGeneralTargetOptions {
|
||||
generals: TurnCommandOption[];
|
||||
generalTargets: Record<string, TurnCommandOption[]>;
|
||||
}
|
||||
|
||||
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
|
||||
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const;
|
||||
|
||||
/** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */
|
||||
export const buildRefGeneralTargetOptions = (options: {
|
||||
actorId: number;
|
||||
actorNationId: number;
|
||||
generals: readonly GeneralTargetSource[];
|
||||
nationNames: ReadonlyMap<number, string>;
|
||||
cityNames: ReadonlyMap<number, string>;
|
||||
}): RefGeneralTargetOptions => {
|
||||
const toOption = (entry: GeneralTargetSource): TurnCommandOption => ({
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
|
||||
options.cityNames.get(entry.cityId) ?? '재야'
|
||||
})`,
|
||||
});
|
||||
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
|
||||
options.generals.filter(predicate).map(toOption);
|
||||
|
||||
const sameNation = project((entry) => entry.nationId === options.actorNationId);
|
||||
const generalTargets: Record<string, TurnCommandOption[]> = {};
|
||||
for (const action of SAME_NATION_GENERAL_COMMANDS) generalTargets[action] = sameNation;
|
||||
for (const action of SAME_NATION_NATION_COMMANDS) generalTargets[action] = sameNation;
|
||||
|
||||
generalTargets.che_선양 = project(
|
||||
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
|
||||
);
|
||||
generalTargets.che_등용 = project(
|
||||
(entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId
|
||||
);
|
||||
generalTargets.che_장수대상임관 = project((entry) => entry.id !== options.actorId);
|
||||
|
||||
return {
|
||||
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다.
|
||||
generals: project((entry) => entry.npcState < 2),
|
||||
generalTargets,
|
||||
};
|
||||
};
|
||||
@@ -165,20 +165,22 @@ const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<
|
||||
return buildTurnListFromRows(rows, MAX_GENERAL_TURNS);
|
||||
};
|
||||
|
||||
const loadGeneralAutorunLimit = async (db: DatabaseClient, generalId: number): Promise<number | null> => {
|
||||
const general = await db.general.findUnique({ where: { id: generalId }, select: { meta: true } });
|
||||
const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined;
|
||||
return typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit) ? Math.trunc(rawAutorunLimit) : null;
|
||||
};
|
||||
|
||||
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
|
||||
const [turns, revisionRow, general] = await Promise.all([
|
||||
const [turns, revisionRow, autorunLimit] = await Promise.all([
|
||||
loadGeneralTurns(db, generalId),
|
||||
db.generalTurnRevision.findUnique({ where: { generalId } }),
|
||||
db.general.findUnique({ where: { id: generalId }, select: { meta: true } }),
|
||||
loadGeneralAutorunLimit(db, generalId),
|
||||
]);
|
||||
const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined;
|
||||
return {
|
||||
revision: revisionRow?.revision ?? 0,
|
||||
turns: serializeTurnList(turns),
|
||||
autorunLimit:
|
||||
typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit)
|
||||
? Math.trunc(rawAutorunLimit)
|
||||
: null,
|
||||
autorunLimit,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -331,7 +333,7 @@ export const setGeneralTurns = async (
|
||||
}
|
||||
}
|
||||
await persistGeneralTurns(db, generalId, turns);
|
||||
return { revision, turns: serializeTurnList(turns) };
|
||||
return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) };
|
||||
};
|
||||
|
||||
export const setGeneralTurn = async (
|
||||
@@ -357,7 +359,7 @@ export const shiftGeneralTurns = async (
|
||||
const turns = await loadGeneralTurns(db, generalId);
|
||||
const shifted = applyShift(turns, amount);
|
||||
await persistGeneralTurns(db, generalId, shifted);
|
||||
return { revision, turns: serializeTurnList(shifted) };
|
||||
return { revision, turns: serializeTurnList(shifted), autorunLimit: await loadGeneralAutorunLimit(db, generalId) };
|
||||
};
|
||||
|
||||
export const repeatGeneralTurns = async (
|
||||
@@ -372,7 +374,7 @@ export const repeatGeneralTurns = async (
|
||||
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
|
||||
const turns = applyRepeat(await loadGeneralTurns(db, generalId), amount);
|
||||
await persistGeneralTurns(db, generalId, turns);
|
||||
return { revision, turns: serializeTurnList(turns) };
|
||||
return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) };
|
||||
};
|
||||
|
||||
export const setNationTurns = async (
|
||||
@@ -396,6 +398,32 @@ export const setNationTurns = async (
|
||||
return { revision, turns: serializeTurnList(turns) };
|
||||
};
|
||||
|
||||
/**
|
||||
* 국가 턴 입력은 화면을 연 뒤 daemon이 선두 턴을 소비했더라도 사용자가 고른
|
||||
* 슬롯 번호를 현재 큐에 적용한다. 큐 lease가 실제로 잡혀 있는 충돌은 그대로
|
||||
* 거절하고, revision이 앞으로 진행한 경우에만 새 revision으로 재기준화한다.
|
||||
*/
|
||||
export const setNationTurnsAtCurrentPositions = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
officerLevel: number,
|
||||
updates: readonly ReservedTurnUpdate[],
|
||||
expectedRevision: number
|
||||
): Promise<ReservedTurnSnapshot> => {
|
||||
let revision = expectedRevision;
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
try {
|
||||
return await setNationTurns(db, nationId, officerLevel, updates, revision);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReservedTurnRevisionConflictError) || error.currentRevision === revision) {
|
||||
throw error;
|
||||
}
|
||||
revision = error.currentRevision;
|
||||
}
|
||||
}
|
||||
throw new ReservedTurnRevisionConflictError(revision, revision);
|
||||
};
|
||||
|
||||
export const setNationTurn = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
@@ -407,6 +435,23 @@ export const setNationTurn = async (
|
||||
): Promise<ReservedTurnSnapshot> =>
|
||||
setNationTurns(db, nationId, officerLevel, [{ turnIndices: [turnIndex], action, args }], expectedRevision);
|
||||
|
||||
export const setNationTurnAtCurrentPosition = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
officerLevel: number,
|
||||
turnIndex: number,
|
||||
action: string,
|
||||
args: unknown,
|
||||
expectedRevision: number
|
||||
): Promise<ReservedTurnSnapshot> =>
|
||||
setNationTurnsAtCurrentPositions(
|
||||
db,
|
||||
nationId,
|
||||
officerLevel,
|
||||
[{ turnIndices: [turnIndex], action, args }],
|
||||
expectedRevision
|
||||
);
|
||||
|
||||
export const shiftNationTurns = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
|
||||
@@ -117,7 +117,7 @@ const buildBattleRequest = () => ({
|
||||
conflict: '{}',
|
||||
},
|
||||
attackerNation: {
|
||||
type: 'test',
|
||||
type: 'che_도적',
|
||||
tech: 1000,
|
||||
level: 1,
|
||||
capital: 1,
|
||||
@@ -193,7 +193,7 @@ const buildBattleRequest = () => ({
|
||||
conflict: '{}',
|
||||
},
|
||||
defenderNation: {
|
||||
type: 'test',
|
||||
type: 'che_도적',
|
||||
tech: 1000,
|
||||
level: 1,
|
||||
capital: 2,
|
||||
@@ -309,6 +309,27 @@ describe('battle router orchestration', () => {
|
||||
expect(battleSim.simulateCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects the neutral storage nation type before preparing or queuing a simulation', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
|
||||
const request = buildBattleRequest();
|
||||
request.attackerNation.type = 'che_중립';
|
||||
|
||||
await expect(caller.battle.prepareSimulation(request)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
await expect(caller.battle.simulate(request)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(battleSim.simulateCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('returns queued then completed results via transport', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
setGeneralTurn,
|
||||
setGeneralTurns,
|
||||
setNationTurn,
|
||||
setNationTurnAtCurrentPosition,
|
||||
setNationTurns,
|
||||
setNationTurnsAtCurrentPositions,
|
||||
shiftGeneralTurns,
|
||||
shiftNationTurns,
|
||||
ReservedTurnRevisionConflictError,
|
||||
} from '../src/turns/reservedTurns.js';
|
||||
|
||||
const buildDb = () => {
|
||||
const buildDb = (autorunLimit: number | null = null) => {
|
||||
const generalTurns = new Map<number, GeneralTurnRow[]>();
|
||||
const nationTurns = new Map<string, NationTurnRow[]>();
|
||||
const generalRevisions = new Map<number, number>();
|
||||
@@ -45,7 +47,7 @@ const buildDb = () => {
|
||||
findFirst: async () => null,
|
||||
},
|
||||
general: {
|
||||
findUnique: async () => null,
|
||||
findUnique: async () => ({ meta: autorunLimit === null ? {} : { autorun_limit: autorunLimit } }),
|
||||
},
|
||||
city: {
|
||||
findUnique: async () => null,
|
||||
@@ -195,27 +197,30 @@ const buildDb = () => {
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
|
||||
return { db };
|
||||
return { db, nationTurns, nationRevisions };
|
||||
};
|
||||
|
||||
describe('reservedTurns', () => {
|
||||
it('sets and shifts general turns', async () => {
|
||||
const { db } = buildDb();
|
||||
const { db } = buildDb(2408);
|
||||
|
||||
const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0);
|
||||
|
||||
expect(initial.revision).toBe(1);
|
||||
expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS);
|
||||
expect(initial.turns[0]?.action).toBe('che_화계');
|
||||
expect(initial.autorunLimit).toBe(2408);
|
||||
|
||||
const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision);
|
||||
expect(pushed.revision).toBe(2);
|
||||
expect(pushed.turns[0]?.action).toBe('휴식');
|
||||
expect(pushed.turns[1]?.action).toBe('che_화계');
|
||||
expect(pushed.autorunLimit).toBe(2408);
|
||||
|
||||
const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision);
|
||||
expect(pulled.turns[0]?.action).toBe('che_화계');
|
||||
expect(pulled.turns[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식');
|
||||
expect(pulled.autorunLimit).toBe(2408);
|
||||
|
||||
await expect(setGeneralTurn(db, 1, 2, 'che_훈련', {}, 1)).rejects.toBeInstanceOf(
|
||||
ReservedTurnRevisionConflictError
|
||||
@@ -285,7 +290,7 @@ describe('reservedTurns', () => {
|
||||
});
|
||||
|
||||
it('repeats the leading general pattern at the legacy interval', async () => {
|
||||
const { db } = buildDb();
|
||||
const { db } = buildDb(2408);
|
||||
const seeded = await setGeneralTurns(
|
||||
db,
|
||||
4,
|
||||
@@ -309,6 +314,7 @@ describe('reservedTurns', () => {
|
||||
'che_사기진작',
|
||||
'che_징병',
|
||||
]);
|
||||
expect(repeated.autorunLimit).toBe(2408);
|
||||
});
|
||||
|
||||
it('supports nation bulk/repeat and preserves the legacy amount-12 no-op', async () => {
|
||||
@@ -339,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 () => ({}));
|
||||
@@ -367,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -869,7 +869,7 @@ describe('appRouter', () => {
|
||||
});
|
||||
|
||||
it('validates and persists general command arguments from the authenticated owner', async () => {
|
||||
const general = buildGeneralRow({ id: 13 });
|
||||
const general = buildGeneralRow({ id: 13, meta: { autorun_limit: 2408 } });
|
||||
const writes: unknown[] = [];
|
||||
const changeJournal = new ChangeJournal();
|
||||
const caller = appRouter.createCaller(
|
||||
@@ -885,6 +885,7 @@ describe('appRouter', () => {
|
||||
});
|
||||
|
||||
expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } });
|
||||
expect(response.autorunLimit).toBe(2408);
|
||||
expect(writes).toHaveLength(1);
|
||||
const written = writes[0] as { data: unknown[] };
|
||||
expect(written.data).toHaveLength(30);
|
||||
@@ -1020,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 () => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AVAILABLE_NATION_TRAIT_KEYS } from '@sammo-ts/logic';
|
||||
|
||||
import { loadBattleSimTraitOptions } from '../src/battleSim/simulatorOptions.js';
|
||||
|
||||
describe('selectable trait options', () => {
|
||||
it('uses the Ref available nation-type list for founding and battle simulation inputs', async () => {
|
||||
const options = await loadBattleSimTraitOptions();
|
||||
|
||||
expect(options.nationTypes.map((entry) => entry.key)).toEqual(AVAILABLE_NATION_TRAIT_KEYS);
|
||||
expect(options.nationTypes).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ key: 'che_중립' })])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user