fix: Ref 게임 로직과 시나리오 풀 호환을 보정

월 경계, 전투 기술 상한, 연감과 베팅·설문·경매 정산 순서를 Ref 계약에 맞춘다.\n\n시나리오 일반 풀을 ENGINE mutation과 logical tick 기반으로 직렬화하고 914·915 catalog 및 조건부 100기 pool 실행 경계를 추가한다.\n\n경매 worker는 세대별 durable event만 만들고 ENGINE이 row lock 후 상태 전이와 정산을 단일 transaction으로 소유한다.
This commit is contained in:
2026-08-23 16:26:14 +00:00
parent bf6b7be7b0
commit 85591c68ad
114 changed files with 13327 additions and 901 deletions
+21 -9
View File
@@ -43,6 +43,7 @@ interface AuctionRow {
detail: unknown;
status: string;
closeAt: Date;
closeTick: bigint | null;
}
export interface AuctionDetail {
@@ -55,6 +56,14 @@ export interface AuctionDetail {
remainCloseDateExtensionCnt?: number | null;
}
export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null },
time: { now: Date; tick: number | null }
): boolean =>
auction.closeTick !== null && time.tick !== null
? auction.closeTick < BigInt(time.tick)
: auction.closeAt.getTime() < time.now.getTime();
interface AuctionBidRow {
id: number;
generalId: number;
@@ -109,7 +118,8 @@ const loadAuction = async (db: DatabaseClient, auctionId: number): Promise<Aucti
host_general_id as "hostGeneralId",
detail,
status,
close_at as "closeAt"
close_at as "closeAt",
close_tick as "closeTick"
FROM auction
WHERE id = ${auctionId}
`
@@ -181,6 +191,8 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi
return myPrevBid;
};
const MIN_AUCTION_REMAINING_RESOURCE = 1_000;
export const auctionRouter = router({
getOverview: authedProcedure.query(async ({ ctx }) => {
const auth = requireAuth(ctx);
@@ -372,8 +384,7 @@ export const auctionRouter = router({
}
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
if (hasAuctionClosePassed(auction, gameTime)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
if (auction.hostGeneralId === general.id) {
@@ -407,7 +418,7 @@ export const auctionRouter = router({
if (morePoint <= 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰가가 유효하지 않습니다.' });
}
if (general.gold < morePoint) {
if (general.gold < morePoint + MIN_AUCTION_REMAINING_RESOURCE) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
}
@@ -417,6 +428,7 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
if (!result || result.type !== 'auctionBid') {
@@ -448,8 +460,7 @@ export const auctionRouter = router({
}
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
if (hasAuctionClosePassed(auction, gameTime)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
if (auction.hostGeneralId === general.id) {
@@ -483,7 +494,7 @@ export const auctionRouter = router({
if (morePoint <= 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰가가 유효하지 않습니다.' });
}
if (general.rice < morePoint) {
if (general.rice < morePoint + MIN_AUCTION_REMAINING_RESOURCE) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '쌀이 부족합니다.' });
}
@@ -493,6 +504,7 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
if (!result || result.type !== 'auctionBid') {
@@ -524,8 +536,7 @@ export const auctionRouter = router({
}
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
if (hasAuctionClosePassed(auction, gameTime)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
@@ -630,6 +641,7 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
});
if (!result || result.type !== 'auctionBid') {
+15 -6
View File
@@ -11,6 +11,7 @@ import {
WarTraitLoader,
WAR_TRAIT_KEYS,
isWarTraitKey,
isCentennialStatResetAllowed,
} from '@sammo-ts/logic';
import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
@@ -307,6 +308,7 @@ export const inheritRouter = router({
const resetTurnLevel = asNumber(asRecord(general.meta).inheritResetTurnTime, -1) + 1;
const config = asRecord(worldState.config);
const canResetStat = isCentennialStatResetAllowed(config);
const constValues = asRecord(config.const);
const availableSpecialWar = Array.isArray(constValues.availableSpecialWar)
? constValues.availableSpecialWar.filter((key): key is string => typeof key === 'string')
@@ -347,6 +349,7 @@ export const inheritRouter = router({
availableTargetGenerals: others,
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
isUnited,
canResetStat,
currentSpecialWar: general.special2Code ?? 'None',
currentStat: {
leadership: general.leadership,
@@ -700,12 +703,6 @@ export const inheritRouter = router({
});
}
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0;
if (currentPoint < cost) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const general = await ctx.db.general.findFirst({
where: { userId },
select: { id: true, npcState: true },
@@ -716,6 +713,18 @@ export const inheritRouter = router({
if (general.npcState >= 2) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'NPC는 능력치 초기화를 할 수 없습니다.' });
}
if (!isCentennialStatResetAllowed(config)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
});
}
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0;
if (currentPoint < cost) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const seasonValue = resolveSeasonValue(worldMeta);
if (seasonValue !== null) {
+40 -17
View File
@@ -16,11 +16,7 @@ import {
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import {
getSelectionPoolStatus,
reserveSelectionPool,
resolveSelectionMaxGeneral,
} from '@sammo-ts/game-engine/turn/selectPoolService.js';
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
import {
ConflictingTurnDaemonCommandError,
RejectedNpcPossessionCommandError,
@@ -54,6 +50,31 @@ const resolveSelectionCommandResult = (
return { ok: true, generalId: result.generalId };
};
const resolveSelectionReservationCommandResult = (
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null
) => {
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message:
'장수 선택 목록 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
});
}
if (result.type !== 'selectPoolReserve') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '턴 데몬이 올바르지 않은 장수 선택 목록 결과를 반환했습니다.',
});
}
if (!result.ok) {
throw new TRPCError({ code: result.code, message: result.reason });
}
return result.reservation;
};
const resolveSelectionReservationRequestId = (contextRequestId: string | undefined, userId: string) =>
contextRequestId ? `select-pool:${userId}:${contextRequestId}:reserve` : undefined;
const resolveSelectionRequestId = (
contextRequestId: string | undefined,
userId: string,
@@ -366,26 +387,22 @@ export const joinRouter = router({
},
};
}),
getSelectionPool: authedProcedure.mutation(async ({ ctx }) => {
getSelectionPool: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const gameTime = await loadCurrentGameTime(ctx.db);
return reserveSelectionPool({
db: ctx.db,
worldState,
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReserve',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
now: gameTime.now,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionReservationCommandResult(result);
}),
selectPoolGeneral: engineAuthedProcedure
.input(
@@ -413,6 +430,7 @@ export const joinRouter = router({
});
}
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}),
@@ -421,6 +439,8 @@ export const joinRouter = router({
uniqueName: input.uniqueName,
personality: input.personality,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
...(selectedIcon
? {
ownerPicture: selectedIcon.picture,
@@ -450,12 +470,15 @@ export const joinRouter = router({
input.clientRequestId,
'reselect'
);
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReselect',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionCommandResult(result, 'selectPoolReselect');
}),
+8 -3
View File
@@ -221,11 +221,14 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
};
};
const resolveCommandEnv = (config: Record<string, unknown>): { develCost: number; defaultCrewTypeId: number } => {
const resolveCommandEnv = (
config: Record<string, unknown>
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
const constValues = asRecord(config.const ?? config.consts);
return {
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
};
};
@@ -345,13 +348,14 @@ const buildZeroPolicy = async (
nationTech: number;
develCost: number;
defaultCrewTypeId: number;
maxTechLevel: number;
unitSetName: string;
}
): Promise<NationPolicy> => {
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, maxTechLevel, unitSetName } = options;
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0);
const techCost = getTechCost(nationTech);
const techCost = getTechCost(nationTech, maxTechLevel);
const next = clonePolicy(policy);
if (next.reqNPCDevelGold === 0) {
@@ -511,6 +515,7 @@ export const npcRouter = router({
nationTech,
develCost: env.develCost,
defaultCrewTypeId: env.defaultCrewTypeId,
maxTechLevel: env.maxTechLevel,
unitSetName,
});
+9 -6
View File
@@ -29,6 +29,13 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
return fallback;
};
const resolveCurrentDevelCost = (worldState: { config?: unknown; meta?: unknown } | null): number => {
const config = asRecord(worldState?.config ?? {});
const constValues = asRecord(config.const ?? config);
const configured = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
};
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
const roles = ctx.auth?.user.roles ?? [];
if (!hasAdminRole(roles, ctx.profile.name)) {
@@ -403,9 +410,7 @@ export const tournamentRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' });
}
const config = asRecord(worldState?.config ?? {});
const constValues = asRecord(config.const ?? config);
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
reason: 'tournamentJoin',
@@ -462,9 +467,7 @@ export const tournamentRouter = router({
const [participants, bets] = await Promise.all([store.getParticipants(), store.getBettingEntries()]);
const worldState = await ctx.db.worldState.findFirst();
const config = asRecord(worldState?.config ?? {});
const constValues = asRecord(config.const ?? config);
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
const develCost = resolveCurrentDevelCost(worldState);
const refundMap = new Map<number, number>();
for (const participant of participants) {
+11 -153
View File
@@ -1,20 +1,8 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import {
ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
loadItemModules,
resolveUniqueConfig,
rollUniqueLottery,
type GeneralItemSlots,
type ItemModule,
} from '@sammo-ts/logic';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -40,15 +28,6 @@ const adminProcedure = authedProcedure.use(({ ctx, next }) => {
return next();
});
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
const getItemRegistry = async (): Promise<Map<string, ItemModule>> => {
if (!itemRegistryPromise) {
itemRegistryPromise = loadItemModules([...ITEM_KEYS]).then((modules) => createItemModuleRegistry(modules));
}
return itemRegistryPromise;
};
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = source[key];
@@ -65,30 +44,9 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
return fallback;
};
const normalizeCode = (value: string | null | undefined): string | null => {
if (!value || value === 'None') {
return null;
}
return value;
};
const normalizeOptions = (options: string[]): string[] =>
options.map((option) => option.trim()).filter((option) => option.length > 0);
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.floor(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
}
}
return fallback;
};
const parseOptions = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string');
@@ -138,11 +96,14 @@ type VotePollRow = {
closed_at: Date | null;
};
const hasPollEnded = (poll: Pick<VotePollRow, 'closed_at' | 'end_at' | 'end_tick'>, time: CurrentGameTime): boolean =>
export const hasPollEnded = (
poll: Pick<VotePollRow, 'closed_at' | 'end_at' | 'end_tick'>,
time: CurrentGameTime
): boolean =>
Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null
? poll.end_tick <= BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at <= time.now));
? poll.end_tick < BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null;
@@ -388,116 +349,12 @@ export const voteRouter = router({
}
const general = await getMyGeneral(ctx);
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
INSERT INTO vote (vote_id, general_id, nation_id, selection)
VALUES (
${input.voteId},
${general.id},
${general.nationId},
CAST(${JSON.stringify(sortedSelection)} AS jsonb)
)
ON CONFLICT (vote_id, general_id) DO NOTHING
RETURNING id
`);
if (!rows[0]?.id) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 설문조사를 완료하였습니다.' });
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const worldMeta = asRecord(worldState.meta);
const config = asRecord(worldState.config);
const constValues = asRecord(config.const);
const develCost = resolveNumber(
worldMeta,
['develcost', 'develCost'],
resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0)
);
const voteReward = develCost * 5;
const scenarioMeta = asRecord(worldMeta.scenarioMeta);
const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear);
const initYear = readMetaNumber(worldMeta, 'initYear', startYear);
const initMonth = readMetaNumber(worldMeta, 'initMonth', 1);
const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0);
const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id;
const itemRegistry = await getItemRegistry();
const uniqueConfig = resolveUniqueConfig(constValues);
const [generalRows, reservedUniqueRows] = await Promise.all([
ctx.db.general.findMany({
select: {
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
},
}),
ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
}),
]);
const generalItems: GeneralItemSlots[] = generalRows.map((row) => ({
horse: normalizeCode(row.horseCode),
weapon: normalizeCode(row.weaponCode),
book: normalizeCode(row.bookCode),
item: normalizeCode(row.itemCode),
}));
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry);
addOccupiedUniqueItemKeys(
occupiedUniqueCounts,
reservedUniqueRows.map((row) => row.targetCode),
itemRegistry
);
const userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
const rngSeed = buildVoteUniqueSeed(
typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed),
input.voteId,
general.id
);
const rng = new RandUtil(LiteHashDRBG.build(rngSeed));
const itemKey = rollUniqueLottery({
rng,
config: uniqueConfig,
itemRegistry,
generalItems: {
horse: normalizeCode(general.horseCode),
weapon: normalizeCode(general.weaponCode),
book: normalizeCode(general.bookCode),
item: normalizeCode(general.itemCode),
},
occupiedUniqueCounts,
scenarioId,
userCount,
currentYear: worldState.currentYear,
currentMonth: worldState.currentMonth,
startYear,
initYear,
initMonth,
acquireType: '설문조사',
});
const rewardResult = await ctx.turnDaemon.requestCommand({
type: 'voteReward',
voteId: input.voteId,
generalId: general.id,
goldReward: voteReward,
unique: {
expected: Boolean(itemKey),
itemKey: itemKey ?? null,
},
selection: sortedSelection,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
if (!rewardResult || rewardResult.type !== 'voteReward') {
@@ -559,6 +416,7 @@ export const voteRouter = router({
)
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const openerName = ctx.auth?.user.username ?? general.name;
const options = normalizeOptions(input.options);
if (options.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' });
@@ -610,7 +468,7 @@ export const voteRouter = router({
${multipleOptions},
${input.revealMode},
${general.id},
${general.name},
${openerName},
${gameTime.now},
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt},
+30 -14
View File
@@ -45,6 +45,9 @@ const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
const parseTextArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
const readStoredSnapshotNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const parseYearbookNations = (value: unknown): YearbookNation[] => {
if (!Array.isArray(value)) {
return [];
@@ -114,6 +117,7 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
gold: true,
rice: true,
tech: true,
meta: true,
},
orderBy: { id: 'asc' },
}),
@@ -197,33 +201,45 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
generalStatsByNation.set(general.nationId, entry);
}
return nationRows.map<YearbookNation>((nation) => {
const projected = nationRows.map<YearbookNation>((nation) => {
const generalStats = generalStatsByNation.get(nation.id) ?? {
goldRice: 0,
statPower: 0,
expDed: 0,
generalCount: 0,
};
const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100);
const tech = nation.tech ?? 0;
const cityPower =
nation.level > 0 && cityStats.maxSum > 0
? Math.round((cityStats.popSum * cityStats.valueSum) / cityStats.maxSum / 100)
: 0;
const expDed = Math.round(generalStats.expDed / 100);
const power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10);
const nationMeta = asRecord(nation.meta);
const storedPower = readStoredSnapshotNumber(nationMeta.power);
let power = 1;
if (nation.id !== 0) {
if (storedPower !== null) {
power = storedPower;
} else {
const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100);
const tech = nation.tech ?? 0;
const cityPower =
nation.level > 0 && cityStats.maxSum > 0
? Math.round((cityStats.popSum * cityStats.valueSum) / cityStats.maxSum / 100)
: 0;
const expDed = Math.round(generalStats.expDed / 100);
power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10);
}
}
const storedGeneralCount = readStoredSnapshotNumber(nationMeta.gennum);
const generalCount = nation.id === 0 ? 1 : (storedGeneralCount ?? generalStats.generalCount);
return {
id: nation.id,
name: nation.name,
color: nation.color,
level: nation.level,
name: nation.id === 0 ? '재야' : nation.name,
color: nation.id === 0 ? '#000000' : nation.color,
level: nation.id === 0 ? 0 : nation.level,
power,
generalCount: generalStats.generalCount,
generalCount,
cities: cityNamesByNation.get(nation.id) ?? [],
};
});
return projected.sort((left, right) => right.power - left.power);
};
const readGlobalActionLogs = async (ctx: GameApiContext, year: number, month: number) => {