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
@@ -33,6 +33,7 @@ export const resolveConstraintEnv = (
develCost: env.develCost,
openingPartYear: env.openingPartYear,
minAvailableRecruitPop: env.minAvailableRecruitPop,
maxTechLevel: env.maxTechLevel,
...(Number.isFinite(killturn) ? { killturn } : {}),
};
};
@@ -115,7 +115,8 @@ export const resolveLegacyAiStatsWithModules = (
modules: TurnCommandEnv['generalActionModules'],
worldRef: AiWorldView | null,
world: TurnWorldState,
startYear: number
startYear: number,
maxTechLevel = 12
) => {
const maxLevel = Math.max(1, maxStatLevel);
const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel));
@@ -138,6 +139,7 @@ export const resolveLegacyAiStatsWithModules = (
month: world.currentMonth,
startYear,
},
maxTechLevel,
};
const rawStat = (statName: 'leadership' | 'strength' | 'intelligence'): number => general.stats[statName];
const calculate = (
@@ -699,7 +701,8 @@ export class GeneralAI {
this.commandEnv.generalActionModules,
this.worldRef,
this.world,
this.startYear
this.startYear,
this.commandEnv.maxTechLevel
);
}
return resolveLegacyAiStats(general, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL);
@@ -726,6 +729,7 @@ export class GeneralAI {
month: this.world.currentMonth,
startYear: this.startYear,
},
maxTechLevel: this.commandEnv.maxTechLevel,
},
'징집인구',
'score',
@@ -70,13 +70,14 @@ export const do금쌀구매 = (ai: GeneralAI) => {
}
: {}),
time: { year: ai.world.currentYear, month: ai.world.currentMonth, startYear: ai.startYear },
maxTechLevel: ai.commandEnv.maxTechLevel,
};
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
const goldCost = crewType
? recruitment.getCost(recruitContext, crewType.id, crewAmount, crewType).gold *
(ai.generalPolicy.can('모병') ? 2 : 1)
: 0;
const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0;
const riceCost = crewType ? (crewType.rice * getTechCost(tech, ai.commandEnv.maxTechLevel) * crewAmount) / 100 : 0;
trace('recruit-cost', {
crewTypeId: crewType?.id ?? null,
crewCost: crewType?.cost ?? null,
@@ -87,6 +87,7 @@ export const do징병 = (ai: GeneralAI) => {
month: ai.world.currentMonth,
startYear: ai.startYear,
},
maxTechLevel: ai.commandEnv.maxTechLevel,
};
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
// The cached AI stat follows the scenario/global classification cap, while
@@ -163,11 +164,17 @@ export const do징병 = (ai: GeneralAI) => {
return null;
}
let picked = ai.rng.choiceUsingWeightPair(
candidates.map((crew) => [crew, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)])
candidates.map((crew) => [
crew,
getCrewTypePickScore(crew, tech, warConfig.armPerPhase, ai.commandEnv.maxTechLevel),
])
);
trace('crew-type', {
armType,
candidates: candidates.map((crew) => [crew.id, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)]),
candidates: candidates.map((crew) => [
crew.id,
getCrewTypePickScore(crew, tech, warConfig.armPerPhase, ai.commandEnv.maxTechLevel),
]),
picked: picked.id,
});
if (ai.generalPolicy.can('고급병종')) {
@@ -203,7 +210,7 @@ export const do징병 = (ai: GeneralAI) => {
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
let riceCost = (picked.rice * getTechCost(tech) * expectedCrewLoss) / 100;
let riceCost = (picked.rice * getTechCost(tech, ai.commandEnv.maxTechLevel) * expectedCrewLoss) / 100;
const remainingGold = ai.general.gold - fullLeadership * 3;
const remainingRice = ai.general.rice - fullLeadership * 4;
@@ -56,7 +56,8 @@ const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, baseMultiplier: nu
// Keeping that operation order is observable at exact resource boundaries
// (for example 3036 versus 3036.0000000000005).
return (
(((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) *
(((crewType?.cost ?? 0) * getTechCost(tech, ai.commandEnv.maxTechLevel) * getFullLeadership(ai, general)) /
100) *
100 *
baseMultiplier *
finalMultiplier
+5 -8
View File
@@ -77,10 +77,7 @@ const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeat
};
/** NPC는 기존 계약을 유지하고, 사용자 군주는 명시적으로 고른 업무만 위임한다. */
export const canUseRulerAutomation = (
general: TurnGeneral,
feature: UserRulerAutomationFeature
): boolean => {
export const canUseRulerAutomation = (general: TurnGeneral, feature: UserRulerAutomationFeature): boolean => {
if (general.npcState >= 2) {
return true;
}
@@ -316,8 +313,8 @@ export class AutorunNationPolicy {
const stat = scenarioConfig.stat;
if (this.reqNpcWarGold === 0 || this.reqNpcWarRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.npcMax : 0;
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.npcMax : 0;
const baseGold = crewType ? crewType.cost * getTechCost(tech, env.maxTechLevel) * stat.npcMax : 0;
const baseRice = crewType ? crewType.rice * getTechCost(tech, env.maxTechLevel) * stat.npcMax : 0;
if (this.reqNpcWarGold === 0) {
this.reqNpcWarGold = roundTo(baseGold * 4, -2);
}
@@ -328,8 +325,8 @@ export class AutorunNationPolicy {
if (this.reqHumanWarUrgentGold === 0 || this.reqHumanWarUrgentRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.max : 0;
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.max : 0;
const baseGold = crewType ? crewType.cost * getTechCost(tech, env.maxTechLevel) * stat.max : 0;
const baseRice = crewType ? crewType.rice * getTechCost(tech, env.maxTechLevel) * stat.max : 0;
if (this.reqHumanWarUrgentGold === 0) {
this.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
}
+31 -7
View File
@@ -23,6 +23,7 @@ const parseWith = <T>(schema: z.ZodType<T>, value: unknown): T | null => {
};
const zFiniteNumber = z.number().finite();
const zSafeInteger = zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER);
const zRecord = z.record(z.string(), z.unknown());
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
@@ -35,6 +36,8 @@ const zTurnRunBudget = z.object({
const zAuctionFinalize = z.object({
type: z.literal('auctionFinalize'),
auctionId: zFiniteNumber,
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
expectedCloseTick: zSafeInteger.optional(),
});
const zAuctionOpen = z.object({
@@ -53,6 +56,7 @@ const zAuctionBid = z.object({
auctionId: zFiniteNumber,
generalId: zFiniteNumber,
amount: zFiniteNumber,
acceptedGameTick: zSafeInteger.optional(),
tryExtendCloseDate: z.boolean().optional(),
});
@@ -176,6 +180,7 @@ const zTournamentRefund = z.object({
const zTournamentBettingPayout = z.object({
type: z.literal('tournamentBettingPayout'),
bettingId: zFiniteNumber.optional(),
tournamentType: zFiniteNumber.optional(),
reason: z.string().optional(),
payouts: z.array(z.object({ generalId: zFiniteNumber, amount: zFiniteNumber })).min(1),
});
@@ -194,13 +199,8 @@ const zVoteReward = z.object({
type: z.literal('voteReward'),
voteId: zFiniteNumber,
generalId: zFiniteNumber,
goldReward: zFiniteNumber,
unique: z
.object({
expected: z.boolean(),
itemKey: z.string().nullable().optional(),
})
.optional(),
selection: z.array(zFiniteNumber.int()).min(1),
acceptedGameTick: zSafeInteger.optional(),
});
const zSetNationMeta = z.object({
@@ -329,6 +329,19 @@ const zSelectPoolCreate = z
ownerPicture: z.string().optional(),
ownerImageServer: z.number().int().nonnegative().optional(),
ownerIconRevision: z.string().refine(isCanonicalIsoTimestamp).optional(),
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
})
.strict();
const zSelectPoolReserve = z
.object({
type: z.literal('selectPoolReserve'),
requestId: z.string().optional(),
userId: z.string().min(1),
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp),
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
})
.strict();
@@ -339,6 +352,8 @@ const zSelectPoolReselect = z
userId: z.string().min(1),
ownerDisplayName: z.string().min(1),
uniqueName: z.string().min(1).max(20),
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
})
.strict();
@@ -660,6 +675,14 @@ const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelo
return { ...command, requestId: envelope.requestId };
};
const normalizeSelectPoolReserve: CommandNormalizer<'selectPoolReserve'> = (envelope) => {
const command = parseWith(zSelectPoolReserve, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeSelectPoolReselect: CommandNormalizer<'selectPoolReselect'> = (envelope) => {
const command = parseWith(zSelectPoolReselect, envelope.command);
if (!command) {
@@ -744,6 +767,7 @@ const normalizers: CommandNormalizerMap = {
adjustGeneralIcon: normalizeAdjustGeneralIcon,
joinCreateGeneral: normalizeJoinCreateGeneral,
npcPossessGeneral: normalizeNpcPossessGeneral,
selectPoolReserve: normalizeSelectPoolReserve,
selectPoolCreate: normalizeSelectPoolCreate,
selectPoolReselect: normalizeSelectPoolReselect,
getStatus: normalizeGetStatus,
+77
View File
@@ -24,6 +24,7 @@ import {
LogFormat,
LogScope,
sendMessage,
readScenarioGeneralPoolClaim,
type City,
type LogEntryDraft,
type MessageRecordDraft,
@@ -1192,6 +1193,21 @@ export const createDatabaseTurnHooks = async (
WHERE status = 'OPEN'
`
);
await prisma.$executeRaw(
GamePrisma.sql`
UPDATE select_pool
SET reserved_until_tick = CASE
WHEN reserved_until_tick IS NULL THEN NULL
ELSE reserved_until_tick + ${deltaTicks}
END,
reserved_until = CASE
WHEN reserved_until IS NULL THEN NULL
ELSE reserved_until + (${deltaSeconds} * INTERVAL '1 second')
END
WHERE general_id IS NULL
AND (reserved_until_tick IS NOT NULL OR reserved_until IS NOT NULL)
`
);
}
if (
@@ -1360,6 +1376,66 @@ export const createDatabaseTurnHooks = async (
createdDiplomacy.map((entry) => `${entry.fromNationId}:${entry.toNationId}`)
);
// Ref first claims a select_pool row and then finalizes it with the
// newly inserted general ID. Core computes the turn in memory, so
// perform the equivalent CAS in the same fenced flush transaction.
// Releasing deaths first also preserves same-batch pool reuse.
if (deletedGenerals.length > 0) {
await prisma.selectPoolEntry.updateMany({
where: { generalId: { in: deletedGenerals } },
data: {
generalId: null,
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
}
const createdGeneralPoolClaims = createdGenerals.flatMap((general) => {
const claim = readScenarioGeneralPoolClaim(general.meta);
return claim ? [{ general, claim }] : [];
});
const claimedPoolIds = new Set<number>();
for (const { general, claim } of createdGeneralPoolClaims) {
if (claimedPoolIds.has(claim.poolEntryId)) {
throw new Error(`한 flush에서 select_pool 후보 ${claim.poolEntryId}를 중복 점유할 수 없습니다.`);
}
claimedPoolIds.add(claim.poolEntryId);
const claimedAt = new Date(claim.claimedAt);
const claimedAtTick = world.dateToGameTick(claimedAt);
if (!Number.isSafeInteger(claimedAtTick)) {
throw new Error(
`select_pool 후보 점유 tick이 안전한 정수 범위를 벗어났습니다: ${claim.uniqueName}`
);
}
const occupied = await prisma.selectPoolEntry.updateMany({
where: {
id: claim.poolEntryId,
uniqueName: claim.uniqueName,
OR: [
{ generalId: general.id },
{
generalId: null,
OR: [
{ ownerUserId: null, reservedUntil: null, reservedUntilTick: null },
{ reservedUntilTick: { lt: BigInt(claimedAtTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: claimedAt } },
],
},
],
},
data: {
generalId: general.id,
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
if (occupied.count !== 1) {
throw new Error(`select_pool 후보를 점유하지 못했습니다: ${claim.uniqueName}`);
}
}
if (createdGenerals.length > 0) {
await prisma.general.createMany({
data: createdGenerals.map(buildGeneralCreate),
@@ -1426,6 +1502,7 @@ export const createDatabaseTurnHooks = async (
generalId: null,
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
if (prisma.generalTurnRevision) {
+113 -4
View File
@@ -4,11 +4,12 @@ import type {
MessageDraft,
Nation,
ScenarioConfig,
ScenarioGeneralPoolCandidate,
Troop,
TurnSchedule,
UnitSetDefinition,
} from '@sammo-ts/logic';
import { getNextTurnAt } from '@sammo-ts/logic';
import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic';
import { GAME_TICKS_PER_TURN, GameClock, type GameClockMode } from '@sammo-ts/common';
import type { TurnCheckpoint } from '../lifecycle/types.js';
@@ -21,6 +22,7 @@ import type {
TurnDiplomacy,
TurnEvent,
TurnGeneral,
TurnGeneralPoolEntry,
TurnWorldSnapshot,
TurnWorldState,
} from './types.js';
@@ -154,6 +156,7 @@ export interface InMemoryTurnWorldStateSnapshot {
schedule: TurnSchedule;
state: TurnWorldState;
worldConfig: Record<string, unknown>;
generalPoolEntries: TurnWorldSnapshot['generalPoolEntries'];
checkpoint?: TurnCheckpoint;
generals: Array<[number, TurnGeneral]>;
cities: Array<[number, City]>;
@@ -491,6 +494,7 @@ export class InMemoryTurnWorld {
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
private pendingRealtimeBacklogShiftTicks = 0;
private readonly scenarioConfig: ScenarioConfig;
private generalPoolEntries: TurnWorldSnapshot['generalPoolEntries'];
private readonly worldConfig: Record<string, unknown>;
private readonly unitSet?: UnitSetDefinition;
private checkpoint?: TurnCheckpoint;
@@ -528,6 +532,13 @@ export class InMemoryTurnWorld {
meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() },
};
this.scenarioConfig = snapshot.scenarioConfig;
this.generalPoolEntries = snapshot.generalPoolEntries
? snapshot.generalPoolEntries.map((entry) => ({
...entry,
reservedUntil: entry.reservedUntil ? new Date(entry.reservedUntil.getTime()) : null,
candidate: structuredClone(entry.candidate),
}))
: undefined;
// Runtime callbacks created before the world keep the original object
// reference. Mutate this object in place so a live settings action is
// observed by monthly handlers without restarting the daemon.
@@ -712,6 +723,14 @@ export class InMemoryTurnWorld {
turnTime: clock.tickToDate(turnTick),
});
}
for (const entry of this.generalPoolEntries ?? []) {
if (entry.reservedUntilTick !== null) {
entry.reservedUntilTick = clock.addTicks(entry.reservedUntilTick, shiftedTicks);
entry.reservedUntil = clock.tickToDate(entry.reservedUntilTick);
} else if (entry.reservedUntil) {
entry.reservedUntil = new Date(entry.reservedUntil.getTime() + shiftedMilliseconds);
}
}
if (this.checkpoint) {
const checkpointTick = clock.addTicks(
this.checkpoint.turnTick ?? clock.dateToTick(new Date(this.checkpoint.turnTime)),
@@ -738,6 +757,7 @@ export class InMemoryTurnWorld {
schedule: this.schedule,
state: this.state,
worldConfig: this.worldConfig,
generalPoolEntries: this.generalPoolEntries,
checkpoint: this.checkpoint,
generals: Array.from(this.generals.entries()),
cities: Array.from(this.cities.entries()),
@@ -782,6 +802,7 @@ export class InMemoryTurnWorld {
delete this.worldConfig[key];
}
Object.assign(this.worldConfig, restored.worldConfig);
this.generalPoolEntries = restored.generalPoolEntries;
this.checkpoint = restored.checkpoint;
this.replaceMap(this.generals, restored.generals);
this.replaceMap(this.cities, restored.cities);
@@ -1036,6 +1057,80 @@ export class InMemoryTurnWorld {
}));
}
listGeneralPoolEntries(): TurnGeneralPoolEntry[] | undefined {
return this.generalPoolEntries?.map((entry) => ({
...entry,
reservedUntil: entry.reservedUntil ? new Date(entry.reservedUntil.getTime()) : null,
candidate: structuredClone(entry.candidate),
}));
}
replaceGeneralPoolEntries(entries: readonly TurnGeneralPoolEntry[]): void {
this.generalPoolEntries = entries.map((entry) => ({
...entry,
reservedUntil: entry.reservedUntil ? new Date(entry.reservedUntil.getTime()) : null,
candidate: structuredClone(entry.candidate),
}));
}
listGeneralPoolCandidates(
claimedAt: Date,
claimedAtTick = this.dateToGameTick(claimedAt)
): ScenarioGeneralPoolCandidate[] | undefined {
if (!this.generalPoolEntries) {
return undefined;
}
if (!Number.isSafeInteger(claimedAtTick)) {
throw new Error(`General-pool claim tick must be a safe integer: ${claimedAtTick}`);
}
const claimsByEntryId = new Map<number, number>();
const currentNames = new Set<string>();
const prefixes: Partial<Record<number, string>> = {
1: 'ⓝ',
2: 'ⓝ',
3: 'ⓜ',
4: 'ⓖ',
5: '㉥',
6: 'ⓤ',
9: 'ⓞ',
};
for (const general of this.generals.values()) {
const claim = readScenarioGeneralPoolClaim(general.meta);
if (claim) {
claimsByEntryId.set(claim.poolEntryId, general.id);
}
const prefix = prefixes[general.npcState];
currentNames.add(
prefix && general.name.startsWith(prefix) ? general.name.slice(prefix.length) : general.name
);
}
return this.generalPoolEntries
.filter((entry) => {
const linkedGeneralCanBeReused =
entry.generalId === null || this.deletedGeneralIds.has(entry.generalId);
if (
!linkedGeneralCanBeReused ||
claimsByEntryId.has(entry.id) ||
currentNames.has(entry.uniqueName) ||
currentNames.has(entry.candidate.name)
) {
return false;
}
const isUnreserved =
entry.ownerUserId === null && entry.reservedUntil === null && entry.reservedUntilTick === null;
// Ref compares integer GameClock ticks and keeps a reservation
// valid at exact equality. Legacy rows without a tick fall
// back to the projected Date column.
const isExpired =
entry.reservedUntilTick !== null
? entry.reservedUntilTick < claimedAtTick
: entry.reservedUntil !== null && entry.reservedUntil.getTime() < claimedAt.getTime();
return isUnreserved || isExpired;
})
.map((entry) => structuredClone(entry.candidate));
}
listCities(): City[] {
return Array.from(this.cities.values()).map((city) => ({ ...city }));
}
@@ -1346,6 +1441,11 @@ export class InMemoryTurnWorld {
for (const auction of this.pendingNeutralAuctions) {
auction.closeAt = shiftDate(auction.closeAt);
}
for (const entry of this.generalPoolEntries ?? []) {
if (entry.reservedUntil) {
entry.reservedUntil = shiftDate(entry.reservedUntil);
}
}
if (this.checkpoint) {
const checkpointTime = shiftDate(new Date(this.checkpoint.turnTime));
this.checkpoint = {
@@ -1441,6 +1541,8 @@ export class InMemoryTurnWorld {
executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution {
const currentGeneral = this.generals.get(general.id) ?? general;
const executionYear = this.state.currentYear;
const executionMonth = this.state.currentMonth;
const city = this.cities.get(currentGeneral.cityId);
const nation = currentGeneral.nationId > 0 ? (this.nations.get(currentGeneral.nationId) ?? null) : null;
@@ -1490,10 +1592,17 @@ export class InMemoryTurnWorld {
}
if (result.logs && result.logs.length > 0) {
// Ref command logs use the executing general's pre-advance turntime.
// Preserve that per-entry occurrence time instead of replacing every
// log in the transaction with the shared completion cursor at flush.
// Preserve that per-entry occurrence time and calendar date instead
// of replacing them with the shared post-boundary flush context.
for (const log of result.logs) {
this.pushLog(log, currentGeneral.turnTime);
this.pushLog(
{
...log,
year: log.year ?? executionYear,
month: log.month ?? executionMonth,
},
currentGeneral.turnTime
);
}
}
if (result.messages && result.messages.length > 0) {
@@ -135,7 +135,10 @@ const resolveInheritConstants = (worldState: WorldStateRow): InheritConstants =>
const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => {
const config = asRecord(worldState.config);
const map = asRecord(config.map);
return asNumber(config.npcMode, 0) === 2 && map.targetGeneralPool === 'SPoolUnderU30';
return (
asNumber(config.npcMode, 0) === 2 &&
(map.targetGeneralPool === 'SPoolUnderU30' || map.targetGeneralPool === 'SPoolUnderU100')
);
};
const resolveMaxGeneral = (worldState: WorldStateRow): number => {
@@ -0,0 +1,98 @@
import { asNumber, asRecord } from '@sammo-ts/common';
import {
CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT,
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
CENTENNIAL_ALL_STAR_POOL,
LogCategory,
LogFormat,
LogScope,
applyCentennialAllStarTarget,
readCentennialAllStarAux,
type CentennialAllStarRules,
} from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { MonthlyEventActionHandler } from './monthlyEventHandler.js';
const resolveRules = (world: InMemoryTurnWorld): CentennialAllStarRules => {
const scenario = world.getScenarioConfig();
const configConst = asRecord(scenario.const);
return {
defaultStatMin: scenario.stat.min,
defaultStatMax: scenario.stat.max,
defaultStatTotal: scenario.stat.total,
maxStatLevel: asNumber(configConst.maxLevel, 255),
defaultSpecialDomestic:
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None',
dexLimit: asNumber(configConst.dexLimit, CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT),
};
};
const resolveNpcDexTargetRatio = (world: InMemoryTurnWorld): number => {
const value = asNumber(asRecord(world.getScenarioConfig().map).centennialNpcDexTargetRatio, 0.4);
if (value < 0 || value > 1) {
throw new Error('centennialNpcDexTargetRatio must be between 0 and 1');
}
return value;
};
export const createAdvanceCentennialAllStarHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
}): MonthlyEventActionHandler => {
return (_args, environment) => {
const world = options.getWorld();
if (!world || asRecord(world.getScenarioConfig().map).targetGeneralPool !== CENTENNIAL_ALL_STAR_POOL) {
return;
}
const rules = resolveRules(world);
const npcDexTargetRatio = resolveNpcDexTargetRatio(world);
for (const general of world.listGenerals()) {
const aux = readCentennialAllStarAux(general.meta as Record<string, unknown>);
if (!aux) {
continue;
}
const isGeneratedNpc = general.npcState === 3 || general.npcState === 4;
const result = applyCentennialAllStarTarget(
general,
aux.target,
{
startYear: environment.startyear,
year: environment.year,
month: environment.month,
},
rules,
isGeneratedNpc ? CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER : 1,
isGeneratedNpc ? npcDexTargetRatio : 1
);
// progressMonth 같은 aux 필드도 월마다 영속화해야 하므로 실제 수치
// 변화가 없는 경우에도 Ref General::applyDB()와 같이 dirty 처리한다.
world.updateGeneral(general.id, {
stats: result.stats,
role: result.role,
meta: result.meta,
});
if (result.milestone <= result.previousMilestone) {
continue;
}
const percent = result.milestone * 20;
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: general.id,
text: `<L>올스타 동조율</>이 <C>${percent}%</>에 도달했습니다!`,
format: LogFormat.PLAIN,
year: environment.year,
month: environment.month,
});
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: general.id,
text: `<L>올스타 동조율 ${percent}% 달성</>`,
format: LogFormat.YEAR_MONTH,
year: environment.year,
month: environment.month,
});
}
};
};
@@ -1,5 +1,19 @@
import { GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common';
import { LogCategory, LogFormat, LogScope, type TurnCommandEnv } from '@sammo-ts/logic';
import {
buildScenarioGeneralPoolClaimMeta,
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
LogCategory,
LogFormat,
LogScope,
applyCentennialAllStarTarget,
initializeCentennialGeneratedNpc,
pickUniqueScenarioGeneralPoolCandidates,
readCentennialAllStarPoolTarget,
resolveCentennialAllStarRules,
resolveCentennialNpcDexTargetRatio,
type ScenarioGeneralPoolCandidate,
type TurnCommandEnv,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -113,19 +127,21 @@ const buildNpc = (options: {
environment: MonthlyEventEnvironment;
env: TurnCommandEnv;
baseName: string;
candidate?: ScenarioGeneralPoolCandidate;
}): TurnGeneral => {
const { world, reservedTurns, rng, environment, env } = options;
const centennialTarget = readCentennialAllStarPoolTarget(options.candidate);
const age = rng.nextRangeInt(20, 25);
const bornYear = environment.year - age;
const deadYear = environment.year + rng.nextRangeInt(10, 50);
const stats = buildNpcStats(rng, env);
const affinity = rng.nextRangeInt(1, 150);
const stats = centennialTarget ? buildNpcStats(rng, env) : (options.candidate?.stats ?? buildNpcStats(rng, env));
const affinity = options.candidate?.affinity ?? rng.nextRangeInt(1, 150);
const relativeYear = Math.max(environment.year - environment.startyear, 0);
const configValues = asRecord(world.getScenarioConfig().const);
const retirementYear = readLegacyNumber(configValues.retirementYear, 80);
const specAge = buildSpecialityAge(retirementYear, age, relativeYear, 12);
const specAge2 = buildSpecialityAge(retirementYear, age, relativeYear, 6);
const personality = rng.choice(env.availablePersonalities ?? ['che_안전']);
const personality = options.candidate?.personality ?? rng.choice(env.availablePersonalities ?? ['che_안전']);
const cities = world.listCities();
if (cities.length === 0) {
throw new Error('CreateManyNPC requires at least one city.');
@@ -145,7 +161,7 @@ const buildNpc = (options: {
const turnTime = world.gameTickToDate(turnTick);
const killturn = (deadYear - environment.year) * 12 + rng.nextRangeInt(0, 11) + environment.month - 1;
const id = world.getNextGeneralId();
const general: TurnGeneral = {
let general: TurnGeneral = {
id,
userId: null,
name: `${NPC_NAME_PREFIX}${options.baseName}`,
@@ -158,8 +174,12 @@ const buildNpc = (options: {
officerLevel: 0,
role: {
personality,
specialDomestic: env.defaultSpecialDomestic,
specialWar: env.defaultSpecialWar,
specialDomestic: centennialTarget
? env.defaultSpecialDomestic
: (options.candidate?.specialDomestic ?? env.defaultSpecialDomestic),
specialWar: centennialTarget
? env.defaultSpecialWar
: (options.candidate?.specialWar ?? env.defaultSpecialWar),
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
@@ -174,7 +194,8 @@ const buildNpc = (options: {
bornYear,
deadYear,
affinity,
picture: 'default.jpg',
picture: typeof options.candidate?.picture === 'string' ? options.candidate.picture : 'default.jpg',
imageServer: options.candidate?.imageServer ?? 0,
triggerState: {
flags: {},
counters: {},
@@ -193,13 +214,32 @@ const buildNpc = (options: {
dedlevel: 1,
specage: specAge,
specage2: specAge2,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
dex1: centennialTarget ? 0 : (options.candidate?.dex?.[0] ?? 0),
dex2: centennialTarget ? 0 : (options.candidate?.dex?.[1] ?? 0),
dex3: centennialTarget ? 0 : (options.candidate?.dex?.[2] ?? 0),
dex4: centennialTarget ? 0 : (options.candidate?.dex?.[3] ?? 0),
dex5: centennialTarget ? 0 : (options.candidate?.dex?.[4] ?? 0),
...(options.candidate ? buildScenarioGeneralPoolClaimMeta(options.candidate, environment.turnTime) : {}),
},
};
if (centennialTarget) {
const scenario = world.getScenarioConfig();
const rules = resolveCentennialAllStarRules(scenario, env.defaultSpecialDomestic);
const initialized = initializeCentennialGeneratedNpc(general, centennialTarget, rules);
const growth = applyCentennialAllStarTarget(
{ ...general, ...initialized },
centennialTarget,
{
startYear: environment.startyear,
year: environment.year,
month: environment.month,
},
rules,
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
resolveCentennialNpcDexTargetRatio(scenario)
);
general = { ...general, stats: growth.stats, role: growth.role, meta: growth.meta };
}
if (!world.addGeneral(general)) {
throw new Error(`CreateManyNPC generated a duplicate general id: ${id}`);
}
@@ -241,8 +281,15 @@ export const createCreateManyNpcHandler = (options: {
simpleSerialize(resolveHiddenSeed(world), 'CreateManyNPC', environment.year, environment.month)
)
);
const baseNames = pickNpcNames(rng, requestedCount, world.listGenerals(), options.env);
const created = baseNames.map((baseName) =>
const generalPool = world.listGeneralPoolCandidates(environment.turnTime);
const candidates: Array<{ baseName: string; candidate?: ScenarioGeneralPoolCandidate }> =
generalPool === undefined
? pickNpcNames(rng, requestedCount, world.listGenerals(), options.env).map((baseName) => ({ baseName }))
: pickUniqueScenarioGeneralPoolCandidates(rng, generalPool, requestedCount).map((candidate) => ({
baseName: candidate.name,
candidate,
}));
const created = candidates.map(({ baseName, candidate }) =>
buildNpc({
world,
reservedTurns: options.reservedTurns,
@@ -250,6 +297,7 @@ export const createCreateManyNpcHandler = (options: {
environment,
env: options.env,
baseName,
...(candidate ? { candidate } : {}),
})
);
@@ -32,6 +32,7 @@ export const MONTHLY_EVENT_ACTION_CATALOG = [
'ProcessSemiAnnual',
'ProcessWarIncome',
'CreateAdminNPC',
'AdvanceCentennialAllStar',
'CreateManyNPC',
'RegNPC',
'RegNeutralNPC',
@@ -233,8 +234,7 @@ export const createMonthlyEventHandler = (options: {
// the previous monthly boundary even after turnDate() has advanced
// year/month. Generated general turn times depend on this distinction.
const legacyTurnTime =
context.legacyTurnTime ??
new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000);
context.legacyTurnTime ?? new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000);
for (const event of world.listEvents(targetCode)) {
const environment: MonthlyEventEnvironment = {
@@ -40,7 +40,11 @@ export const createOpenNationBettingHandler = (options: {
.filter((nation) => nation.id > 0)
.sort((left, right) => right.power - left.power)
.map((nation) => {
const generalCount = generals.filter((general) => general.nationId === nation.id).length;
const storedGeneralCount = nation.meta.gennum;
const generalCount =
typeof storedGeneralCount === 'number' && Number.isFinite(storedGeneralCount)
? storedGeneralCount
: generals.filter((general) => general.nationId === nation.id && general.npcState !== 5).length;
const cityCount = cities.filter((city) => city.nationId === nation.id).length;
return {
title: nation.name,
@@ -150,7 +154,7 @@ export const createFinishNationBettingHandler = (options: {
id: bettingId,
winnerNationIds: world
.listNations()
.filter((nation) => nation.level > 0)
.filter((nation) => nation.id > 0 && nation.level > 0)
.map((nation) => nation.id),
year: environment.year,
month: environment.month,
@@ -4,10 +4,17 @@ import {
LogFormat,
LogScope,
AVAILABLE_NATION_TRAIT_KEYS,
CENTENNIAL_ALL_STAR_AUX_KEY,
getCityDistance,
buildScenarioGeneralPoolClaimMeta,
initialCentennialAllStarAux,
pickUniqueScenarioGeneralPoolCandidates,
readCentennialAllStarPoolTarget,
resolveCentennialAllStarRules,
type City,
type MapDefinition,
type Nation,
type ScenarioGeneralPoolCandidate,
type TurnCommandEnv,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
@@ -126,8 +133,10 @@ const createNpcGeneral = (options: {
bornYear: number;
deadYear: number;
killturn?: number;
candidate?: ScenarioGeneralPoolCandidate;
}): TurnGeneral => {
const { world, reservedTurns, rng, env, environment } = options;
const centennialTarget = readCentennialAllStarPoolTarget(options.candidate);
const stats = buildNpcStats(rng, env, STAT_TYPE_WEIGHTS);
const affinity = rng.nextRangeInt(1, 150);
const personality = rng.choice(env.availablePersonalities ?? ['che_안전']);
@@ -152,12 +161,9 @@ const createNpcGeneral = (options: {
const turnTime = world.gameTickToDate(turnTick);
const killturn =
options.killturn ??
(options.deadYear - environment.year) * 12 +
rng.nextRangeInt(0, 11) +
environment.month -
1;
(options.deadYear - environment.year) * 12 + rng.nextRangeInt(0, 11) + environment.month - 1;
const id = world.getNextGeneralId();
const general: TurnGeneral = {
let general: TurnGeneral = {
id,
userId: null,
name: `${NPC_PREFIX}${options.baseName}`,
@@ -170,8 +176,12 @@ const createNpcGeneral = (options: {
officerLevel: options.officerLevel,
role: {
personality,
specialDomestic: env.defaultSpecialDomestic,
specialWar: env.defaultSpecialWar,
specialDomestic: centennialTarget
? env.defaultSpecialDomestic
: (options.candidate?.specialDomestic ?? env.defaultSpecialDomestic),
specialWar: centennialTarget
? env.defaultSpecialWar
: (options.candidate?.specialWar ?? env.defaultSpecialWar),
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
@@ -186,7 +196,8 @@ const createNpcGeneral = (options: {
bornYear: options.bornYear,
deadYear: options.deadYear,
affinity,
picture: 'default.jpg',
picture: typeof options.candidate?.picture === 'string' ? options.candidate.picture : 'default.jpg',
imageServer: options.candidate?.imageServer ?? 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
lastTurn: { command: '휴식' },
turnTime,
@@ -200,13 +211,23 @@ const createNpcGeneral = (options: {
dedlevel: 1,
specage: buildSpecialityAge(retirementYear, age, relativeYear, 12),
specage2: buildSpecialityAge(retirementYear, age, relativeYear, 6),
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
dex1: centennialTarget ? 0 : (options.candidate?.dex?.[0] ?? 0),
dex2: centennialTarget ? 0 : (options.candidate?.dex?.[1] ?? 0),
dex3: centennialTarget ? 0 : (options.candidate?.dex?.[2] ?? 0),
dex4: centennialTarget ? 0 : (options.candidate?.dex?.[3] ?? 0),
dex5: centennialTarget ? 0 : (options.candidate?.dex?.[4] ?? 0),
...(options.candidate ? buildScenarioGeneralPoolClaimMeta(options.candidate, environment.turnTime) : {}),
},
};
if (centennialTarget) {
const meta: TurnGeneral['meta'] = { ...general.meta };
const mutableMeta: Record<string, unknown> = meta;
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = initialCentennialAllStarAux(
centennialTarget,
resolveCentennialAllStarRules(world.getScenarioConfig(), env.defaultSpecialDomestic)
);
general = { ...general, meta };
}
if (!world.addGeneral(general)) {
throw new Error(`RaiseNPCNation generated duplicate general id ${id}.`);
}
@@ -268,8 +289,7 @@ export const createRaiseNpcNationHandler = (options: {
);
const currentLast = world.getState().meta.lastNationId;
const currentLastNumber =
typeof currentLast === 'number' && Number.isFinite(currentLast) ? currentLast : 0;
const currentLastNumber = typeof currentLast === 'number' && Number.isFinite(currentLast) ? currentLast : 0;
const liveNationMax = world.listNations().reduce((maxId, nation) => Math.max(maxId, nation.id), 0);
let resolvedLastNationId = Math.max(currentLastNumber, liveNationMax);
const serverId = resolveServerId(world);
@@ -342,17 +362,22 @@ export const createRaiseNpcNationHandler = (options: {
deadYear: environment.year + 60,
killturn: 240,
});
const subordinateNames = pickNpcNames(
rng,
Math.max(averageGeneralCount - 1, 0),
world.listGenerals(),
options.env
);
for (const baseName of subordinateNames) {
const deadYear =
environment.year +
10 +
Math.trunc(60 * (1 - Math.log2(rng.nextRange(1, 1024)) / 10));
const subordinateCount = Math.max(averageGeneralCount - 1, 0);
const generalPool = world.listGeneralPoolCandidates(environment.turnTime);
const subordinateCandidates: Array<{
baseName: string;
candidate?: ScenarioGeneralPoolCandidate;
}> =
generalPool === undefined
? pickNpcNames(rng, subordinateCount, world.listGenerals(), options.env).map((baseName) => ({
baseName,
}))
: pickUniqueScenarioGeneralPoolCandidates(rng, generalPool, subordinateCount).map((candidate) => ({
baseName: candidate.name,
candidate,
}));
for (const { baseName, candidate } of subordinateCandidates) {
const deadYear = environment.year + 10 + Math.trunc(60 * (1 - Math.log2(rng.nextRange(1, 1024)) / 10));
createNpcGeneral({
world,
reservedTurns: options.reservedTurns,
@@ -365,11 +390,12 @@ export const createRaiseNpcNationHandler = (options: {
officerLevel: 1,
bornYear: environment.year - 20,
deadYear,
...(candidate ? { candidate } : {}),
});
}
world.updateNation(nationId, {
chiefGeneralId: ruler.id,
meta: { ...nation.meta, gennum: 1 + subordinateNames.length },
meta: { ...nation.meta, gennum: 1 + subordinateCandidates.length },
});
options.reservedTurns.ensureNationTurns(nationId, 12);
options.reservedTurns.ensureNationTurns(nationId, 11);
@@ -31,6 +31,7 @@ import {
createItemModuleRegistry,
loadItemModules,
resolveUniqueConfig,
readScenarioGeneralPoolClaim,
rollUniqueLottery,
getNextTurnAt,
getBillByLevel,
@@ -509,6 +510,7 @@ type WorldView = {
listNations(): Nation[];
listTroops(): Troop[];
listDiplomacy(): TurnDiplomacy[];
listGeneralPoolCandidates(claimedAt: Date): ReturnType<InMemoryTurnWorld['listGeneralPoolCandidates']>;
};
const mergeStats = (base: TurnGeneral['stats'], patch: Partial<TurnGeneral['stats']>): TurnGeneral['stats'] => ({
@@ -614,6 +616,20 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => {
...entry,
meta: { ...entry.meta },
})),
listGeneralPoolCandidates: (claimedAt) => {
const candidates = world.listGeneralPoolCandidates(claimedAt);
if (!candidates) {
return candidates;
}
const overlayClaimedIds = new Set<number>();
for (const general of generalOverrides.values()) {
const claim = readScenarioGeneralPoolClaim(general.meta);
if (claim) {
overlayClaimedIds.add(claim.poolEntryId);
}
}
return candidates.filter((candidate) => !overlayClaimedIds.has(candidate.poolEntryId));
},
};
return {
@@ -1080,6 +1096,7 @@ export const createReservedTurnHandler = async (options: {
: {}),
rng: actionRng,
time: actionTime,
maxTechLevel: env.maxTechLevel,
uniqueLottery,
};
let specificContext = buildActionContext(
@@ -1113,6 +1130,7 @@ export const createReservedTurnHandler = async (options: {
nation: currentNation,
rng: actionRng,
time: actionTime,
maxTechLevel: env.maxTechLevel,
};
specificContext = baseContext;
}
@@ -2418,6 +2436,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
month: state.currentMonth,
startYear,
},
maxTechLevel: env.maxTechLevel,
uniqueLottery,
};
const actionContext =
+357 -122
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
import {
EventDomesticTraitLoader,
@@ -10,14 +10,25 @@ import {
LogCategory,
LogScope,
PERSONALITY_TRAIT_KEYS,
CENTENNIAL_ALL_STAR_AUX_KEY,
CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT,
CENTENNIAL_ALL_STAR_POOL,
applyCentennialAllStarTarget,
calculateCentennialUserCurrentTargetStats,
calculateCentennialUserInitialStats,
buildScenarioGeneralPoolClaimMeta,
initialCentennialAllStarAux,
parseScenarioGeneralPoolCandidate,
prepareCentennialLegacyUserReselection,
simpleSerialize,
WarTraitLoader,
} from '@sammo-ts/logic';
import type { CentennialAllStarEnvironment, CentennialAllStarRules, CentennialAllStarTarget } from '@sammo-ts/logic';
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { buildPrestartDeleteAfter } from './prestartDeletion.js';
import type { TurnGeneral } from './types.js';
import type { TurnGeneral, TurnGeneralPoolEntry } from './types.js';
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
@@ -33,7 +44,8 @@ export class SelectPoolError extends Error {
}
}
const SUPPORTED_POOL = 'SPoolUnderU30';
const LEGACY_SELECTION_POOL = 'SPoolUnderU30';
const SUPPORTED_POOLS = new Set([LEGACY_SELECTION_POOL, CENTENNIAL_ALL_STAR_POOL]);
const RESERVATION_COUNT = 14;
const RESERVATION_TURN_MULTIPLIER = 2;
const RESELECTION_TURN_MULTIPLIER = 12;
@@ -41,7 +53,6 @@ const DEFAULT_MAX_GENERAL = 500;
const DEFAULT_CREW_TYPE_ID = 1100;
const MAX_GENERAL_TURNS = 30;
const DEFAULT_TURN_ACTION = '휴식';
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
const zCandidateInfo = z.object({
uniqueName: z.string().min(1),
@@ -49,7 +60,7 @@ const zCandidateInfo = z.object({
leadership: z.number().int(),
strength: z.number().int(),
intel: z.number().int(),
specialDomestic: z.string().min(1),
specialDomestic: z.string().min(1).nullable(),
specialWar: z.string().min(1).optional(),
ego: z.string().min(1).optional(),
experience: z.number().int().optional(),
@@ -67,6 +78,7 @@ interface SelectPoolRow {
ownerUserId: string | null;
generalId: number | null;
reservedUntil: Date | null;
reservedUntilTick: bigint | null;
info: unknown;
}
@@ -76,8 +88,8 @@ export interface SelectPoolCandidateDto {
leadership: number;
strength: number;
intel: number;
specialDomestic: string;
specialDomesticName: string;
specialDomestic: string | null;
specialDomesticName: string | null;
specialDomesticInfo: string;
specialWar: string | null;
specialWarName: string | null;
@@ -89,7 +101,7 @@ export interface SelectPoolCandidateDto {
}
export interface SelectPoolReservationDto {
poolName: typeof SUPPORTED_POOL;
poolName: string;
hasGeneral: boolean;
validUntil: string;
candidates: SelectPoolCandidateDto[];
@@ -120,7 +132,8 @@ const resolveTurnTermMinutes = (worldState: WorldStateRow): number => {
export const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => {
const config = asRecord(worldState.config);
return asNumber(config.npcMode, 0) === 2 && resolvePoolName(worldState) === SUPPORTED_POOL;
const poolName = resolvePoolName(worldState);
return asNumber(config.npcMode, 0) === 2 && poolName !== null && SUPPORTED_POOLS.has(poolName);
};
export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number => {
@@ -158,16 +171,61 @@ const parseCandidate = (row: Pick<SelectPoolRow, 'uniqueName' | 'info'>): Select
return candidate;
};
const candidateWeight = (candidate: SelectPoolCandidateInfo): number =>
candidate.dex.reduce((sum, value) => sum + value, 0);
export const calculateSelectionCandidateWeight = (
poolName: string,
candidate: SelectPoolCandidateInfo,
ownerIsUser: boolean
): number => {
const dexWeight = candidate.dex.reduce((sum, value) => sum + value, 0);
if (poolName !== CENTENNIAL_ALL_STAR_POOL) {
return dexWeight;
}
const eligibleDexWeight = Math.max(100_000, dexWeight);
if (!ownerIsUser) {
return eligibleDexWeight;
}
const statTotal = candidate.leadership + candidate.strength + candidate.intel;
const normalizedStat = Math.min(1, Math.max(0, (statTotal - 160) / 30));
return eligibleDexWeight * (1 + 0.5 * normalizedStat);
};
const resolveCentennialEnvironment = (worldState: WorldStateRow): CentennialAllStarEnvironment => {
const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta);
return {
startYear: Math.trunc(asNumber(scenarioMeta.startYear, worldState.currentYear)),
year: worldState.currentYear,
month: worldState.currentMonth,
};
};
const resolveCentennialRules = (worldState: WorldStateRow): CentennialAllStarRules => {
const config = asRecord(worldState.config);
const stat = asRecord(config.stat);
const configConst = asRecord(config.const);
const defaultSpecialDomestic = configConst.defaultSpecialDomestic;
return {
defaultStatMin: asNumber(stat.min, 15),
defaultStatMax: asNumber(stat.max, 80),
defaultStatTotal: asNumber(stat.total, 165),
maxStatLevel: asNumber(configConst.maxLevel, 255),
defaultSpecialDomestic: typeof defaultSpecialDomestic === 'string' ? defaultSpecialDomestic : 'None',
dexLimit: asNumber(configConst.dexLimit, CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT),
};
};
const asCentennialTarget = (candidate: SelectPoolCandidateInfo): CentennialAllStarTarget => ({
...candidate,
specialDomestic: candidate.specialDomestic,
});
const eventDomesticTraitLoader = new EventDomesticTraitLoader();
const warTraitLoader = new WarTraitLoader();
const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<SelectPoolCandidateDto> => {
const trait = isEventDomesticTraitKey(candidate.specialDomestic)
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
: null;
const trait =
candidate.specialDomestic && isEventDomesticTraitKey(candidate.specialDomestic)
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
: null;
const warTrait =
candidate.specialWar && isWarTraitKey(candidate.specialWar)
? await warTraitLoader.load(candidate.specialWar)
@@ -179,7 +237,7 @@ const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<Selec
strength: candidate.strength,
intel: candidate.intel,
specialDomestic: candidate.specialDomestic,
specialDomesticName: trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
specialDomesticName: trait?.name ?? candidate.specialDomestic?.replace(/^che_event_/, '') ?? null,
specialDomesticInfo: trait?.info ?? '',
specialWar: candidate.specialWar ?? null,
specialWarName: warTrait?.name ?? candidate.specialWar?.replace(/^che_(?:event_)?/u, '') ?? null,
@@ -192,35 +250,63 @@ const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<Selec
};
const toReservationDto = (
rows: Array<Pick<SelectPoolRow, 'id' | 'uniqueName' | 'reservedUntil' | 'info'>>,
hasGeneral: boolean
rows: Array<Pick<SelectPoolRow, 'id' | 'uniqueName' | 'reservedUntil' | 'reservedUntilTick' | 'info'>>,
hasGeneral: boolean,
worldState: WorldStateRow,
world: InMemoryTurnWorld
): Promise<SelectPoolReservationDto> => {
const validUntil = rows[0]?.reservedUntil;
if (!validUntil) {
const first = rows[0];
if (!first || (first.reservedUntilTick === null && first.reservedUntil === null)) {
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
}
const expiresAt = validUntil;
const expiresAt =
first.reservedUntilTick === null
? first.reservedUntil!
: world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
const poolName = resolvePoolName(worldState);
if (!poolName || !SUPPORTED_POOLS.has(poolName)) {
throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
}
const centennialEnvironment = resolveCentennialEnvironment(worldState);
const centennialRules = resolveCentennialRules(worldState);
const sorted = rows
.map((row) => ({ id: row.id, info: parseCandidate(row) }))
.sort((left, right) => candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id);
.map((row) => {
const raw = parseCandidate(row);
if (poolName !== CENTENNIAL_ALL_STAR_POOL) {
return { id: row.id, info: raw };
}
const target = asCentennialTarget(raw);
const display = hasGeneral
? calculateCentennialUserCurrentTargetStats(target, centennialEnvironment, centennialRules)
: calculateCentennialUserInitialStats(target, centennialRules);
return {
id: row.id,
info: {
...raw,
leadership: display.leadership,
strength: display.strength,
intel: display.intel,
},
};
})
.sort(
(left, right) =>
left.info.dex.reduce((sum, value) => sum + value, 0) -
right.info.dex.reduce((sum, value) => sum + value, 0) || left.id - right.id
);
return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({
poolName: SUPPORTED_POOL,
poolName,
hasGeneral,
validUntil: expiresAt.toISOString(),
candidates,
}));
};
const formatLegacySeedTime = (value: Date): string => {
const pad = (part: number): string => String(part).padStart(2, '0');
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
koreaTime.getUTCDate()
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`;
};
export const buildSelectPoolSeed = (hiddenSeed: string | number, ownerIdentity: string | number, now: Date): string =>
simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
export const buildSelectPoolSeed = (
hiddenSeed: string | number,
ownerIdentity: string | number,
nowTick: number
): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, nowTick);
export const claimWeightedSelectionCandidates = async <T extends { id: number }>(options: {
weighted: [T, number][];
@@ -265,6 +351,27 @@ const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
: fail('INTERNAL_SERVER_ERROR', '장수 선택 비밀 seed가 설정되지 않았습니다.');
};
const toSafeReservationTick = (value: bigint | number, uniqueName: string): number => {
const tick = Number(value);
if (!Number.isSafeInteger(tick)) {
fail('INTERNAL_SERVER_ERROR', `장수 선택 후보 ${uniqueName}의 예약 tick이 안전한 정수 범위를 벗어났습니다.`);
}
return tick;
};
const resolveAcceptedGameTick = (world: InMemoryTurnWorld, now: Date): number => {
const tick = world.dateToGameTick(now);
if (!Number.isSafeInteger(tick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
}
return tick;
};
const isReservationActive = (row: SelectPoolRow, now: Date, nowTick: number): boolean =>
row.reservedUntilTick !== null
? toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick
: row.reservedUntil !== null && row.reservedUntil.getTime() >= now.getTime();
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
};
@@ -273,14 +380,18 @@ const requireSelectionToken = async (
db: DatabaseClient,
userId: string,
uniqueName: string,
now: Date
now: Date,
nowTick: number
): Promise<SelectPoolRow> => {
const token = await db.selectPoolEntry.findFirst({
where: {
ownerUserId: userId,
uniqueName,
reservedUntil: { gte: now },
generalId: null,
OR: [
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
},
});
if (!token) {
@@ -289,17 +400,44 @@ const requireSelectionToken = async (
return token as SelectPoolRow;
};
const mapSelectionPoolRow = (row: SelectPoolRow): TurnGeneralPoolEntry => ({
id: row.id,
uniqueName: row.uniqueName,
ownerUserId: row.ownerUserId,
generalId: row.generalId,
reservedUntil: row.reservedUntil ? new Date(row.reservedUntil.getTime()) : null,
reservedUntilTick:
row.reservedUntilTick === null ? null : toSafeReservationTick(row.reservedUntilTick, row.uniqueName),
candidate: parseScenarioGeneralPoolCandidate({ id: row.id, uniqueName: row.uniqueName, info: row.info }),
});
const synchronizeSelectionPoolWorld = async (
db: DatabaseClient,
world: InMemoryTurnWorld
): Promise<SelectPoolRow[]> => {
const rows = (await db.selectPoolEntry.findMany({ orderBy: { id: 'asc' } })) as SelectPoolRow[];
world.replaceGeneralPoolEntries(rows.map(mapSelectionPoolRow));
return rows;
};
export const reserveSelectionPool = async (options: {
db: DatabaseClient;
world: InMemoryTurnWorld;
worldState: WorldStateRow;
userId: string;
now?: Date;
acceptedGameTick?: number;
seedOwnerIdentity?: string | number;
}): Promise<SelectPoolReservationDto> => {
const { db, worldState, userId } = options;
const { db, world, worldState, userId } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
const acceptedGameTick = options.acceptedGameTick ?? resolveAcceptedGameTick(world, now);
if (!Number.isSafeInteger(acceptedGameTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
}
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
const general = await db.general.findFirst({
where: { userId },
select: { id: true, meta: true },
@@ -309,77 +447,94 @@ export const reserveSelectionPool = async (options: {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
const existing = await db.selectPoolEntry.findMany({
where: {
ownerUserId: userId,
reservedUntil: { gte: now },
generalId: null,
},
orderBy: { id: 'asc' },
});
let currentRows = await synchronizeSelectionPoolWorld(db, world);
const existing = currentRows.filter(
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, now, acceptedGameTick)
);
if (existing.length > 0) {
return toReservationDto(existing as SelectPoolRow[], Boolean(general));
return toReservationDto(existing, Boolean(general), worldState, world);
}
await db.selectPoolEntry.updateMany({
where: {
reservedUntil: { lt: now },
generalId: null,
OR: [
{ reservedUntilTick: { lt: BigInt(acceptedGameTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: now } },
],
},
data: {
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
const available = (await db.selectPoolEntry.findMany({
where: {
ownerUserId: null,
reservedUntil: null,
generalId: null,
},
orderBy: { id: 'asc' },
})) as SelectPoolRow[];
currentRows = await synchronizeSelectionPoolWorld(db, world);
const availableIds = new Set(
world.listGeneralPoolCandidates(now, acceptedGameTick)?.map((candidate) => candidate.poolEntryId) ?? []
);
const available = currentRows.filter(
(row) =>
availableIds.has(row.id) &&
row.ownerUserId === null &&
row.reservedUntil === null &&
row.reservedUntilTick === null &&
row.generalId === null
);
if (available.length < RESERVATION_COUNT) {
fail('PRECONDITION_FAILED', 'pool 부족');
}
const rng = new RandUtil(
new LiteHashDRBG(buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, now))
new LiteHashDRBG(
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, acceptedGameTick)
)
);
const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]);
const reservedUntil = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESERVATION_TURN_MULTIPLIER * 60_000
const poolName = resolvePoolName(worldState)!;
const weighted = available.map(
(row) =>
[row, calculateSelectionCandidateWeight(poolName, parseCandidate(row), true)] as [SelectPoolRow, number]
);
const reservedUntilTick = acceptedGameTick + RESERVATION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
if (!Number.isSafeInteger(reservedUntilTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
}
const reservedUntil = world.gameTickToDate(reservedUntilTick);
const selected = await claimWeightedSelectionCandidates({
weighted,
rng,
count: RESERVATION_COUNT,
claim: async (candidate) => {
const claimed = await db.selectPoolEntry.updateMany({
where: {
id: candidate.id,
ownerUserId: null,
reservedUntil: null,
generalId: null,
},
data: {
ownerUserId: userId,
reservedUntil,
},
});
return claimed.count > 0;
},
claim: async () => true,
});
const reserved = selected.map((candidate) => ({
...candidate,
ownerUserId: userId,
reservedUntil,
reservedUntilTick: BigInt(reservedUntilTick),
}));
if (reserved.length !== RESERVATION_COUNT) {
fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.');
}
return toReservationDto(reserved, Boolean(general));
const reservation = await toReservationDto(reserved, Boolean(general), worldState, world);
const claimed = await db.selectPoolEntry.updateMany({
where: {
id: { in: selected.map((candidate) => candidate.id) },
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
generalId: null,
},
data: {
ownerUserId: userId,
reservedUntil,
reservedUntilTick: BigInt(reservedUntilTick),
},
});
if (claimed.count !== RESERVATION_COUNT) {
throw new Error('장수 선택 후보의 DB 점유 수가 턴 데몬 선택 결과와 일치하지 않습니다.');
}
await synchronizeSelectionPoolWorld(db, world);
return reservation;
};
const lockSelectionMutationTables = async (db: DatabaseClient): Promise<void> => {
@@ -403,15 +558,25 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor
}
};
const clearUnusedReservations = async (db: DatabaseClient, userId: string, now: Date): Promise<void> => {
const clearUnusedReservations = async (
db: DatabaseClient,
userId: string,
now: Date,
nowTick: number
): Promise<void> => {
await db.selectPoolEntry.updateMany({
where: {
generalId: null,
OR: [{ ownerUserId: userId }, { reservedUntil: { lt: now } }],
OR: [
{ ownerUserId: userId },
{ reservedUntilTick: { lt: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: now } },
],
},
data: {
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
};
@@ -533,8 +698,10 @@ export const createGeneralFromSelectionPool = async (options: {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
const nowTick = resolveAcceptedGameTick(world, now);
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
await synchronizeSelectionPoolWorld(db, world);
await assertGeneralIdSnapshotMatches(db, world);
if (
world.listGenerals().some((general) => general.userId === userId) ||
@@ -542,8 +709,15 @@ export const createGeneralFromSelectionPool = async (options: {
) {
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.');
}
const token = await requireSelectionToken(db, userId, uniqueName, now);
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
const info = parseCandidate(token);
const poolName = resolvePoolName(worldState)!;
const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL;
const centennialTarget = isCentennial ? asCentennialTarget(info) : null;
const centennialRules = resolveCentennialRules(worldState);
const centennialInitialStats = centennialTarget
? calculateCentennialUserInitialStats(centennialTarget, centennialRules)
: null;
const config = asRecord(worldState.config);
const configConst = asRecord(config.const);
@@ -556,9 +730,22 @@ export const createGeneralFromSelectionPool = async (options: {
const seedOwnerIdentity = options.seedOwnerIdentity ?? userId;
const rng = resolvePoolRng(worldState, seedOwnerIdentity, uniqueName);
const affinity = rng.nextRangeInt(1, 150);
const cities = await db.city.findMany({ select: { id: true, name: true }, orderBy: { id: 'asc' } });
const allCities = await db.city.findMany({
select: { id: true, name: true, level: true, nationId: true },
orderBy: { id: 'asc' },
});
const centennialCities = allCities.filter((city) => city.level >= 5 && city.level <= 6);
const neutralCentennialCities = centennialCities.filter((city) => city.nationId === 0);
const cities = isCentennial
? neutralCentennialCities.length > 0
? neutralCentennialCities
: centennialCities
: allCities;
if (cities.length === 0) {
fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.');
fail(
'PRECONDITION_FAILED',
isCentennial ? '장수를 생성할 소·중성이 없습니다.' : '생성 가능한 도시가 없습니다.'
);
}
const city = rng.choice(cities);
const turnTime = buildInitialTurnTime(rng, worldState, now);
@@ -575,12 +762,45 @@ export const createGeneralFromSelectionPool = async (options: {
const imageServer = useOwnerPicture ? (options.ownerImageServer ?? 1) : info.imgsvr;
const defaultSpecialWar =
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
const defaultSpecialDomestic =
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None';
const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality);
// 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다.
// SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저
// getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다.
const generalId = world.getNextGeneralId();
const generalMeta: TurnGeneral['meta'] = {
createdBy: 'select_pool',
ownerName: ownerDisplayName,
owner_name: ownerDisplayName,
killturn: 5,
specage: specialityAges.domestic,
specage2: specialityAges.war,
dex1: isCentennial ? 0 : info.dex[0],
dex2: isCentennial ? 0 : info.dex[1],
dex3: isCentennial ? 0 : info.dex[2],
dex4: isCentennial ? 0 : info.dex[3],
dex5: isCentennial ? 0 : info.dex[4],
next_change: nextChangeAt.toISOString(),
nextChangeAt: nextChangeAt.toISOString(),
prestart_delete_after: prestartDeleteAfter.toISOString(),
...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}),
npc_org: 0,
...buildScenarioGeneralPoolClaimMeta(
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
now
),
};
if (centennialTarget && centennialInitialStats) {
const mutableMeta: Record<string, unknown> = generalMeta;
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = initialCentennialAllStarAux(
centennialTarget,
centennialRules,
centennialInitialStats
);
}
const general: TurnGeneral = {
id: generalId,
userId,
@@ -595,9 +815,9 @@ export const createGeneralFromSelectionPool = async (options: {
picture,
imageServer,
stats: {
leadership: info.leadership,
strength: info.strength,
intelligence: info.intel,
leadership: centennialInitialStats?.leadership ?? info.leadership,
strength: centennialInitialStats?.strength ?? info.strength,
intelligence: centennialInitialStats?.intel ?? info.intel,
},
experience: info.experience ?? age * 100,
dedication: info.dedication ?? age * 100,
@@ -614,8 +834,8 @@ export const createGeneralFromSelectionPool = async (options: {
startAge: age,
role: {
personality,
specialDomestic: info.specialDomestic,
specialWar: info.specialWar ?? defaultSpecialWar,
specialDomestic: isCentennial ? defaultSpecialDomestic : info.specialDomestic,
specialWar: isCentennial ? defaultSpecialWar : (info.specialWar ?? defaultSpecialWar),
items: {
horse: null,
weapon: null,
@@ -632,26 +852,7 @@ export const createGeneralFromSelectionPool = async (options: {
lastTurn: { command: DEFAULT_TURN_ACTION },
penalty: {},
refreshScoreTotal: 0,
meta: {
createdBy: 'select_pool',
ownerName: ownerDisplayName,
owner_name: ownerDisplayName,
killturn: 5,
specage: specialityAges.domestic,
specage2: specialityAges.war,
dex1: info.dex[0],
dex2: info.dex[1],
dex3: info.dex[2],
dex4: info.dex[3],
dex5: info.dex[4],
next_change: nextChangeAt.toISOString(),
nextChangeAt: nextChangeAt.toISOString(),
prestart_delete_after: prestartDeleteAfter.toISOString(),
...(useOwnerPicture && options.ownerIconRevision
? { accountIconUpdatedAt: options.ownerIconRevision }
: {}),
npc_org: 0,
},
meta: generalMeta,
};
if (!world.addGeneral(general)) {
throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`);
@@ -674,13 +875,17 @@ export const createGeneralFromSelectionPool = async (options: {
where: {
id: token.id,
ownerUserId: userId,
reservedUntil: { gte: now },
generalId: null,
OR: [
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
},
data: {
generalId,
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
if (occupied.count === 0) {
@@ -691,7 +896,8 @@ export const createGeneralFromSelectionPool = async (options: {
update: { userId, lastRefresh: now },
create: { generalId, userId, lastRefresh: now },
});
await clearUnusedReservations(db, userId, now);
await clearUnusedReservations(db, userId, now, nowTick);
await synchronizeSelectionPoolWorld(db, world);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
const generalJosaRo = JosaUtil.pick(info.generalName, '로');
@@ -718,8 +924,10 @@ export const reselectGeneralFromSelectionPool = async (options: {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
const nowTick = resolveAcceptedGameTick(world, now);
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
await synchronizeSelectionPoolWorld(db, world);
const persistedGeneral = await db.general.findFirst({ where: { userId } });
const general = world.listGenerals().find((candidate) => candidate.userId === userId);
if (!persistedGeneral || !general) {
@@ -735,21 +943,26 @@ export const reselectGeneralFromSelectionPool = async (options: {
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
const token = await requireSelectionToken(db, userId, uniqueName, now);
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
const info = parseCandidate(token);
const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL;
const provisionalGeneralId = -general.id;
const claimed = await db.selectPoolEntry.updateMany({
where: {
id: token.id,
ownerUserId: userId,
reservedUntil: { gte: now },
generalId: null,
OR: [
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
},
data: {
generalId: provisionalGeneralId,
ownerUserId: null,
reservedUntil: null,
reservedUntilTick: null,
},
});
if (claimed.count === 0) {
@@ -757,7 +970,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
}
await db.selectPoolEntry.updateMany({
where: { generalId: general.id },
data: { generalId: null, ownerUserId: null, reservedUntil: null },
data: { generalId: null, ownerUserId: null, reservedUntil: null, reservedUntilTick: null },
});
const finalized = await db.selectPoolEntry.updateMany({
where: {
@@ -772,30 +985,51 @@ export const reselectGeneralFromSelectionPool = async (options: {
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
}
const currentMeta = asRecord(general.meta);
const cooldown = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const updatedMeta = {
...currentMeta,
const centennialBaseGeneral = isCentennial
? {
...general,
meta: prepareCentennialLegacyUserReselection(general, resolveCentennialRules(worldState)),
}
: general;
const centennialGrowth = isCentennial
? applyCentennialAllStarTarget(
centennialBaseGeneral,
asCentennialTarget(info),
resolveCentennialEnvironment(worldState),
resolveCentennialRules(worldState)
)
: null;
const updatedMeta: TurnGeneral['meta'] = {
...(centennialGrowth?.meta ?? general.meta),
ownerName: ownerDisplayName,
owner_name: ownerDisplayName,
dex1: info.dex[0],
dex2: info.dex[1],
dex3: info.dex[2],
dex4: info.dex[3],
dex5: info.dex[4],
...(!isCentennial
? {
dex1: info.dex[0],
dex2: info.dex[1],
dex3: info.dex[2],
dex4: info.dex[3],
dex5: info.dex[4],
}
: {}),
next_change: cooldown.toISOString(),
nextChangeAt: cooldown.toISOString(),
...buildScenarioGeneralPoolClaimMeta(
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
now
),
};
const updated = world.updateGeneral(general.id, {
name: info.generalName,
stats: {
stats: centennialGrowth?.stats ?? {
leadership: info.leadership,
strength: info.strength,
intelligence: info.intel,
},
role: {
role: centennialGrowth?.role ?? {
...general.role,
personality: info.ego ?? general.role.personality,
specialDomestic: info.specialDomestic,
@@ -803,12 +1037,13 @@ export const reselectGeneralFromSelectionPool = async (options: {
},
picture: info.picture,
imageServer: info.imgsvr,
meta: updatedMeta as unknown as TurnGeneral['meta'],
meta: updatedMeta,
});
if (!updated) {
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
}
await clearUnusedReservations(db, userId, now);
await clearUnusedReservations(db, userId, now, nowTick);
await synchronizeSelectionPoolWorld(db, world);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
const generalJosaRo = JosaUtil.pick(info.generalName, '로');
+4 -1
View File
@@ -53,7 +53,7 @@ import { createAuctionBidder } from '../auction/bidder.js';
import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js';
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
import { createYearbookHandler } from './yearbookHandler.js';
import { createDynastyStatisticsHandler, createYearbookHandler } from './yearbookHandler.js';
import {
createMonthlyEventHandler,
createRandomizeCityTradeRateHandler,
@@ -79,6 +79,7 @@ import { createFinishNationBettingHandler, createOpenNationBettingHandler } from
import { createScoutBlockHandler } from './monthlyScoutBlockAction.js';
import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler } from './monthlySpecialityBetrayAction.js';
import { createLostUniqueItemHandler, createMergeInheritPointRankHandler } from './monthlyUniqueInheritAction.js';
import { createAdvanceCentennialAllStarHandler } from './monthlyCentennialAllStarAction.js';
import {
createNewYearHandler,
createNoticeToHistoryLogHandler,
@@ -386,6 +387,7 @@ const createMonthlyEventActions = (options: {
})
);
eventActions.set('MergeInheritPointRank', createMergeInheritPointRankHandler({ getWorld: options.getWorld }));
eventActions.set('AdvanceCentennialAllStar', createAdvanceCentennialAllStarHandler({ getWorld: options.getWorld }));
eventActions.set('ProcessIncome', createProcessIncomeActionHandler(options.incomeHandler));
eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: options.getWorld }));
eventActions.set('NewYear', createNewYearHandler({ getWorld: options.getWorld }));
@@ -464,6 +466,7 @@ const createMonthlyCalendarRuntime = async (options: {
now: () => options.getWorld()?.getGameNow(new Date(options.clock.nowMs())) ?? new Date(options.clock.nowMs()),
});
const calendarHandler = composeCalendarHandlers(
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
options.monthlyEventHandler,
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
+12
View File
@@ -5,6 +5,7 @@ import type {
Nation,
ScenarioConfig,
ScenarioMeta,
ScenarioGeneralPoolCandidate,
Troop,
UnitSetDefinition,
WorldSnapshot,
@@ -62,6 +63,16 @@ export interface TurnEvent {
meta: Record<string, unknown>;
}
export interface TurnGeneralPoolEntry {
id: number;
uniqueName: string;
ownerUserId: string | null;
generalId: number | null;
reservedUntil: Date | null;
reservedUntilTick: number | null;
candidate: ScenarioGeneralPoolCandidate;
}
export interface PendingNeutralAuction {
registrationKey: string;
type: 'BUY_RICE' | 'SELL_RICE';
@@ -152,6 +163,7 @@ export interface TurnWorldSnapshot extends Omit<
diplomacy: TurnDiplomacy[];
events: TurnEvent[];
initialEvents: TurnEvent[];
generalPoolEntries?: TurnGeneralPoolEntry[];
generals: TurnGeneral[];
cities: City[];
nations: Nation[];
@@ -3,7 +3,7 @@ import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '@sammo-ts/
import type { InMemoryTurnWorld, TurnCalendarContext, TurnCalendarHandler } from './inMemoryWorld.js';
import type { PendingUnificationAuctionCancellation } from './types.js';
import { queueYearbookSnapshot } from './yearbookHandler.js';
import { queueYearbookSnapshot, updateDynastyStatistics } from './yearbookHandler.js';
const UNIFIER_POINT = 2000;
const INVADER_MESSAGE_OPTIONS = [
@@ -67,6 +67,7 @@ export const createUnificationHandler = (options: {
if (cities.length === 0 || cities.some((city) => city.nationId !== winner.id)) return;
const serverId = resolveServerId(world, options.profileName);
updateDynastyStatistics(world);
world.pushLog(buildNationHistoryLog(winner.id, winner.name));
const auctionCancellations = (await options.loadPendingUniqueAuctions?.()) ?? [];
+340 -73
View File
@@ -54,6 +54,7 @@ import {
} from './scenarioStaticEvents.js';
import {
createGeneralFromSelectionPool,
reserveSelectionPool,
reselectGeneralFromSelectionPool,
SelectPoolError,
} from './selectPoolService.js';
@@ -158,6 +159,7 @@ const resolveCommandAcceptedAt = async (
| 'ensureDieOnPrestartStatus'
| 'joinCreateGeneral'
| 'npcPossessGeneral'
| 'selectPoolReserve'
| 'selectPoolCreate'
| 'selectPoolReselect'
| 'adjustGeneralIcon';
@@ -183,6 +185,21 @@ const resolveCommandAcceptedAt = async (
return event.createdAt;
};
const resolveSelectionCommandAcceptedAt = async (
db: DatabaseClient,
world: InMemoryTurnWorld,
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }>
): Promise<Date> => {
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
if (command.acceptedGameTick !== undefined) {
return world.gameTickToDate(command.acceptedGameTick);
}
if (command.acceptedGameAt !== undefined) {
return new Date(command.acceptedGameAt);
}
return world.getGameNow(operationalAcceptedAt);
};
const resolveOperationalAcceptedAt = async (
db: DatabaseClient,
command: Pick<TurnDaemonCommand, 'type' | 'requestId'>
@@ -347,8 +364,7 @@ async function handleSelectPoolCreate(
if (!worldState) {
throw new Error('Selection-pool world state is missing.');
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command);
try {
return {
type: 'selectPoolCreate',
@@ -380,6 +396,45 @@ async function handleSelectPoolCreate(
}
}
async function handleSelectPoolReserve(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx);
const worldState = await db.worldState.findUnique({
where: { id: ctx.world.getState().id },
});
if (!worldState) {
throw new Error('Selection-pool world state is missing.');
}
const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command);
try {
return {
type: 'selectPoolReserve',
ok: true,
reservation: await reserveSelectionPool({
db,
world: ctx.world,
worldState,
userId: command.userId,
seedOwnerIdentity: command.seedOwnerIdentity,
now: acceptedAt,
...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }),
}),
};
} catch (error) {
if (error instanceof SelectPoolError) {
return {
type: 'selectPoolReserve',
ok: false,
code: error.code,
reason: error.message,
};
}
throw error;
}
}
async function handleSelectPoolReselect(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>
@@ -391,8 +446,7 @@ async function handleSelectPoolReselect(
if (!worldState) {
throw new Error('Selection-pool world state is missing.');
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command);
try {
return {
type: 'selectPoolReselect',
@@ -420,7 +474,10 @@ async function handleSelectPoolReselect(
}
interface AuctionFinalizer {
finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
finalize(
command: Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>,
db?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult>;
}
interface AuctionBidder {
@@ -1780,7 +1837,7 @@ async function handleAuctionFinalize(
reason: '경매 확정기가 준비되지 않았습니다.',
};
}
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
return ctx.auctionFinalizer.finalize(command, ctx.commandDb);
}
async function handleAuctionOpen(
@@ -2277,49 +2334,57 @@ async function handleTournamentBettingPayout(
};
}
const tournamentName = ['전력전', '통솔전', '일기토', '설전'][command.tournamentType ?? -1] ?? '대회';
const payoutsByGeneral = new Map<number, number>();
for (const payout of command.payouts) {
if (
!payout ||
typeof payout.generalId !== 'number' ||
typeof payout.amount !== 'number' ||
payout.amount <= 0
) {
continue;
}
payoutsByGeneral.set(payout.generalId, (payoutsByGeneral.get(payout.generalId) ?? 0) + payout.amount);
}
let processed = 0;
let missing = 0;
let totalPayout = 0;
const metaDeltas = new Map<number, { betwin: number; betwingold: number }>();
for (const payout of command.payouts) {
if (!payout || typeof payout.generalId !== 'number' || typeof payout.amount !== 'number') {
continue;
}
if (payout.amount <= 0) {
continue;
}
const general = world.getGeneralById(payout.generalId);
for (const [generalId, amount] of payoutsByGeneral) {
const general = world.getGeneralById(generalId);
if (!general) {
missing += 1;
continue;
}
world.updateGeneral(payout.generalId, {
gold: general.gold + payout.amount,
const shouldRecordRank =
general.npcState === 0 ||
(general.npcState === 1 &&
typeof general.meta.betgold === 'number' &&
Number.isFinite(general.meta.betgold) &&
general.meta.betgold > 0);
const nextMeta = { ...general.meta } as TurnGeneral['meta'];
if (shouldRecordRank) {
const currentBetwin = typeof nextMeta.betwin === 'number' ? Number(nextMeta.betwin) : 0;
const currentBetwingold = typeof nextMeta.betwingold === 'number' ? Number(nextMeta.betwingold) : 0;
nextMeta.betwin = currentBetwin + 1;
nextMeta.betwingold = currentBetwingold + amount;
}
world.updateGeneral(generalId, {
gold: general.gold + amount,
...(shouldRecordRank ? { meta: nextMeta } : {}),
});
processed += 1;
totalPayout += payout.amount;
const currentDelta = metaDeltas.get(payout.generalId) ?? { betwin: 0, betwingold: 0 };
metaDeltas.set(payout.generalId, {
betwin: currentDelta.betwin + 1,
betwingold: currentDelta.betwingold + payout.amount,
totalPayout += amount;
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.EVENT_PLAIN,
generalId,
text: `<C>${tournamentName}</>의 베팅 당첨 보상으로 <C>${amount.toLocaleString('ko-KR')}</>의 <S>금</> 획득!`,
meta: {},
});
}
for (const [generalId, delta] of metaDeltas) {
const general = world.getGeneralById(generalId);
if (!general) {
continue;
}
const nextMeta = { ...general.meta } as TurnGeneral['meta'];
const betwinKey = 'betwin';
const betwingoldKey = 'betwingold';
const currentBetwin = typeof nextMeta[betwinKey] === 'number' ? Number(nextMeta[betwinKey]) : 0;
const currentBetwingold = typeof nextMeta[betwingoldKey] === 'number' ? Number(nextMeta[betwingoldKey]) : 0;
nextMeta[betwinKey] = currentBetwin + delta.betwin;
nextMeta[betwingoldKey] = currentBetwingold + delta.betwingold;
world.updateGeneral(generalId, { meta: nextMeta });
}
return {
type: 'tournamentBettingPayout',
ok: true,
@@ -2346,7 +2411,147 @@ async function handleTournamentReward(
return ctx.tournamentRewardFinalizer.finalize(command, ctx.commandDb);
}
// 설문 보상은 API에서 전달된 RNG 결과를 재검증한 뒤 월드에 반영한다.
type VoteSelectionPersistenceResult = 'inserted' | 'existing-match' | 'existing-mismatch' | 'missing';
const normalizePersistedVoteSelection = (value: unknown): number[] | null => {
let parsed = value;
if (typeof value === 'string') {
try {
parsed = JSON.parse(value) as unknown;
} catch {
return null;
}
}
if (!Array.isArray(parsed)) return null;
if (parsed.some((entry) => typeof entry !== 'number' || !Number.isInteger(entry))) return null;
const normalized = [...parsed].sort((left, right) => left - right);
return new Set(normalized).size === normalized.length ? normalized : null;
};
const voteSelectionsMatch = (stored: unknown, submitted: number[]): boolean => {
const normalized = normalizePersistedVoteSelection(stored);
return (
normalized !== null &&
normalized.length === submitted.length &&
normalized.every((value, index) => value === submitted[index])
);
};
const readExistingVoteSelection = async (
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>,
selection: number[]
): Promise<Exclude<VoteSelectionPersistenceResult, 'inserted'>> => {
if (!ctx.commandDb) return 'missing';
const rows = await ctx.commandDb.$queryRaw<Array<{ selection: unknown }>>(GamePrisma.sql`
SELECT selection
FROM vote
WHERE vote_id = ${command.voteId}
AND general_id = ${command.generalId}
FOR UPDATE
`);
if (!rows[0]) return 'missing';
return voteSelectionsMatch(rows[0].selection, selection) ? 'existing-match' : 'existing-mismatch';
};
const insertVoteSelection = async (
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>,
general: TurnGeneral,
selection: number[]
): Promise<VoteSelectionPersistenceResult> => {
if (!ctx.commandDb) {
return 'missing';
}
const rows = await ctx.commandDb.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
INSERT INTO vote (vote_id, general_id, nation_id, selection)
SELECT poll.id,
${general.id},
${general.nationId},
CAST(${JSON.stringify(selection)} AS jsonb)
FROM vote_poll poll
WHERE poll.id = ${command.voteId}
ON CONFLICT (vote_id, general_id) DO NOTHING
RETURNING id
`);
if (rows[0]?.id) return 'inserted';
return readExistingVoteSelection(ctx, command, selection);
};
type VotePollValidationRow = {
options: unknown;
multipleOptions: number;
endAt: Date | null;
endTick: bigint | number | string | null;
closedAt: Date | null;
};
export const hasVotePollDeadlinePassed = (
poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>,
acceptedGameAt: Date,
acceptedGameTick: number
): boolean => {
const endTick =
poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick);
return (
poll.closedAt !== null ||
(endTick !== null
? endTick < BigInt(acceptedGameTick)
: Boolean(poll.endAt && poll.endAt.getTime() < acceptedGameAt.getTime()))
);
};
const parseVoteOptionCount = (value: unknown): number => {
if (Array.isArray(value)) {
return value.filter((entry) => typeof entry === 'string').length;
}
if (typeof value !== 'string') return 0;
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string').length : 0;
} catch {
return 0;
}
};
const validateVoteSelectionInTransaction = async (
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>,
selection: number[]
): Promise<string | null> => {
if (!ctx.commandDb) return '설문 응답 트랜잭션이 준비되지 않았습니다.';
const rows = await ctx.commandDb.$queryRaw<VotePollValidationRow[]>(GamePrisma.sql`
SELECT options,
multiple_options AS "multipleOptions",
end_at AS "endAt",
end_tick AS "endTick",
closed_at AS "closedAt"
FROM vote_poll
WHERE id = ${command.voteId}
FOR UPDATE
`);
const poll = rows[0];
if (!poll) return '설문조사가 없습니다.';
const processingNow = ctx.world.getGameNow(new Date());
const acceptedGameTick = command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow);
const acceptedGameAt =
command.acceptedGameTick === undefined ? processingNow : ctx.world.gameTickToDate(command.acceptedGameTick);
if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) {
return '설문조사가 종료되었습니다.';
}
const optionCount = parseVoteOptionCount(poll.options);
if (selection.some((value) => value < 0 || value >= optionCount)) {
return '선택한 항목이 없습니다.';
}
if (poll.multipleOptions >= 1 && selection.length > poll.multipleOptions) {
return '선택한 항목이 너무 많습니다.';
}
return null;
};
// 설문 응답과 보상은 같은 ENGINE mutation transaction에서 확정한다.
async function handleVoteReward(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>
@@ -2367,7 +2572,8 @@ async function handleVoteReward(
const metaRecord = asRecord(baseMeta);
const existingRewards = asRecord(metaRecord.voteRewards);
const rewardKey = String(command.voteId);
if (Object.prototype.hasOwnProperty.call(existingRewards, rewardKey)) {
const hasExistingReward = Object.prototype.hasOwnProperty.call(existingRewards, rewardKey);
const buildAlreadyAppliedResult = (): TurnDaemonCommandResult => {
const existingValue = existingRewards[rewardKey];
const existingEntry = asRecord(existingValue);
const existingItemKey = typeof existingEntry.itemKey === 'string' ? existingEntry.itemKey : null;
@@ -2381,6 +2587,71 @@ async function handleVoteReward(
itemKey: existingItemKey ?? null,
alreadyApplied: true,
};
};
const sortedSelection = [...command.selection].sort((a, b) => a - b);
if (
sortedSelection.length === 0 ||
sortedSelection.some((value) => !Number.isInteger(value)) ||
new Set(sortedSelection).size !== sortedSelection.length
) {
return {
type: 'voteReward',
ok: false,
voteId: command.voteId,
generalId: command.generalId,
reason: '선택한 항목이 올바르지 않습니다.',
};
}
if (!ctx.commandDb) {
return {
type: 'voteReward',
ok: false,
voteId: command.voteId,
generalId: command.generalId,
reason: '설문 응답 트랜잭션이 준비되지 않았습니다.',
};
}
if (hasExistingReward) {
// The reward marker is the idempotency authority. Do not invent a vote
// row from a later retry when the original selection is unavailable.
return buildAlreadyAppliedResult();
}
const selectionError = await validateVoteSelectionInTransaction(ctx, command, sortedSelection);
let votePersistence: VoteSelectionPersistenceResult | null = null;
if (selectionError === '설문조사가 종료되었습니다.') {
// Before vote+reward became one ENGINE transaction, the API could
// persist the vote and fail before the reward command committed. A
// matching locked row is sufficient evidence to repair that partial
// even when the poll has since closed.
votePersistence = await readExistingVoteSelection(ctx, command, sortedSelection);
if (votePersistence === 'existing-mismatch') {
return {
type: 'voteReward',
ok: false,
voteId: command.voteId,
generalId: command.generalId,
reason: '이미 설문조사를 완료하였습니다.',
};
}
if (votePersistence !== 'existing-match') {
return {
type: 'voteReward',
ok: false,
voteId: command.voteId,
generalId: command.generalId,
reason: selectionError,
};
}
} else if (selectionError) {
return {
type: 'voteReward',
ok: false,
voteId: command.voteId,
generalId: command.generalId,
reason: selectionError,
};
}
const worldState = world.getState();
@@ -2391,9 +2662,20 @@ async function handleVoteReward(
const initMonth = readMetaNumber(worldMeta, 'initMonth', 1);
const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0);
const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id;
const configConst = asRecord(world.getScenarioConfig().const);
const configuredDevelCost = readMetaNumber(
configConst,
'develCost',
readMetaNumber(configConst, 'develcost', readMetaNumber(configConst, 'develrate', 0))
);
const voteReward =
readMetaNumber(
worldMeta,
'develcost',
readMetaNumber(worldMeta, 'develCost', readMetaNumber(worldMeta, 'develrate', configuredDevelCost))
) * 5;
const itemRegistry = await getItemRegistry();
const configConst = asRecord(world.getScenarioConfig().const);
const uniqueConfig = resolveUniqueConfig(configConst);
const generals = world.listGenerals();
const occupiedUniqueCounts = countOccupiedUniqueItems(
@@ -2438,34 +2720,27 @@ async function handleVoteReward(
acquireType: '설문조사',
});
const expectedUnique = command.unique?.expected ?? false;
const expectedItemKey = command.unique?.itemKey ?? null;
if (expectedUnique !== Boolean(itemKey)) {
const awardedItemModule = itemKey ? itemRegistry.get(itemKey) : null;
if (itemKey && !awardedItemModule) {
return {
type: 'voteReward',
ok: false,
voteId: command.voteId,
generalId: command.generalId,
reason: '유니크 판정이 일치하지 않습니다.',
reason: '유니크 아이템을 찾을 수 없습니다.',
};
}
if (expectedUnique) {
if (!expectedItemKey || !itemKey || expectedItemKey !== itemKey) {
return {
type: 'voteReward',
ok: false,
voteId: command.voteId,
generalId: command.generalId,
reason: '유니크 판정이 일치하지 않습니다.',
};
}
} else if (itemKey) {
if (votePersistence === null) {
votePersistence = await insertVoteSelection(ctx, command, general, sortedSelection);
}
if (votePersistence !== 'inserted' && votePersistence !== 'existing-match') {
return {
type: 'voteReward',
ok: false,
voteId: command.voteId,
generalId: command.generalId,
reason: '유니크 판정이 일치하지 않습니다.',
reason: '이미 설문조사를 완료하였습니다.',
};
}
@@ -2488,36 +2763,26 @@ async function handleVoteReward(
};
const patch: Partial<TurnGeneral> = {
gold: general.gold + command.goldReward,
gold: general.gold + voteReward,
meta: nextMeta,
};
if (itemKey) {
const itemModule = itemRegistry.get(itemKey);
if (!itemModule) {
return {
type: 'voteReward',
ok: false,
voteId: command.voteId,
generalId: command.generalId,
reason: '유니크 아이템을 찾을 수 없습니다.',
};
}
if (itemKey && awardedItemModule) {
const nextGeneral = {
...general,
role: { ...general.role, items: { ...general.role.items } },
itemInventory: cloneItemInventory(ensureItemInventory(general)),
};
equipNewItem(nextGeneral, itemModule.slot, itemKey, {
...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }),
equipNewItem(nextGeneral, awardedItemModule.slot, itemKey, {
...(awardedItemModule.initialCharges === undefined ? {} : { charges: awardedItemModule.initialCharges }),
});
patch.role = nextGeneral.role;
patch.itemInventory = nextGeneral.itemInventory;
const nationName = world.getNationById(general.nationId)?.name ?? '재야';
const generalName = general.name;
const itemName = itemModule.name;
const itemRawName = itemModule.rawName;
const itemName = awardedItemModule.name;
const itemRawName = awardedItemModule.rawName;
const josaYi = JosaUtil.pick(generalName, '이');
const josaUl = JosaUtil.pick(itemRawName, '을');
@@ -2677,6 +2942,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
npcPossessGeneral: (command) =>
handleNpcPossessGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
selectPoolReserve: (command) =>
handleSelectPoolReserve(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolReserve' }>),
selectPoolCreate: (command) =>
handleSelectPoolCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>),
selectPoolReselect: (command) =>
+30 -1
View File
@@ -17,11 +17,13 @@ import type {
GeneralLastTurn,
Nation,
ScenarioConfig,
ScenarioGeneralPoolCandidate,
ScenarioMeta,
Troop,
TriggerValue,
} from '@sammo-ts/logic';
import { normalizeScenarioEffect } from '@sammo-ts/logic';
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
import { z } from 'zod';
import { GameClock, asRecord, isRecord, type GameClockMode } from '@sammo-ts/common';
@@ -30,7 +32,7 @@ import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js';
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnGeneralPoolEntry, TurnWorldLoadResult } from './types.js';
import { readDiplomacyMeta } from '@sammo-ts/logic';
import { applyPersistedRankRowsToMeta } from './rankData.js';
@@ -360,6 +362,27 @@ const mapEventRow = (row: {
meta: asRecord(row.meta),
});
const mapGeneralPoolRow = (row: {
id: number;
uniqueName: string;
ownerUserId: string | null;
generalId: number | null;
reservedUntil: Date | null;
reservedUntilTick: bigint | null;
info: JsonValue;
}): TurnGeneralPoolEntry => ({
id: row.id,
uniqueName: row.uniqueName,
ownerUserId: row.ownerUserId,
generalId: row.generalId,
reservedUntil: row.reservedUntil,
reservedUntilTick:
row.reservedUntilTick === null
? null
: toSafeTick(row.reservedUntilTick, `select_pool.reserved_until_tick(${row.id})`),
candidate: parseScenarioGeneralPoolCandidate(row) satisfies ScenarioGeneralPoolCandidate,
});
const mapTroopRow = (row: TurnEngineTroopRow): Troop => ({
id: row.troopLeaderId,
nationId: row.nationId,
@@ -386,6 +409,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
diplomacyRows,
troopRows,
eventRows,
generalPoolRows,
] = await Promise.all([
prisma.general.findMany(),
prisma.rankData.findMany(),
@@ -398,6 +422,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
prisma.event.findMany({
orderBy: [{ priority: 'desc' }, { id: 'asc' }],
}),
prisma.selectPoolEntry.findMany({ orderBy: { id: 'asc' } }),
]);
const meta = asRecord(worldState.meta);
@@ -465,6 +490,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
const worldConfig = asRecord(worldState.config);
const scenarioConfig = mapScenarioConfig(worldState.config);
const targetGeneralPool = scenarioConfig.map.targetGeneralPool;
const mapName = scenarioConfig.environment?.mapName ?? 'che';
const map = await loadMapDefinitionByName(mapName, options.mapOptions);
const unitSetName = scenarioConfig.environment?.unitSet ?? 'che';
@@ -508,6 +534,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
diplomacy,
events,
initialEvents,
...(typeof targetGeneralPool === 'string'
? { generalPoolEntries: generalPoolRows.map(mapGeneralPoolRow) }
: {}),
},
};
} finally {
+50 -16
View File
@@ -58,6 +58,9 @@ const resolveStartYear = (meta: Record<string, unknown>): number => {
return 0;
};
const readStoredSnapshotNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const buildMapSnapshot = (world: InMemoryTurnWorld, year: number, month: number): YearbookMap => {
const state = world.getState();
const cityList: MapCityCompact[] = world.listCities().map((city) => {
@@ -134,40 +137,57 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => {
generalStatsByNation.set(general.nationId, entry);
}
return nations.map((nation) => {
const projected = nations.map((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 = asNumber(asRecord(nation.meta).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(nation.power);
let power = 1;
if (nation.id !== 0) {
if (storedPower !== null) {
power = storedPower;
} else {
// Old or malformed in-memory snapshots can lack the monthly
// nation projection. Only that absence uses the former derived
// value; current worlds archive the stored Ref-compatible value.
const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const resource = Math.round((nation.gold + nation.rice + generalStats.goldRice) / 100);
const tech = asNumber(nationMeta.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) ?? [],
};
});
// Ref's getCurrentHistory() replaces nation 0 with its canonical static
// projection and then applies a stable descending-power sort.
return projected.sort((left, right) => right.power - left.power);
};
const increment = (target: Record<string, number>, key: string): void => {
target[key] = (target[key] ?? 0) + 1;
};
const updateDynastyStatistics = (world: InMemoryTurnWorld): void => {
export const updateDynastyStatistics = (world: InMemoryTurnWorld): void => {
const state = world.getState();
const previous = asRecord(state.meta.dynastyStatistics);
const activeNations = world
@@ -247,8 +267,22 @@ export const createYearbookHandler = (options: {
beforeMonthChanged: (context) => {
const world = options.getWorld();
if (!world) return;
updateDynastyStatistics(world);
queueYearbookSnapshot(world, options.profileName, context.previousYear, context.previousMonth);
},
},
});
export const createDynastyStatisticsHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
}): { handler: TurnCalendarHandler } => ({
handler: {
// Ref calls checkStatistic() after turnDate() only when the newly
// entered month is January. Unification takes one additional sample.
onMonthChanged: (context) => {
if (context.currentMonth !== 1) return;
const world = options.getWorld();
if (!world) return;
updateDynastyStatistics(world);
},
},
});