merge: 최신 main을 transport 권한과 durable 검증에 최종 통합한다
This commit is contained in:
@@ -61,10 +61,11 @@ export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
|
||||
NPC전방발령: true,
|
||||
};
|
||||
|
||||
export type UserRulerAutomationFeature = 'diplomacy' | 'promotion' | 'finance' | 'capital';
|
||||
export type UserRulerAutomationFeature = 'diplomacy' | 'war' | 'promotion' | 'finance' | 'capital';
|
||||
|
||||
const USER_RULER_AUTOMATION_META_KEY = {
|
||||
diplomacy: 'use_auto_nation_diplomacy',
|
||||
war: 'use_auto_nation_war',
|
||||
promotion: 'use_auto_nation_promotion',
|
||||
finance: 'use_auto_nation_finance',
|
||||
capital: 'use_auto_nation_capital',
|
||||
@@ -72,7 +73,7 @@ const USER_RULER_AUTOMATION_META_KEY = {
|
||||
|
||||
const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = {
|
||||
불가침제의: 'diplomacy',
|
||||
선전포고: 'diplomacy',
|
||||
선전포고: 'war',
|
||||
천도: 'capital',
|
||||
};
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ const zSetMySetting = z.object({
|
||||
use_treatment: z.number().int().optional(),
|
||||
use_auto_nation_turn: z.number().int().optional(),
|
||||
use_auto_nation_diplomacy: z.number().int().optional(),
|
||||
use_auto_nation_war: z.number().int().optional(),
|
||||
use_auto_nation_promotion: z.number().int().optional(),
|
||||
use_auto_nation_finance: z.number().int().optional(),
|
||||
use_auto_nation_capital: z.number().int().optional(),
|
||||
|
||||
@@ -174,7 +174,10 @@ const processIncomeForNation = (
|
||||
world.updateNation(nation.id, { rice: next, meta: nextMeta });
|
||||
}
|
||||
|
||||
const incomeText = incomeValue.toLocaleString();
|
||||
// Ref keeps the fractional pre-flush value for the payout ratio and
|
||||
// prev_income_* metadata, but number_format() rounds the user-facing log
|
||||
// to the same integer precision as the persisted resource column.
|
||||
const incomeText = Math.round(incomeValue).toLocaleString('en-US');
|
||||
const incomeLog =
|
||||
type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`;
|
||||
for (const general of nationGenerals) {
|
||||
|
||||
@@ -522,8 +522,9 @@ export const createGeneralFromJoin = async (options: {
|
||||
worldState: WorldStateRow;
|
||||
input: JoinCreateGeneralInput;
|
||||
acceptedAt: Date;
|
||||
operationalAcceptedAt: Date;
|
||||
}): Promise<{ ok: true; generalId: number }> => {
|
||||
const { db, world, worldState, input, acceptedAt } = options;
|
||||
const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options;
|
||||
await lockJoinMutation(db, input.userId);
|
||||
await assertGeneralIdSnapshotMatches(db, world);
|
||||
|
||||
@@ -734,7 +735,10 @@ export const createGeneralFromJoin = async (options: {
|
||||
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
||||
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
|
||||
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(acceptedAt, worldState.tickSeconds, config);
|
||||
// Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다.
|
||||
// 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면
|
||||
// 삭제할 수 있어야 하므로 RNG/턴 배치용 acceptedAt과 이 벽시계 경계를 분리한다.
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(operationalAcceptedAt, worldState.tickSeconds, config);
|
||||
const general: TurnGeneral = {
|
||||
id: generalId,
|
||||
userId: input.userId,
|
||||
@@ -839,11 +843,11 @@ export const createGeneralFromJoin = async (options: {
|
||||
});
|
||||
await db.generalAccessLog.upsert({
|
||||
where: { generalId },
|
||||
update: { userId: input.userId, lastRefresh: acceptedAt },
|
||||
update: { userId: input.userId, lastRefresh: operationalAcceptedAt },
|
||||
create: {
|
||||
generalId,
|
||||
userId: input.userId,
|
||||
lastRefresh: acceptedAt,
|
||||
lastRefresh: operationalAcceptedAt,
|
||||
},
|
||||
});
|
||||
if (inheritRequiredPoint > 0) {
|
||||
|
||||
@@ -54,6 +54,18 @@ const DEFAULT_CREW_TYPE_ID = 1100;
|
||||
const MAX_GENERAL_TURNS = 30;
|
||||
const DEFAULT_TURN_ACTION = '휴식';
|
||||
|
||||
export const resolveSelectionPoolUserIcon = (options: {
|
||||
showImgLevel: number;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
}): { picture: string; imageServer: number } => {
|
||||
const useOwnerPicture =
|
||||
options.showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg';
|
||||
return useOwnerPicture
|
||||
? { picture: options.ownerPicture!, imageServer: options.ownerImageServer ?? 1 }
|
||||
: { picture: 'default.jpg', imageServer: 0 };
|
||||
};
|
||||
|
||||
const zCandidateInfo = z.object({
|
||||
uniqueName: z.string().min(1),
|
||||
generalName: z.string().min(1),
|
||||
@@ -690,6 +702,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
uniqueName: string;
|
||||
personality: string;
|
||||
now?: Date;
|
||||
operationalAcceptedAt: Date;
|
||||
seedOwnerIdentity?: string | number;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
@@ -754,12 +767,15 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const nextChangeAt = new Date(
|
||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||
);
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config);
|
||||
const showImgLevel = asNumber(config.showImgLevel, 0);
|
||||
const useOwnerPicture =
|
||||
showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg';
|
||||
const picture = useOwnerPicture ? options.ownerPicture! : showImgLevel >= 3 ? info.picture : 'default.jpg';
|
||||
const imageServer = useOwnerPicture ? (options.ownerImageServer ?? 1) : info.imgsvr;
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
|
||||
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
|
||||
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
|
||||
const { picture, imageServer } = resolveSelectionPoolUserIcon({
|
||||
showImgLevel: asNumber(config.showImgLevel, 0),
|
||||
ownerPicture: options.ownerPicture,
|
||||
ownerImageServer: options.ownerImageServer,
|
||||
});
|
||||
const useOwnerPicture = picture !== 'default.jpg';
|
||||
const defaultSpecialWar =
|
||||
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||
const defaultSpecialDomestic =
|
||||
@@ -893,8 +909,8 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
}
|
||||
await db.generalAccessLog.upsert({
|
||||
where: { generalId },
|
||||
update: { userId, lastRefresh: now },
|
||||
create: { generalId, userId, lastRefresh: now },
|
||||
update: { userId, lastRefresh: options.operationalAcceptedAt },
|
||||
create: { generalId, userId, lastRefresh: options.operationalAcceptedAt },
|
||||
});
|
||||
await clearUnusedReservations(db, userId, now, nowTick);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
@@ -1022,6 +1038,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
now
|
||||
),
|
||||
};
|
||||
const reselectionIcon = resolveSelectionPoolUserIcon({ showImgLevel: 0 });
|
||||
const updated = world.updateGeneral(general.id, {
|
||||
name: info.generalName,
|
||||
stats: centennialGrowth?.stats ?? {
|
||||
@@ -1035,8 +1052,10 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
specialDomestic: info.specialDomestic,
|
||||
specialWar: info.specialWar ?? general.role.specialWar,
|
||||
},
|
||||
picture: info.picture,
|
||||
imageServer: info.imgsvr,
|
||||
// 재선택 후보의 preset은 유저 장수에 이어 붙이지 않는다. 전용 아이콘을
|
||||
// 다시 고르는 UI가 없는 현재 경로는 안전한 기본 아이콘으로 되돌린다.
|
||||
picture: reselectionIcon.picture,
|
||||
imageServer: reselectionIcon.imageServer,
|
||||
meta: updatedMeta,
|
||||
});
|
||||
if (!updated) {
|
||||
|
||||
@@ -296,6 +296,7 @@ async function handleJoinCreateGeneral(
|
||||
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
|
||||
},
|
||||
acceptedAt,
|
||||
operationalAcceptedAt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -366,7 +367,13 @@ async function handleSelectPoolCreate(
|
||||
if (!worldState) {
|
||||
throw new Error('Selection-pool world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command);
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt =
|
||||
command.acceptedGameTick !== undefined
|
||||
? ctx.world.gameTickToDate(command.acceptedGameTick)
|
||||
: command.acceptedGameAt !== undefined
|
||||
? new Date(command.acceptedGameAt)
|
||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolCreate',
|
||||
@@ -383,6 +390,7 @@ async function handleSelectPoolCreate(
|
||||
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
||||
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
|
||||
now: acceptedAt,
|
||||
operationalAcceptedAt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -1779,6 +1787,7 @@ async function handleSetMySetting(
|
||||
}
|
||||
for (const key of [
|
||||
'use_auto_nation_diplomacy',
|
||||
'use_auto_nation_war',
|
||||
'use_auto_nation_promotion',
|
||||
'use_auto_nation_finance',
|
||||
'use_auto_nation_capital',
|
||||
@@ -1988,7 +1997,7 @@ async function handleKick(
|
||||
}
|
||||
|
||||
const target = world.getGeneralById(command.destGeneralId);
|
||||
if (!target || target.id === general.id || target.nationId !== general.nationId) {
|
||||
if (!target || target.nationId !== general.nationId) {
|
||||
return {
|
||||
type: 'kick',
|
||||
ok: false,
|
||||
@@ -1996,7 +2005,18 @@ async function handleKick(
|
||||
reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.',
|
||||
};
|
||||
}
|
||||
if (resolveMaxSecretPermission(target) === 4 && resolvePermissionKind(target) === 'ambassador') {
|
||||
if (target.id === general.id) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '본인은 추방할 수 없습니다.' };
|
||||
}
|
||||
// Ref 화면은 군주와 본인을 후보에서 제외하지만 서버는 조작 요청을 막지 못했다.
|
||||
// 국가 소유권을 깨뜨리는 대상은 UI와 무관하게 durable command 경계에서 거부한다.
|
||||
if (target.id === nation.chiefGeneralId || target.officerLevel === 12) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '군주는 추방할 수 없습니다.' };
|
||||
}
|
||||
if (target.officerLevel >= 5) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '수뇌는 추방할 수 없습니다.' };
|
||||
}
|
||||
if (resolvePermissionKind(target) === 'ambassador') {
|
||||
return {
|
||||
type: 'kick',
|
||||
ok: false,
|
||||
|
||||
Reference in New Issue
Block a user