Merge branch 'main' into feature/general-icon-names
This commit is contained in:
@@ -55,7 +55,7 @@ export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}):
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let job: BattleSimJob | null = null;
|
let job: BattleSimJob;
|
||||||
try {
|
try {
|
||||||
job = JSON.parse(raw) as BattleSimJob;
|
job = JSON.parse(raw) as BattleSimJob;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import type { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||||
|
|
||||||
const zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']);
|
|
||||||
|
|
||||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||||
const nation = await ctx.db.nation.findUnique({
|
const nation = await ctx.db.nation.findUnique({
|
||||||
where: { id: nationId },
|
where: { id: nationId },
|
||||||
@@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], n
|
|||||||
return resolveNationPermission(general, nation.meta, true);
|
return resolveNationPermission(general, nation.meta, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapLetterState = (state: string): z.infer<typeof zLetterState> => {
|
const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | 'REPLACED' => {
|
||||||
if (state === 'ACTIVATED') return 'ACTIVATED';
|
if (state === 'ACTIVATED') return 'ACTIVATED';
|
||||||
if (state === 'CANCELLED') return 'CANCELLED';
|
if (state === 'CANCELLED') return 'CANCELLED';
|
||||||
if (state === 'REPLACED') return 'REPLACED';
|
if (state === 'REPLACED') return 'REPLACED';
|
||||||
@@ -153,7 +151,10 @@ export const diplomacyRouter = router({
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (newer) {
|
if (newer) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' });
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '해당 문서에 대한 새로운 문서가 이미 있습니다.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevLetter.state === 'PROPOSED') {
|
if (prevLetter.state === 'PROPOSED') {
|
||||||
@@ -169,7 +170,8 @@ export const diplomacyRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
destNationId =
|
||||||
|
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nations = await ctx.db.nation.findMany({
|
const nations = await ctx.db.nation.findMany({
|
||||||
|
|||||||
@@ -8,8 +8,29 @@ import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
|||||||
|
|
||||||
import { authedProcedure, procedure, router } from '../../trpc.js';
|
import { authedProcedure, procedure, router } from '../../trpc.js';
|
||||||
|
|
||||||
const DEFAULT_BG_COLOR = '#2b2b2b';
|
const DEFAULT_BG_COLOR = '#330000';
|
||||||
const DEFAULT_FG_COLOR = '#ffffff';
|
const DEFAULT_FG_COLOR = '#ffffff';
|
||||||
|
const NEUTRAL_BG_COLOR = '#000000';
|
||||||
|
const LEGACY_WHITE_TEXT_COLORS = new Set([
|
||||||
|
'',
|
||||||
|
'#330000',
|
||||||
|
'#FF0000',
|
||||||
|
'#800000',
|
||||||
|
'#A0522D',
|
||||||
|
'#FF6347',
|
||||||
|
'#808000',
|
||||||
|
'#008000',
|
||||||
|
'#2E8B57',
|
||||||
|
'#008080',
|
||||||
|
'#6495ED',
|
||||||
|
'#0000FF',
|
||||||
|
'#000080',
|
||||||
|
'#483D8B',
|
||||||
|
'#7B68EE',
|
||||||
|
'#800080',
|
||||||
|
'#A9A9A9',
|
||||||
|
'#000000',
|
||||||
|
]);
|
||||||
|
|
||||||
const readMetaNumber = (value: unknown): number => {
|
const readMetaNumber = (value: unknown): number => {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
@@ -24,7 +45,17 @@ const readMetaNumber = (value: unknown): number => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`;
|
export const formatLegacyRankingNumber = (value: number, fractionDigits = 0): string =>
|
||||||
|
new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: fractionDigits,
|
||||||
|
maximumFractionDigits: fractionDigits,
|
||||||
|
useGrouping: true,
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
export const resolveLegacyTextColor = (backgroundColor: string): string =>
|
||||||
|
LEGACY_WHITE_TEXT_COLORS.has(backgroundColor.toUpperCase()) ? '#ffffff' : '#000000';
|
||||||
|
|
||||||
|
const percentText = (value: number): string => `${formatLegacyRankingNumber(value * 100, 2)}%`;
|
||||||
|
|
||||||
const readOwnerDisplayName = (value: unknown): string | null => {
|
const readOwnerDisplayName = (value: unknown): string | null => {
|
||||||
const meta = asRecord(value);
|
const meta = asRecord(value);
|
||||||
@@ -72,6 +103,7 @@ export const rankingRouter = router({
|
|||||||
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
|
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
|
||||||
ctx.db.general.findMany({
|
ctx.db.general.findMany({
|
||||||
where: { npcState: npcFilter },
|
where: { npcState: npcFilter },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -133,11 +165,11 @@ export const rankingRouter = router({
|
|||||||
}
|
}
|
||||||
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
|
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
|
||||||
}],
|
}],
|
||||||
['보 병 숙 련 도', 'int', (_g, r) => r.dex1 ?? 0],
|
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
|
||||||
['궁 병 숙 련 도', 'int', (_g, r) => r.dex2 ?? 0],
|
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
|
||||||
['기 병 숙 련 도', 'int', (_g, r) => r.dex3 ?? 0],
|
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
|
||||||
['귀 병 숙 련 도', 'int', (_g, r) => r.dex4 ?? 0],
|
['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
|
||||||
['차 병 숙 련 도', 'int', (_g, r) => r.dex5 ?? 0],
|
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
|
||||||
['전 력 전 승 률', 'percent', (_g, r) => {
|
['전 력 전 승 률', 'percent', (_g, r) => {
|
||||||
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
|
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
|
||||||
if (total < 50) {
|
if (total < 50) {
|
||||||
@@ -186,17 +218,20 @@ export const rankingRouter = router({
|
|||||||
const ranks = rankMap.get(general.id) ?? {};
|
const ranks = rankMap.get(general.id) ?? {};
|
||||||
const value = valueFn(general, ranks);
|
const value = valueFn(general, ranks);
|
||||||
const nation = nationMap.get(general.nationId) ?? null;
|
const nation = nationMap.get(general.nationId) ?? null;
|
||||||
|
const bgColor =
|
||||||
|
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||||
let display = {
|
let display = {
|
||||||
id: general.id,
|
id: general.id,
|
||||||
name: general.name,
|
name: general.name,
|
||||||
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
|
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
|
||||||
nationName: nation?.name ?? '재야',
|
nationName: nation?.name ?? '재야',
|
||||||
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
bgColor,
|
||||||
fgColor: DEFAULT_FG_COLOR,
|
fgColor: resolveLegacyTextColor(bgColor),
|
||||||
picture: general.picture ?? null,
|
picture: general.picture ?? null,
|
||||||
imageServer: general.imageServer ?? 0,
|
imageServer: general.imageServer ?? 0,
|
||||||
value,
|
value,
|
||||||
printValue: valueType === 'percent' ? percentText(value) : Math.floor(value).toLocaleString('ko-KR'),
|
printValue:
|
||||||
|
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
|
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
|
||||||
@@ -206,7 +241,7 @@ export const rankingRouter = router({
|
|||||||
ownerName: null,
|
ownerName: null,
|
||||||
nationName: '???',
|
nationName: '???',
|
||||||
bgColor: DEFAULT_BG_COLOR,
|
bgColor: DEFAULT_BG_COLOR,
|
||||||
fgColor: DEFAULT_FG_COLOR,
|
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
|
||||||
picture: null,
|
picture: null,
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
};
|
};
|
||||||
@@ -268,12 +303,14 @@ export const rankingRouter = router({
|
|||||||
})
|
})
|
||||||
.map((general) => {
|
.map((general) => {
|
||||||
const nation = nationMap.get(general.nationId) ?? null;
|
const nation = nationMap.get(general.nationId) ?? null;
|
||||||
|
const bgColor =
|
||||||
|
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||||
return {
|
return {
|
||||||
id: general.id,
|
id: general.id,
|
||||||
name: general.name,
|
name: general.name,
|
||||||
nationName: nation?.name ?? '재야',
|
nationName: nation?.name ?? '재야',
|
||||||
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
bgColor,
|
||||||
fgColor: DEFAULT_FG_COLOR,
|
fgColor: resolveLegacyTextColor(bgColor),
|
||||||
picture: general.picture ?? null,
|
picture: general.picture ?? null,
|
||||||
imageServer: general.imageServer ?? 0,
|
imageServer: general.imageServer ?? 0,
|
||||||
};
|
};
|
||||||
@@ -299,7 +336,7 @@ export const rankingRouter = router({
|
|||||||
name: '미발견',
|
name: '미발견',
|
||||||
nationName: '-',
|
nationName: '-',
|
||||||
bgColor: DEFAULT_BG_COLOR,
|
bgColor: DEFAULT_BG_COLOR,
|
||||||
fgColor: DEFAULT_FG_COLOR,
|
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
|
||||||
picture: null,
|
picture: null,
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
},
|
},
|
||||||
@@ -398,7 +435,10 @@ export const rankingRouter = router({
|
|||||||
picture: typeof aux.picture === 'string' ? aux.picture : null,
|
picture: typeof aux.picture === 'string' ? aux.picture : null,
|
||||||
imageServer: readMetaNumber(aux.imgsvr),
|
imageServer: readMetaNumber(aux.imgsvr),
|
||||||
value: row.value,
|
value: row.value,
|
||||||
printValue: type.type === 'percent' ? percentText(row.value) : Math.floor(row.value).toLocaleString('ko-KR'),
|
printValue:
|
||||||
|
type.type === 'percent'
|
||||||
|
? percentText(row.value)
|
||||||
|
: formatLegacyRankingNumber(row.value),
|
||||||
serverName: String(aux.serverName ?? ''),
|
serverName: String(aux.serverName ?? ''),
|
||||||
serverIdx: readMetaNumber(aux.serverIdx),
|
serverIdx: readMetaNumber(aux.serverIdx),
|
||||||
scenarioName: String(aux.scenarioName ?? ''),
|
scenarioName: String(aux.scenarioName ?? ''),
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
|||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
||||||
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
|
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
|
||||||
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
|
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
|
||||||
|
npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], 0.01),
|
||||||
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.j
|
|||||||
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
|
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
|
||||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
import { formatLegacyRankingNumber, resolveLegacyTextColor } from '../src/router/ranking/index.js';
|
||||||
|
|
||||||
const profile: GameProfile = {
|
const profile: GameProfile = {
|
||||||
id: 'che',
|
id: 'che',
|
||||||
@@ -40,7 +41,7 @@ const generalRows = [
|
|||||||
npcState: 0,
|
npcState: 0,
|
||||||
picture: '1.jpg',
|
picture: '1.jpg',
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
meta: { ownerName: '공개소유자' },
|
meta: { ownerName: '공개소유자', dex1: 120 },
|
||||||
experience: 1200,
|
experience: 1200,
|
||||||
dedication: 900,
|
dedication: 900,
|
||||||
horseCode: 'che_명마_15_적토마',
|
horseCode: 'che_명마_15_적토마',
|
||||||
@@ -56,7 +57,7 @@ const generalRows = [
|
|||||||
npcState: 1,
|
npcState: 1,
|
||||||
picture: null,
|
picture: null,
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
meta: { owner_name: '빙의소유자' },
|
meta: { owner_name: '빙의소유자', dex1: 80 },
|
||||||
experience: 1100,
|
experience: 1100,
|
||||||
dedication: 800,
|
dedication: 800,
|
||||||
horseCode: 'None',
|
horseCode: 'None',
|
||||||
@@ -72,7 +73,7 @@ const generalRows = [
|
|||||||
npcState: 2,
|
npcState: 2,
|
||||||
picture: null,
|
picture: null,
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
meta: {},
|
meta: { dex1: 200 },
|
||||||
experience: 1300,
|
experience: 1300,
|
||||||
dedication: 1000,
|
dedication: 1000,
|
||||||
horseCode: 'None',
|
horseCode: 'None',
|
||||||
@@ -122,6 +123,9 @@ const buildContext = (options?: {
|
|||||||
{ generalId: 1, type: 'firenum', value: 10 },
|
{ generalId: 1, type: 'firenum', value: 10 },
|
||||||
{ generalId: 2, type: 'firenum', value: 20 },
|
{ generalId: 2, type: 'firenum', value: 20 },
|
||||||
{ generalId: 3, type: 'firenum', value: 30 },
|
{ generalId: 3, type: 'firenum', value: 30 },
|
||||||
|
{ generalId: 1, type: 'dex1', value: 999 },
|
||||||
|
{ generalId: 2, type: 'dex1', value: 999 },
|
||||||
|
{ generalId: 3, type: 'dex1', value: 999 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
auction: {
|
auction: {
|
||||||
@@ -218,6 +222,27 @@ describe('ranking.getBestGeneral', () => {
|
|||||||
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
|
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
|
||||||
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
|
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the general dex columns as the legacy source of truth instead of mirrored rank rows', async () => {
|
||||||
|
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'user' });
|
||||||
|
const dex = result.sections.find((section) => section.title === '보 병 숙 련 도');
|
||||||
|
|
||||||
|
expect(dex?.entries.map((entry) => [entry.id, entry.value, entry.printValue])).toEqual([
|
||||||
|
[1, 120, '120'],
|
||||||
|
[2, 80, '80'],
|
||||||
|
]);
|
||||||
|
expect(dex?.entries[0]).toMatchObject({
|
||||||
|
bgColor: '#006400',
|
||||||
|
fgColor: '#000000',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches PHP number_format rounding and the legacy fixed color table', () => {
|
||||||
|
expect(formatLegacyRankingNumber(1.005, 2)).toBe('1.01');
|
||||||
|
expect(formatLegacyRankingNumber(12345.6, 2)).toBe('12,345.60');
|
||||||
|
expect(resolveLegacyTextColor('#006400')).toBe('#000000');
|
||||||
|
expect(resolveLegacyTextColor('#330000')).toBe('#ffffff');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ranking hall of fame', () => {
|
describe('ranking hall of fame', () => {
|
||||||
|
|||||||
@@ -711,7 +711,7 @@ export class GeneralAI {
|
|||||||
const leadership = this.general.stats.leadership;
|
const leadership = this.general.stats.leadership;
|
||||||
const strength = Math.max(this.general.stats.strength, 1);
|
const strength = Math.max(this.general.stats.strength, 1);
|
||||||
const intel = Math.max(this.general.stats.intelligence, 1);
|
const intel = Math.max(this.general.stats.intelligence, 1);
|
||||||
let genType = 0;
|
let genType: number;
|
||||||
|
|
||||||
if (strength >= intel) {
|
if (strength >= intel) {
|
||||||
genType = t무장;
|
genType = t무장;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const do부대전방발령 = (ai: GeneralAI) => {
|
|||||||
const force = ai.nationPolicy.combatForce[leader.id];
|
const force = ai.nationPolicy.combatForce[leader.id];
|
||||||
let [fromCityId, toCityId] = force;
|
let [fromCityId, toCityId] = force;
|
||||||
|
|
||||||
let targetCityId: number | null = null;
|
let targetCityId: number | null;
|
||||||
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
|
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
|
||||||
targetCityId = pickRandomCityId(ai, ai.frontCities);
|
targetCityId = pickRandomCityId(ai, ai.frontCities);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type LogEntryDraft,
|
type LogEntryDraft,
|
||||||
type MessageRecordDraft,
|
type MessageRecordDraft,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
@@ -33,6 +33,7 @@ import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence
|
|||||||
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
|
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||||
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
||||||
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
||||||
|
import { buildPersistedRankRows } from './rankData.js';
|
||||||
|
|
||||||
export interface DatabaseTurnHooks {
|
export interface DatabaseTurnHooks {
|
||||||
hooks: TurnDaemonHooks;
|
hooks: TurnDaemonHooks;
|
||||||
@@ -270,20 +271,6 @@ const toLegacyDatabaseInt = (value: number): number => {
|
|||||||
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
|
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
|
||||||
};
|
};
|
||||||
|
|
||||||
const readRankMetaNumber = (meta: Record<string, unknown>, key: string): number => {
|
|
||||||
const value = meta[key];
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
||||||
return toLegacyDatabaseInt(value);
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return toLegacyDatabaseInt(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LEGACY_INTEGER_GENERAL_META_KEYS = [
|
const LEGACY_INTEGER_GENERAL_META_KEYS = [
|
||||||
'leadership_exp',
|
'leadership_exp',
|
||||||
'strength_exp',
|
'strength_exp',
|
||||||
@@ -312,72 +299,10 @@ const buildPersistedGeneralMeta = (
|
|||||||
return asJson(meta);
|
return asJson(meta);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildRankRows = (
|
|
||||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
|
||||||
): Array<{ generalId: number; nationId: number; type: string; value: number }> => {
|
|
||||||
const meta = asRecord(general.meta);
|
|
||||||
const readMeta = (key: string) => readRankMetaNumber(meta, key);
|
|
||||||
const readRank = (key: string) => readRankMetaNumber(meta, `rank_${key}`);
|
|
||||||
|
|
||||||
const entries: Array<[RankDataType, number]> = [
|
|
||||||
['experience', toLegacyDatabaseInt(general.experience)],
|
|
||||||
['dedication', toLegacyDatabaseInt(general.dedication)],
|
|
||||||
['firenum', readMeta('firenum')],
|
|
||||||
['warnum', readRank('warnum')],
|
|
||||||
['killnum', readRank('killnum')],
|
|
||||||
['deathnum', readRank('deathnum')],
|
|
||||||
['occupied', readRank('occupied')],
|
|
||||||
['killcrew', readRank('killcrew')],
|
|
||||||
['deathcrew', readRank('deathcrew')],
|
|
||||||
['killcrew_person', readRank('killcrew_person')],
|
|
||||||
['deathcrew_person', readRank('deathcrew_person')],
|
|
||||||
['dex1', readMeta('dex1')],
|
|
||||||
['dex2', readMeta('dex2')],
|
|
||||||
['dex3', readMeta('dex3')],
|
|
||||||
['dex4', readMeta('dex4')],
|
|
||||||
['dex5', readMeta('dex5')],
|
|
||||||
['ttw', readMeta('ttw')],
|
|
||||||
['ttd', readMeta('ttd')],
|
|
||||||
['ttl', readMeta('ttl')],
|
|
||||||
['ttg', readMeta('ttg')],
|
|
||||||
['ttp', readMeta('ttp')],
|
|
||||||
['tlw', readMeta('tlw')],
|
|
||||||
['tld', readMeta('tld')],
|
|
||||||
['tll', readMeta('tll')],
|
|
||||||
['tlg', readMeta('tlg')],
|
|
||||||
['tlp', readMeta('tlp')],
|
|
||||||
['tsw', readMeta('tsw')],
|
|
||||||
['tsd', readMeta('tsd')],
|
|
||||||
['tsl', readMeta('tsl')],
|
|
||||||
['tsg', readMeta('tsg')],
|
|
||||||
['tsp', readMeta('tsp')],
|
|
||||||
['tiw', readMeta('tiw')],
|
|
||||||
['tid', readMeta('tid')],
|
|
||||||
['til', readMeta('til')],
|
|
||||||
['tig', readMeta('tig')],
|
|
||||||
['tip', readMeta('tip')],
|
|
||||||
['betgold', readMeta('betgold')],
|
|
||||||
['betwin', readMeta('betwin')],
|
|
||||||
['betwingold', readMeta('betwingold')],
|
|
||||||
['inherit_earned', readMeta('inherit_earned')],
|
|
||||||
['inherit_spent', readMeta('inherit_spent')],
|
|
||||||
['inherit_earned_dyn', readMeta('inherit_earned_dyn')],
|
|
||||||
['inherit_earned_act', readMeta('inherit_earned_act')],
|
|
||||||
['inherit_spent_dyn', readMeta('inherit_spent_dyn')],
|
|
||||||
];
|
|
||||||
|
|
||||||
return entries.map(([type, value]) => ({
|
|
||||||
generalId: general.id,
|
|
||||||
nationId: general.nationId,
|
|
||||||
type,
|
|
||||||
value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildInitialRankRows = (
|
const buildInitialRankRows = (
|
||||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||||
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
|
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
|
||||||
buildRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
|
buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
|
||||||
|
|
||||||
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
||||||
|
|
||||||
@@ -976,7 +901,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
if (createdGenerals.length > 0 || rankTargets.length > 0) {
|
if (createdGenerals.length > 0 || rankTargets.length > 0) {
|
||||||
const rankRows = [
|
const rankRows = [
|
||||||
...createdGenerals.flatMap(buildInitialRankRows),
|
...createdGenerals.flatMap(buildInitialRankRows),
|
||||||
...rankTargets.flatMap(buildRankRows),
|
...rankTargets.flatMap(buildPersistedRankRows),
|
||||||
];
|
];
|
||||||
await upsertRankRows(prisma, rankRows);
|
await upsertRankRows(prisma, rankRows);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const applyIncomeOutcome = (
|
|||||||
originOutcome: number
|
originOutcome: number
|
||||||
): { next: number; ratio: number; realOutcome: number } => {
|
): { next: number; ratio: number; realOutcome: number } => {
|
||||||
let next = current + income;
|
let next = current + income;
|
||||||
let realOutcome = 0;
|
let realOutcome: number;
|
||||||
if (next < baseResource) {
|
if (next < baseResource) {
|
||||||
realOutcome = 0;
|
realOutcome = 0;
|
||||||
next = baseResource;
|
next = baseResource;
|
||||||
@@ -139,14 +139,11 @@ const processIncomeForNation = (
|
|||||||
const trait = traitMap.get(nation.typeCode) ?? null;
|
const trait = traitMap.get(nation.typeCode) ?? null;
|
||||||
const incomeContext = buildNationIncomeContext(nation, trait);
|
const incomeContext = buildNationIncomeContext(nation, trait);
|
||||||
|
|
||||||
let income = 0;
|
const income =
|
||||||
if (type === 'gold') {
|
type === 'gold'
|
||||||
income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level)
|
||||||
} else {
|
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
|
||||||
income =
|
|
||||||
getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
|
|
||||||
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
|
||||||
}
|
|
||||||
|
|
||||||
const incomeValue = roundResource(income);
|
const incomeValue = roundResource(income);
|
||||||
const originOutcome = getOutcome(100, nationGenerals);
|
const originOutcome = getOutcome(100, nationGenerals);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
LEGACY_RANK_DATA_TYPES,
|
||||||
|
RANK_DATA_TYPES,
|
||||||
|
rankDataMetaKey,
|
||||||
|
type LegacyRankDataType,
|
||||||
|
type RankDataType,
|
||||||
|
} from '@sammo-ts/common';
|
||||||
|
|
||||||
|
export interface RankedGeneralState {
|
||||||
|
id: number;
|
||||||
|
nationId: number;
|
||||||
|
experience: number;
|
||||||
|
dedication: number;
|
||||||
|
meta: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistedRankRow {
|
||||||
|
generalId: number;
|
||||||
|
nationId: number;
|
||||||
|
type: RankDataType;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? { ...(value as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
const toLegacyDatabaseInt = (value: number): number => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readMetaNumber = (meta: Record<string, unknown>, key: string): number => {
|
||||||
|
const value = meta[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return toLegacyDatabaseInt(value);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? toLegacyDatabaseInt(parsed) : 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rankMetaKey = rankDataMetaKey;
|
||||||
|
|
||||||
|
export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRankRow[] => {
|
||||||
|
const meta = asRecord(general.meta);
|
||||||
|
return RANK_DATA_TYPES.map((type) => {
|
||||||
|
const value =
|
||||||
|
type === 'experience'
|
||||||
|
? toLegacyDatabaseInt(general.experience)
|
||||||
|
: type === 'dedication'
|
||||||
|
? toLegacyDatabaseInt(general.dedication)
|
||||||
|
: readMetaNumber(meta, rankMetaKey(type));
|
||||||
|
return {
|
||||||
|
generalId: general.id,
|
||||||
|
nationId: general.nationId,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildLegacyComparableRankRows = (
|
||||||
|
general: RankedGeneralState
|
||||||
|
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
|
||||||
|
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
|
||||||
|
return buildPersistedRankRows(general).filter(
|
||||||
|
(row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyPersistedRankRowsToMeta = (
|
||||||
|
rawMeta: Record<string, unknown>,
|
||||||
|
rows: ReadonlyArray<{ type: string; value: number }>
|
||||||
|
): void => {
|
||||||
|
const supportedTypes = new Set<string>(RANK_DATA_TYPES);
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.type === 'experience' || row.type === 'dedication' || !supportedTypes.has(row.type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rawMeta[rankMetaKey(row.type as RankDataType)] = row.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -37,6 +37,7 @@ const DEFAULT_BASE_GOLD = 0;
|
|||||||
const DEFAULT_BASE_RICE = 2000;
|
const DEFAULT_BASE_RICE = 2000;
|
||||||
const DEFAULT_GENERAL_MINIMUM_GOLD = 0;
|
const DEFAULT_GENERAL_MINIMUM_GOLD = 0;
|
||||||
const DEFAULT_GENERAL_MINIMUM_RICE = 500;
|
const DEFAULT_GENERAL_MINIMUM_RICE = 500;
|
||||||
|
const DEFAULT_NPC_SEIZURE_MESSAGE_PROB = 0.01;
|
||||||
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
|
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
|
||||||
|
|
||||||
const normalizeCode = (value: string | null | undefined): string | null => {
|
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||||
@@ -136,6 +137,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
|||||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
||||||
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD),
|
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD),
|
||||||
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE),
|
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE),
|
||||||
|
npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], DEFAULT_NPC_SEIZURE_MESSAGE_PROB),
|
||||||
maxResourceActionAmount: resolveNumber(
|
maxResourceActionAmount: resolveNumber(
|
||||||
constValues,
|
constValues,
|
||||||
['maxResourceActionAmount'],
|
['maxResourceActionAmount'],
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { asRecord, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js';
|
|||||||
import { buildActionContext } from './reservedTurnActionContext.js';
|
import { buildActionContext } from './reservedTurnActionContext.js';
|
||||||
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
|
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
|
||||||
import type { AiReservedTurnProvider } from './ai/types.js';
|
import type { AiReservedTurnProvider } from './ai/types.js';
|
||||||
|
import { rankMetaKey } from './rankData.js';
|
||||||
|
|
||||||
const DEFAULT_ACTION = '휴식';
|
const DEFAULT_ACTION = '휴식';
|
||||||
|
|
||||||
@@ -227,44 +228,11 @@ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
|
|||||||
|
|
||||||
const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => {
|
const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => {
|
||||||
const meta = { ...general.meta };
|
const meta = { ...general.meta };
|
||||||
for (const key of [
|
for (const type of LEGACY_RANK_DATA_TYPES) {
|
||||||
'firenum',
|
meta[rankMetaKey(type)] = 0;
|
||||||
'rank_warnum',
|
|
||||||
'rank_killnum',
|
|
||||||
'rank_deathnum',
|
|
||||||
'rank_occupied',
|
|
||||||
'rank_killcrew',
|
|
||||||
'rank_deathcrew',
|
|
||||||
'rank_killcrew_person',
|
|
||||||
'rank_deathcrew_person',
|
|
||||||
'rank_ttw',
|
|
||||||
'rank_ttd',
|
|
||||||
'rank_ttl',
|
|
||||||
'rank_ttg',
|
|
||||||
'rank_ttp',
|
|
||||||
'rank_tlw',
|
|
||||||
'rank_tld',
|
|
||||||
'rank_tll',
|
|
||||||
'rank_tlg',
|
|
||||||
'rank_tlp',
|
|
||||||
'rank_tsw',
|
|
||||||
'rank_tsd',
|
|
||||||
'rank_tsl',
|
|
||||||
'rank_tsg',
|
|
||||||
'rank_tsp',
|
|
||||||
'rank_tiw',
|
|
||||||
'rank_tid',
|
|
||||||
'rank_til',
|
|
||||||
'rank_tig',
|
|
||||||
'rank_tip',
|
|
||||||
'rank_betgold',
|
|
||||||
'rank_betwin',
|
|
||||||
'rank_betwingold',
|
|
||||||
'specage',
|
|
||||||
'specage2',
|
|
||||||
]) {
|
|
||||||
meta[key] = 0;
|
|
||||||
}
|
}
|
||||||
|
meta.specage = 0;
|
||||||
|
meta.specage2 = 0;
|
||||||
for (let dex = 1; dex <= 5; dex += 1) {
|
for (let dex = 1; dex <= 5; dex += 1) {
|
||||||
const key = `dex${dex}`;
|
const key = `dex${dex}`;
|
||||||
meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5);
|
meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5);
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ export const createUnificationHandler = (options: {
|
|||||||
sabotage,
|
sabotage,
|
||||||
dex,
|
dex,
|
||||||
unifier,
|
unifier,
|
||||||
unifierAward: general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
|
unifierAward:
|
||||||
|
general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -194,15 +195,11 @@ export const createUnificationHandler = (options: {
|
|||||||
const meta = asRecord(state.meta);
|
const meta = asRecord(state.meta);
|
||||||
|
|
||||||
const serverId =
|
const serverId =
|
||||||
typeof meta.serverId === 'string' && meta.serverId.trim()
|
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
|
||||||
? meta.serverId.trim()
|
|
||||||
: options.profileName;
|
|
||||||
const season = readMetaNumberOrNull(meta, 'season') ?? 1;
|
const season = readMetaNumberOrNull(meta, 'season') ?? 1;
|
||||||
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
|
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
|
||||||
const scenarioName =
|
const scenarioName =
|
||||||
typeof asRecord(meta.scenarioMeta).title === 'string'
|
typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : '';
|
||||||
? String(asRecord(meta.scenarioMeta).title)
|
|
||||||
: '';
|
|
||||||
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
|
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
|
||||||
const unitedTime = new Date().toISOString();
|
const unitedTime = new Date().toISOString();
|
||||||
|
|
||||||
@@ -307,14 +304,16 @@ export const createUnificationHandler = (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const [typeName, valueType] of hallTypes) {
|
for (const [typeName, valueType] of hallTypes) {
|
||||||
let value = 0;
|
const value =
|
||||||
if (valueType === 'natural') {
|
valueType === 'natural'
|
||||||
value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0;
|
? typeName === 'experience'
|
||||||
} else if (valueType === 'rank') {
|
? general.experience
|
||||||
value = ranks[typeName] ?? 0;
|
: typeName === 'dedication'
|
||||||
} else {
|
? general.dedication
|
||||||
value = calcValues[typeName] ?? 0;
|
: (ranks[typeName] ?? 0)
|
||||||
}
|
: valueType === 'rank'
|
||||||
|
? (ranks[typeName] ?? 0)
|
||||||
|
: (calcValues[typeName] ?? 0);
|
||||||
|
|
||||||
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
|
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
|
||||||
continue;
|
continue;
|
||||||
@@ -391,9 +390,7 @@ export const createUnificationHandler = (options: {
|
|||||||
const meta = asRecord(state.meta);
|
const meta = asRecord(state.meta);
|
||||||
|
|
||||||
const serverId =
|
const serverId =
|
||||||
typeof meta.serverId === 'string' && meta.serverId.trim()
|
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
|
||||||
? meta.serverId.trim()
|
|
||||||
: options.profileName;
|
|
||||||
const serverName =
|
const serverName =
|
||||||
typeof meta.serverName === 'string' && meta.serverName.trim()
|
typeof meta.serverName === 'string' && meta.serverName.trim()
|
||||||
? meta.serverName.trim()
|
? meta.serverName.trim()
|
||||||
@@ -676,7 +673,7 @@ export const createUnificationHandler = (options: {
|
|||||||
turnTime: general.turnTime,
|
turnTime: general.turnTime,
|
||||||
data: snapshot,
|
data: snapshot,
|
||||||
},
|
},
|
||||||
}))( {
|
}))({
|
||||||
...general,
|
...general,
|
||||||
turnTime: general.turnTime.toISOString(),
|
turnTime: general.turnTime.toISOString(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -315,8 +315,8 @@ async function handleTournamentMatchResult(
|
|||||||
const attackerG = getRankNumber(attacker, rankKey('g'));
|
const attackerG = getRankNumber(attacker, rankKey('g'));
|
||||||
const defenderG = getRankNumber(defender, rankKey('g'));
|
const defenderG = getRankNumber(defender, rankKey('g'));
|
||||||
|
|
||||||
let attackerGDelta = 0;
|
let attackerGDelta: number;
|
||||||
let defenderGDelta = 0;
|
let defenderGDelta: number;
|
||||||
let attackerW = 0;
|
let attackerW = 0;
|
||||||
let attackerD = 0;
|
let attackerD = 0;
|
||||||
let attackerL = 0;
|
let attackerL = 0;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
|
|||||||
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
|
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
|
||||||
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js';
|
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js';
|
||||||
import { readDiplomacyMeta } from '@sammo-ts/logic';
|
import { readDiplomacyMeta } from '@sammo-ts/logic';
|
||||||
|
import { applyPersistedRankRowsToMeta } from './rankData.js';
|
||||||
|
|
||||||
interface TurnWorldLoaderOptions {
|
interface TurnWorldLoaderOptions {
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
@@ -157,17 +158,6 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
|
|||||||
return parsed.data;
|
return parsed.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GENERAL_RANK_META_PREFIX_TYPES = new Set([
|
|
||||||
'warnum',
|
|
||||||
'killnum',
|
|
||||||
'deathnum',
|
|
||||||
'occupied',
|
|
||||||
'killcrew',
|
|
||||||
'deathcrew',
|
|
||||||
'killcrew_person',
|
|
||||||
'deathcrew_person',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const mapGeneralRow = (
|
const mapGeneralRow = (
|
||||||
row: TurnEngineGeneralRow,
|
row: TurnEngineGeneralRow,
|
||||||
rankRows: readonly TurnEngineRankDataRow[],
|
rankRows: readonly TurnEngineRankDataRow[],
|
||||||
@@ -181,12 +171,7 @@ const mapGeneralRow = (
|
|||||||
item: normalizeCode(row.itemCode),
|
item: normalizeCode(row.itemCode),
|
||||||
};
|
};
|
||||||
const rawMeta = { ...(asTriggerRecord(row.meta) as Record<string, unknown>) };
|
const rawMeta = { ...(asTriggerRecord(row.meta) as Record<string, unknown>) };
|
||||||
for (const rank of rankRows) {
|
applyPersistedRankRowsToMeta(rawMeta, rankRows);
|
||||||
if (rank.type === 'experience' || rank.type === 'dedication') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
rawMeta[GENERAL_RANK_META_PREFIX_TYPES.has(rank.type) ? `rank_${rank.type}` : rank.type] = rank.value;
|
|
||||||
}
|
|
||||||
const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value]));
|
const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value]));
|
||||||
const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots);
|
const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||||
|
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { rankMetaKey } from '../src/turn/rankData.js';
|
||||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||||
|
|
||||||
@@ -294,6 +296,9 @@ describe('legacy general turn lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('retires a player general and resets inherited stats and rank state', async () => {
|
it('retires a player general and resets inherited stats and rank state', async () => {
|
||||||
|
const legacyRankMeta = Object.fromEntries(
|
||||||
|
LEGACY_RANK_DATA_TYPES.map((type, index) => [rankMetaKey(type), index + 1])
|
||||||
|
);
|
||||||
const harness = await createTurnTestHarness({
|
const harness = await createTurnTestHarness({
|
||||||
snapshot: makeSnapshot([
|
snapshot: makeSnapshot([
|
||||||
makeGeneral({
|
makeGeneral({
|
||||||
@@ -302,11 +307,11 @@ describe('legacy general turn lifecycle', () => {
|
|||||||
experience: 101,
|
experience: 101,
|
||||||
dedication: 81,
|
dedication: 81,
|
||||||
meta: {
|
meta: {
|
||||||
|
...legacyRankMeta,
|
||||||
killturn: 24,
|
killturn: 24,
|
||||||
dex1: 101,
|
dex1: 101,
|
||||||
inherit_lived_month: 10,
|
inherit_lived_month: 10,
|
||||||
inherit_active_action: 4,
|
inherit_active_action: 4,
|
||||||
rank_warnum: 12,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -325,7 +330,9 @@ describe('legacy general turn lifecycle', () => {
|
|||||||
expect(updated.meta.dex1).toBe(51);
|
expect(updated.meta.dex1).toBe(51);
|
||||||
expect(updated.meta.inherit_lived_month).toBe(0);
|
expect(updated.meta.inherit_lived_month).toBe(0);
|
||||||
expect(updated.meta.inherit_active_action).toBe(0);
|
expect(updated.meta.inherit_active_action).toBe(0);
|
||||||
expect(updated.meta.rank_warnum).toBe(0);
|
for (const type of LEGACY_RANK_DATA_TYPES) {
|
||||||
|
expect(updated.meta[rankMetaKey(type)], type).toBe(0);
|
||||||
|
}
|
||||||
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
|
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', () => {
|
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', async () => {
|
||||||
const updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
|
const updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
|
||||||
const nations = [
|
const nations = [
|
||||||
{
|
{
|
||||||
@@ -188,7 +188,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
|
|||||||
}) as never,
|
}) as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
handler.beforeMonthChanged?.({} as never);
|
await handler.beforeMonthChanged?.({} as never);
|
||||||
|
|
||||||
expect(updates).toEqual([
|
expect(updates).toEqual([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -327,5 +327,5 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
// Nation awards can occur in the same tick and make the general's net
|
// Nation awards can occur in the same tick and make the general's net
|
||||||
// gold delta smaller than the recruitment price. Exact cost scaling is
|
// gold delta smaller than the recruitment price. Exact cost scaling is
|
||||||
// covered by the unit-set/action contract tests rather than this smoke.
|
// covered by the unit-set/action contract tests rather than this smoke.
|
||||||
}, 60000);
|
}, 300_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -553,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, 180000);
|
}, 360_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyPersistedRankRowsToMeta,
|
||||||
|
buildLegacyComparableRankRows,
|
||||||
|
buildPersistedRankRows,
|
||||||
|
rankMetaKey,
|
||||||
|
} from '../src/turn/rankData.js';
|
||||||
|
|
||||||
|
describe('rank data projection', () => {
|
||||||
|
it('projects every core row while preserving legacy key and integer semantics', () => {
|
||||||
|
const rows = buildPersistedRankRows({
|
||||||
|
id: 7,
|
||||||
|
nationId: 2,
|
||||||
|
experience: 10.5,
|
||||||
|
dedication: 20.49,
|
||||||
|
meta: {
|
||||||
|
rank_warnum: '3.5',
|
||||||
|
ttw: 4.5,
|
||||||
|
inherit_earned: '9',
|
||||||
|
dex1: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(RANK_DATA_TYPES.length);
|
||||||
|
expect(rows).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ generalId: 7, nationId: 2, type: 'experience', value: 11 },
|
||||||
|
{ generalId: 7, nationId: 2, type: 'dedication', value: 20 },
|
||||||
|
{ generalId: 7, nationId: 2, type: 'warnum', value: 4 },
|
||||||
|
{ generalId: 7, nationId: 2, type: 'ttw', value: 5 },
|
||||||
|
{ generalId: 7, nationId: 2, type: 'inherit_earned', value: 9 },
|
||||||
|
{ generalId: 7, nationId: 2, type: 'dex1', value: 12 },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(buildLegacyComparableRankRows({
|
||||||
|
id: 7,
|
||||||
|
nationId: 2,
|
||||||
|
experience: 10.5,
|
||||||
|
dedication: 20.49,
|
||||||
|
meta: {},
|
||||||
|
})).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads persisted rows into the same raw and prefixed meta keys used by commands', () => {
|
||||||
|
const meta: Record<string, unknown> = {};
|
||||||
|
applyPersistedRankRowsToMeta(meta, [
|
||||||
|
{ type: 'warnum', value: 3 },
|
||||||
|
{ type: 'ttw', value: 4 },
|
||||||
|
{ type: 'inherit_spent', value: 5 },
|
||||||
|
{ type: 'dex1', value: 6 },
|
||||||
|
{ type: 'unknown', value: 99 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(meta).toEqual({
|
||||||
|
rank_warnum: 3,
|
||||||
|
ttw: 4,
|
||||||
|
inherit_spent: 5,
|
||||||
|
dex1: 6,
|
||||||
|
});
|
||||||
|
expect(rankMetaKey('warnum')).toBe('rank_warnum');
|
||||||
|
expect(rankMetaKey('betgold')).toBe('betgold');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,5 +13,7 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['test/**/*.test.ts'],
|
include: ['test/**/*.test.ts'],
|
||||||
|
maxWorkers: 4,
|
||||||
|
testTimeout: 10_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
|||||||
import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes';
|
import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
general: GeneralDraft;
|
|
||||||
options: BattleSimOptions;
|
options: BattleSimOptions;
|
||||||
mode: 'attacker' | 'defender';
|
mode: 'attacker' | 'defender';
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,6 +10,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
const general = defineModel<GeneralDraft>('general', { required: true });
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'import'): void;
|
(event: 'import'): void;
|
||||||
|
|||||||
@@ -24,11 +24,10 @@ export const formatLog = (text?: string): string => {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let match: RegExpExecArray | null = null;
|
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
|
|
||||||
while ((match = logRegex.exec(text)) !== null) {
|
for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) {
|
||||||
const partAll = match[0];
|
const partAll = match[0];
|
||||||
const subPart = match[1];
|
const subPart = match[1];
|
||||||
const index = match.index;
|
const index = match.index;
|
||||||
@@ -40,9 +39,7 @@ export const formatLog = (text?: string): string => {
|
|||||||
if (subPart === '/') {
|
if (subPart === '/') {
|
||||||
result.push('</span>');
|
result.push('</span>');
|
||||||
} else if (subPart.length === 2) {
|
} else if (subPart.length === 2) {
|
||||||
result.push(
|
result.push(`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`);
|
||||||
`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
result.push(`<span style="${convertMap[subPart] ?? ''}">`);
|
result.push(`<span style="${convertMap[subPart] ?? ''}">`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1125,7 +1125,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
|
|
||||||
<BattleGeneralCard
|
<BattleGeneralCard
|
||||||
v-if="attackerGeneral"
|
v-if="attackerGeneral"
|
||||||
:general="attackerGeneral!"
|
v-model:general="attackerGeneral"
|
||||||
:options="options!"
|
:options="options!"
|
||||||
mode="attacker"
|
mode="attacker"
|
||||||
title="출병자 설정"
|
title="출병자 설정"
|
||||||
@@ -1193,7 +1193,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
<BattleGeneralCard
|
<BattleGeneralCard
|
||||||
v-for="(defender, index) in defenders"
|
v-for="(defender, index) in defenders"
|
||||||
:key="defender.id"
|
:key="defender.id"
|
||||||
:general="defender"
|
v-model:general="defenders[index]"
|
||||||
:options="options!"
|
:options="options!"
|
||||||
mode="defender"
|
mode="defender"
|
||||||
:title="`수비자 설정 ${index + 1}`"
|
:title="`수비자 설정 ${index + 1}`"
|
||||||
|
|||||||
@@ -189,9 +189,7 @@ const destroyLetter = async (letterId: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevOptions = computed(() =>
|
const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []);
|
||||||
data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
|
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
|
||||||
|
|
||||||
@@ -216,12 +214,14 @@ const canRollback = (letter: DiplomacyLetter) =>
|
|||||||
editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED';
|
editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED';
|
||||||
|
|
||||||
const canDestroy = (letter: DiplomacyLetter) =>
|
const canDestroy = (letter: DiplomacyLetter) =>
|
||||||
editable.value && letter.state === 'ACTIVATED' && (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
|
editable.value &&
|
||||||
|
letter.state === 'ACTIVATED' &&
|
||||||
|
(data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
|
||||||
|
|
||||||
const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED';
|
const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED';
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadLetters();
|
void loadLetters();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -270,26 +270,84 @@ onBeforeUnmount(() => {
|
|||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
<div class="editor-label">내용(국가 내 공개)</div>
|
<div class="editor-label">내용(국가 내 공개)</div>
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<button type="button" @click="briefEditor?.chain().focus().toggleBold().run()" :class="{ active: briefEditor?.isActive('bold') }">굵게</button>
|
<button
|
||||||
<button type="button" @click="briefEditor?.chain().focus().toggleItalic().run()" :class="{ active: briefEditor?.isActive('italic') }">기울임</button>
|
type="button"
|
||||||
<button type="button" @click="briefEditor?.chain().focus().toggleUnderline().run()" :class="{ active: briefEditor?.isActive('underline') }">밑줄</button>
|
@click="briefEditor?.chain().focus().toggleBold().run()"
|
||||||
|
:class="{ active: briefEditor?.isActive('bold') }"
|
||||||
|
>
|
||||||
|
굵게
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="briefEditor?.chain().focus().toggleItalic().run()"
|
||||||
|
:class="{ active: briefEditor?.isActive('italic') }"
|
||||||
|
>
|
||||||
|
기울임
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="briefEditor?.chain().focus().toggleUnderline().run()"
|
||||||
|
:class="{ active: briefEditor?.isActive('underline') }"
|
||||||
|
>
|
||||||
|
밑줄
|
||||||
|
</button>
|
||||||
<button type="button" @click="addLink('brief')">링크</button>
|
<button type="button" @click="addLink('brief')">링크</button>
|
||||||
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
||||||
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
|
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">
|
||||||
<button type="button" @click="uploadTarget = 'brief'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
|
번호 목록
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="
|
||||||
|
uploadTarget = 'brief';
|
||||||
|
fileInputRef?.click();
|
||||||
|
"
|
||||||
|
:disabled="uploadBusy"
|
||||||
|
>
|
||||||
|
이미지 업로드
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<EditorContent v-if="briefEditor" :editor="briefEditor" />
|
<EditorContent v-if="briefEditor" :editor="briefEditor" />
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
<div class="editor-label">내용(외교권자 전용)</div>
|
<div class="editor-label">내용(외교권자 전용)</div>
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<button type="button" @click="detailEditor?.chain().focus().toggleBold().run()" :class="{ active: detailEditor?.isActive('bold') }">굵게</button>
|
<button
|
||||||
<button type="button" @click="detailEditor?.chain().focus().toggleItalic().run()" :class="{ active: detailEditor?.isActive('italic') }">기울임</button>
|
type="button"
|
||||||
<button type="button" @click="detailEditor?.chain().focus().toggleUnderline().run()" :class="{ active: detailEditor?.isActive('underline') }">밑줄</button>
|
@click="detailEditor?.chain().focus().toggleBold().run()"
|
||||||
|
:class="{ active: detailEditor?.isActive('bold') }"
|
||||||
|
>
|
||||||
|
굵게
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="detailEditor?.chain().focus().toggleItalic().run()"
|
||||||
|
:class="{ active: detailEditor?.isActive('italic') }"
|
||||||
|
>
|
||||||
|
기울임
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="detailEditor?.chain().focus().toggleUnderline().run()"
|
||||||
|
:class="{ active: detailEditor?.isActive('underline') }"
|
||||||
|
>
|
||||||
|
밑줄
|
||||||
|
</button>
|
||||||
<button type="button" @click="addLink('detail')">링크</button>
|
<button type="button" @click="addLink('detail')">링크</button>
|
||||||
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</button>
|
||||||
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button>
|
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">
|
||||||
<button type="button" @click="uploadTarget = 'detail'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button>
|
번호 목록
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="
|
||||||
|
uploadTarget = 'detail';
|
||||||
|
fileInputRef?.click();
|
||||||
|
"
|
||||||
|
:disabled="uploadBusy"
|
||||||
|
>
|
||||||
|
이미지 업로드
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<EditorContent v-if="detailEditor" :editor="detailEditor" />
|
<EditorContent v-if="detailEditor" :editor="detailEditor" />
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +385,10 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
<div v-if="historyOpen[letter.id]" class="history-panel">
|
<div v-if="historyOpen[letter.id]" class="history-panel">
|
||||||
<template v-if="getPrevLetter(letter)">
|
<template v-if="getPrevLetter(letter)">
|
||||||
<p>#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} ↔ {{ getPrevLetter(letter)?.dest.nationName }}</p>
|
<p>
|
||||||
|
#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} ↔
|
||||||
|
{{ getPrevLetter(letter)?.dest.nationName }}
|
||||||
|
</p>
|
||||||
<div class="letter-text" v-html="getPrevLetter(letter)?.brief" />
|
<div class="letter-text" v-html="getPrevLetter(letter)?.brief" />
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="hint">이전 문서를 찾을 수 없습니다.</p>
|
<p v-else class="hint">이전 문서를 찾을 수 없습니다.</p>
|
||||||
@@ -335,11 +396,24 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer class="letter-actions">
|
<footer class="letter-actions">
|
||||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">승인</button>
|
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">
|
||||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">거부</button>
|
승인
|
||||||
|
</button>
|
||||||
|
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">
|
||||||
|
거부
|
||||||
|
</button>
|
||||||
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
|
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
|
||||||
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
|
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
|
||||||
<button v-if="canRenew(letter)" type="button" @click="selectedPrevId = letter.id; applyPrevLetter()">추가 문서 작성</button>
|
<button
|
||||||
|
v-if="canRenew(letter)"
|
||||||
|
type="button"
|
||||||
|
@click="
|
||||||
|
selectedPrevId = letter.id;
|
||||||
|
applyPrevLetter();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
추가 문서 작성
|
||||||
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData();
|
void loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -278,19 +278,17 @@ onBeforeUnmount(() => {
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>국가 방침</h2>
|
<h2>국가 방침</h2>
|
||||||
<div class="panel-actions">
|
<div class="panel-actions">
|
||||||
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">
|
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">수정</button>
|
||||||
수정
|
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">저장</button>
|
||||||
</button>
|
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">취소</button>
|
||||||
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">
|
|
||||||
저장
|
|
||||||
</button>
|
|
||||||
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="editingNationMsg" class="editor-toolbar">
|
<div v-if="editingNationMsg" class="editor-toolbar">
|
||||||
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="editor?.chain().focus().toggleBold().run()"
|
||||||
|
:class="{ active: editor?.isActive('bold') }"
|
||||||
|
>
|
||||||
굵게
|
굵게
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -323,21 +321,57 @@ onBeforeUnmount(() => {
|
|||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<h3>자금 예산</h3>
|
<h3>자금 예산</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>현재</dt><dd>{{ data.gold.toLocaleString() }}</dd></div>
|
<div>
|
||||||
<div><dt>단기 수입</dt><dd>{{ data.income.gold.war.toLocaleString() }}</dd></div>
|
<dt>현재</dt>
|
||||||
<div><dt>세금</dt><dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd></div>
|
<dd>{{ data.gold.toLocaleString() }}</dd>
|
||||||
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeGold).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
|
</div>
|
||||||
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd></div>
|
<div>
|
||||||
|
<dt>단기 수입</dt>
|
||||||
|
<dd>{{ data.income.gold.war.toLocaleString() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>세금</dt>
|
||||||
|
<dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>수입/지출</dt>
|
||||||
|
<dd>
|
||||||
|
+{{ Math.floor(incomeGold).toLocaleString() }} /
|
||||||
|
{{ Math.floor(-outcomeByBill).toLocaleString() }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>국고 예산</dt>
|
||||||
|
<dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<h3>군량 예산</h3>
|
<h3>군량 예산</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>현재</dt><dd>{{ data.rice.toLocaleString() }}</dd></div>
|
<div>
|
||||||
<div><dt>둔전 수입</dt><dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd></div>
|
<dt>현재</dt>
|
||||||
<div><dt>세금</dt><dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd></div>
|
<dd>{{ data.rice.toLocaleString() }}</dd>
|
||||||
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeRice).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
|
</div>
|
||||||
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd></div>
|
<div>
|
||||||
|
<dt>둔전 수입</dt>
|
||||||
|
<dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>세금</dt>
|
||||||
|
<dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>수입/지출</dt>
|
||||||
|
<dd>
|
||||||
|
+{{ Math.floor(incomeRice).toLocaleString() }} /
|
||||||
|
{{ Math.floor(-outcomeByBill).toLocaleString() }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>국고 예산</dt>
|
||||||
|
<dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
@@ -359,7 +393,13 @@ onBeforeUnmount(() => {
|
|||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<h3>기밀 권한</h3>
|
<h3>기밀 권한</h3>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input v-model.number="policyDraft.secretLimit" type="number" min="1" max="99" :disabled="!editable" />
|
<input
|
||||||
|
v-model.number="policyDraft.secretLimit"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="99"
|
||||||
|
:disabled="!editable"
|
||||||
|
/>
|
||||||
<span>년</span>
|
<span>년</span>
|
||||||
<button type="button" @click="setSecretLimit" :disabled="!editable">변경</button>
|
<button type="button" @click="setSecretLimit" :disabled="!editable">변경</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,7 +416,9 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
전쟁 금지
|
전쟁 금지
|
||||||
</label>
|
</label>
|
||||||
<span class="hint">잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회)</span>
|
<span class="hint"
|
||||||
|
>잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회)</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData();
|
void loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -168,7 +168,11 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="editing" class="editor-toolbar">
|
<div v-if="editing" class="editor-toolbar">
|
||||||
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="editor?.chain().focus().toggleBold().run()"
|
||||||
|
:class="{ active: editor?.isActive('bold') }"
|
||||||
|
>
|
||||||
굵게
|
굵게
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한
|
|||||||
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
|
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
|
||||||
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
|
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
|
||||||
비교한다.
|
비교한다.
|
||||||
|
|
||||||
|
2026-07-26부터 canonical snapshot은 관찰 장수의 `rank_data`도 비교한다.
|
||||||
|
ref의 `RankColumn` 37종만 의미 행으로 정규화하며, ref에서 자연 `general`
|
||||||
|
column인 경험·공헌·숙련을 위해 core가 보유한 7개 mirror row는 비교에서
|
||||||
|
제외한다. 화계 fixture는 같은 초기 `firenum`에서 성공 명령 뒤 양쪽이
|
||||||
|
동일하게 1 증가하는지 확인한다. 은퇴 fixture는 37종 전부를 서로 다른
|
||||||
|
비영 값으로 채운 뒤 양쪽이 전부 0으로 만드는지 확인한다. 이 검증으로
|
||||||
|
일반 명령 snapshot에서 누락됐던 명장일람 누적치와 은퇴 후 메모리→DB
|
||||||
|
재저장 경로를 관찰한다.
|
||||||
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
|
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
|
||||||
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
|
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
|
||||||
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
|
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
|
||||||
|
|||||||
@@ -47,6 +47,71 @@ export const RANK_DATA_TYPES = [
|
|||||||
|
|
||||||
export type RankDataType = (typeof RANK_DATA_TYPES)[number];
|
export type RankDataType = (typeof RANK_DATA_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy `sammo\Enums\RankColumn` values stored in `rank_data`.
|
||||||
|
*
|
||||||
|
* `experience`, `dedication`, and `dex1` through `dex5` are natural general
|
||||||
|
* columns in the reference implementation. core2026 currently keeps mirrored
|
||||||
|
* rank rows for those values as a compatibility cache, but differential
|
||||||
|
* snapshots must compare this legacy set rather than treating the mirrors as
|
||||||
|
* source-of-truth rows.
|
||||||
|
*/
|
||||||
|
export const LEGACY_RANK_DATA_TYPES = [
|
||||||
|
'firenum',
|
||||||
|
'warnum',
|
||||||
|
'killnum',
|
||||||
|
'deathnum',
|
||||||
|
'killcrew',
|
||||||
|
'deathcrew',
|
||||||
|
'ttw',
|
||||||
|
'ttd',
|
||||||
|
'ttl',
|
||||||
|
'ttg',
|
||||||
|
'ttp',
|
||||||
|
'tlw',
|
||||||
|
'tld',
|
||||||
|
'tll',
|
||||||
|
'tlg',
|
||||||
|
'tlp',
|
||||||
|
'tsw',
|
||||||
|
'tsd',
|
||||||
|
'tsl',
|
||||||
|
'tsg',
|
||||||
|
'tsp',
|
||||||
|
'tiw',
|
||||||
|
'tid',
|
||||||
|
'til',
|
||||||
|
'tig',
|
||||||
|
'tip',
|
||||||
|
'betwin',
|
||||||
|
'betgold',
|
||||||
|
'betwingold',
|
||||||
|
'killcrew_person',
|
||||||
|
'deathcrew_person',
|
||||||
|
'occupied',
|
||||||
|
'inherit_earned',
|
||||||
|
'inherit_spent',
|
||||||
|
'inherit_earned_dyn',
|
||||||
|
'inherit_earned_act',
|
||||||
|
'inherit_spent_dyn',
|
||||||
|
] as const satisfies readonly RankDataType[];
|
||||||
|
|
||||||
|
export type LegacyRankDataType = (typeof LEGACY_RANK_DATA_TYPES)[number];
|
||||||
|
|
||||||
|
const PREFIXED_RANK_DATA_TYPES = new Set<RankDataType>([
|
||||||
|
'warnum',
|
||||||
|
'killnum',
|
||||||
|
'deathnum',
|
||||||
|
'occupied',
|
||||||
|
'killcrew',
|
||||||
|
'deathcrew',
|
||||||
|
'killcrew_person',
|
||||||
|
'deathcrew_person',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const rankDataMetaKey = (type: RankDataType): string =>
|
||||||
|
PREFIXED_RANK_DATA_TYPES.has(type) ? `rank_${type}` : type;
|
||||||
|
|
||||||
export const HALL_OF_FAME_TYPES = [
|
export const HALL_OF_FAME_TYPES = [
|
||||||
'experience',
|
'experience',
|
||||||
'dedication',
|
'dedication',
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface TurnCommandEnv {
|
|||||||
baseRice: number;
|
baseRice: number;
|
||||||
generalMinimumGold?: number;
|
generalMinimumGold?: number;
|
||||||
generalMinimumRice?: number;
|
generalMinimumRice?: number;
|
||||||
|
npcSeizureMessageProb?: number;
|
||||||
maxResourceActionAmount: number;
|
maxResourceActionAmount: number;
|
||||||
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
||||||
generalActionModules?: Array<GeneralActionModule>;
|
generalActionModules?: Array<GeneralActionModule>;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
|||||||
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil, LEGACY_RANK_DATA_TYPES, rankDataMetaKey } from '@sammo-ts/common';
|
||||||
|
|
||||||
export interface RetireArgs {}
|
export interface RetireArgs {}
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ const ACTION_NAME = '은퇴';
|
|||||||
const ACTION_KEY = 'che_은퇴';
|
const ACTION_KEY = 'che_은퇴';
|
||||||
|
|
||||||
const REQ_AGE = 60;
|
const REQ_AGE = 60;
|
||||||
|
|
||||||
const reqGeneralValue = (): Constraint => ({
|
const reqGeneralValue = (): Constraint => ({
|
||||||
name: 'reqGeneralValue',
|
name: 'reqGeneralValue',
|
||||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||||
@@ -51,46 +50,8 @@ export class ActionResolver<
|
|||||||
}
|
}
|
||||||
nextMeta.specAge = 0;
|
nextMeta.specAge = 0;
|
||||||
nextMeta.specAge2 = 0;
|
nextMeta.specAge2 = 0;
|
||||||
nextMeta.firenum = 0;
|
for (const type of LEGACY_RANK_DATA_TYPES) {
|
||||||
for (const key of [
|
nextMeta[rankDataMetaKey(type)] = 0;
|
||||||
'warnum',
|
|
||||||
'killnum',
|
|
||||||
'deathnum',
|
|
||||||
'killcrew',
|
|
||||||
'deathcrew',
|
|
||||||
'ttw',
|
|
||||||
'ttd',
|
|
||||||
'ttl',
|
|
||||||
'ttg',
|
|
||||||
'ttp',
|
|
||||||
'tlw',
|
|
||||||
'tld',
|
|
||||||
'tll',
|
|
||||||
'tlg',
|
|
||||||
'tlp',
|
|
||||||
'tsw',
|
|
||||||
'tsd',
|
|
||||||
'tsl',
|
|
||||||
'tsg',
|
|
||||||
'tsp',
|
|
||||||
'tiw',
|
|
||||||
'tid',
|
|
||||||
'til',
|
|
||||||
'tig',
|
|
||||||
'tip',
|
|
||||||
'betwin',
|
|
||||||
'betgold',
|
|
||||||
'betwingold',
|
|
||||||
'killcrew_person',
|
|
||||||
'deathcrew_person',
|
|
||||||
'occupied',
|
|
||||||
'inherit_earned',
|
|
||||||
'inherit_spent',
|
|
||||||
'inherit_earned_dyn',
|
|
||||||
'inherit_earned_act',
|
|
||||||
'inherit_spent_dyn',
|
|
||||||
]) {
|
|
||||||
nextMeta[`rank_${key}`] = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const josaYi = JosaUtil.pick(general.name, '이');
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import type {
|
|||||||
GeneralActionOutcome,
|
GeneralActionOutcome,
|
||||||
GeneralActionResolveContext,
|
GeneralActionResolveContext,
|
||||||
} from '@sammo-ts/logic/actions/engine.js';
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createLogEffect, createNationPatchEffect, createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js';
|
import {
|
||||||
|
createGeneralPatchEffect,
|
||||||
|
createLogEffect,
|
||||||
|
createMessageEffect,
|
||||||
|
createNationPatchEffect,
|
||||||
|
} from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
@@ -37,9 +42,40 @@ export interface SeizureResolveContext<
|
|||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> extends GeneralActionResolveContext<TriggerState> {
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
destGeneral: General<TriggerState>;
|
destGeneral: General<TriggerState>;
|
||||||
|
messageTime: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '몰수';
|
const ACTION_NAME = '몰수';
|
||||||
|
const NPC_SEIZURE_MESSAGE_PROB = 0.01;
|
||||||
|
const NPC_SEIZURE_MESSAGES = [
|
||||||
|
'몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...',
|
||||||
|
'사유재산까지 몰수해가면서 이 나라가 잘 될거라 믿습니까? 정말 이해할 수가 없군요...',
|
||||||
|
'내 돈 내놔라! 내 돈! 몰수가 웬 말이냐!',
|
||||||
|
'몰수해간 내 자금... 언젠가 몰래 다시 빼내올 것이다...',
|
||||||
|
'몰수로 인한 사기 저하는 몰수로 얻은 물자보다 더 손해란걸 모른단 말인가!',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & {
|
||||||
|
nextIntInclusive?: (maxInclusive: number) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickLegacyNpcMessage = (rng: GeneralActionResolveContext['rng']): string => {
|
||||||
|
const inclusive = rng as InclusiveRandomGenerator;
|
||||||
|
const index = inclusive.nextIntInclusive
|
||||||
|
? inclusive.nextIntInclusive(NPC_SEIZURE_MESSAGES.length - 1)
|
||||||
|
: rng.nextInt(0, NPC_SEIZURE_MESSAGES.length);
|
||||||
|
return NPC_SEIZURE_MESSAGES[index]!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveGeneralIcon = (general: General): string => {
|
||||||
|
const runtimePicture = (general as General & { picture?: unknown }).picture;
|
||||||
|
const rawPicture = runtimePicture ?? general.meta.picture;
|
||||||
|
const picture =
|
||||||
|
(typeof rawPicture === 'string' && rawPicture !== '') || typeof rawPicture === 'number'
|
||||||
|
? String(rawPicture)
|
||||||
|
: 'default.jpg';
|
||||||
|
return `/image/icons/${picture}`;
|
||||||
|
};
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
@@ -149,6 +185,30 @@ export class ActionDefinition<
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (
|
||||||
|
destGeneral.npcState >= 2 &&
|
||||||
|
context.rng.nextBool(this.env.npcSeizureMessageProb ?? NPC_SEIZURE_MESSAGE_PROB)
|
||||||
|
) {
|
||||||
|
const target = {
|
||||||
|
generalId: destGeneral.id,
|
||||||
|
generalName: destGeneral.name,
|
||||||
|
nationId: nation.id,
|
||||||
|
nationName: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
icon: resolveGeneralIcon(destGeneral),
|
||||||
|
};
|
||||||
|
effects.push(
|
||||||
|
createMessageEffect({
|
||||||
|
msgType: 'public',
|
||||||
|
src: target,
|
||||||
|
dest: target,
|
||||||
|
text: pickLegacyNpcMessage(context.rng),
|
||||||
|
time: context.messageTime,
|
||||||
|
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { effects };
|
return { effects };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +226,7 @@ export const actionContextBuilder: ActionContextBuilder<SeizureArgs> = (base, op
|
|||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
destGeneral,
|
destGeneral,
|
||||||
|
messageTime: base.general.turnTime,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -124,8 +124,11 @@ export const parsePercent = (value: string): number | null => {
|
|||||||
export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!==';
|
export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!==';
|
||||||
|
|
||||||
export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => {
|
export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => {
|
||||||
const lhs = target as any;
|
// The cast is type-only: JavaScript still applies its native relational
|
||||||
const rhs = source as any;
|
// coercion rules to the original runtime values, matching the legacy
|
||||||
|
// constraint evaluator without opting the whole comparison into `any`.
|
||||||
|
const lhs = target as number;
|
||||||
|
const rhs = source as number;
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case '<':
|
case '<':
|
||||||
return lhs < rhs;
|
return lhs < rhs;
|
||||||
|
|||||||
@@ -196,10 +196,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildTraceUnitSnapshot = (
|
const buildTraceUnitSnapshot = (unit: WarUnit, defenderCity: City): WarBattleTraceUnitSnapshot => {
|
||||||
unit: WarUnit,
|
|
||||||
defenderCity: City
|
|
||||||
): WarBattleTraceUnitSnapshot => {
|
|
||||||
const common = {
|
const common = {
|
||||||
kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const),
|
kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const),
|
||||||
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(),
|
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(),
|
||||||
@@ -339,7 +336,6 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
|||||||
);
|
);
|
||||||
|
|
||||||
const iter = defenderUnits.values();
|
const iter = defenderUnits.values();
|
||||||
let defender: WarUnit<TriggerState> | null = null;
|
|
||||||
|
|
||||||
const getNextDefender = (
|
const getNextDefender = (
|
||||||
_prevDefender: WarUnit<TriggerState> | null,
|
_prevDefender: WarUnit<TriggerState> | null,
|
||||||
@@ -359,7 +355,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
|||||||
return candidate;
|
return candidate;
|
||||||
};
|
};
|
||||||
|
|
||||||
defender = getNextDefender(null, true);
|
let defender = getNextDefender(null, true);
|
||||||
let conquerCity = false;
|
let conquerCity = false;
|
||||||
let logWritten = false;
|
let logWritten = false;
|
||||||
let traceSeq = 0;
|
let traceSeq = 0;
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ describe('migrated general commands', () => {
|
|||||||
expect(updatedLord.experience).toBe(700);
|
expect(updatedLord.experience).toBe(700);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('che_증여: 최소 보유량을 넘는 자원만 이전한다', async () => {
|
it('che_증여: 금은 레거시 최소 보유량 0을 적용해 요청한 금액을 이전한다', async () => {
|
||||||
const actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
|
const actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
|
||||||
const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
|
const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
|
||||||
const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 });
|
const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 });
|
||||||
@@ -246,8 +246,8 @@ describe('migrated general commands', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(world.getGeneral(actor.id)!.gold).toBe(1000);
|
expect(world.getGeneral(actor.id)!.gold).toBe(800);
|
||||||
expect(world.getGeneral(dest.id)!.gold).toBe(500);
|
expect(world.getGeneral(dest.id)!.gold).toBe(700);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => {
|
it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => {
|
||||||
|
|||||||
@@ -258,7 +258,6 @@ describe('General Commands New Scenario', () => {
|
|||||||
|
|
||||||
// 6. Retire (Needs age >= 60)
|
// 6. Retire (Needs age >= 60)
|
||||||
// Manually set age
|
// Manually set age
|
||||||
// Manually set age
|
|
||||||
const gToRetire = { ...g1_after_resign, age: 65 };
|
const gToRetire = { ...g1_after_resign, age: 65 };
|
||||||
world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g));
|
world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g));
|
||||||
const retireDef = retireSpec.createDefinition(systemEnv);
|
const retireDef = retireSpec.createDefinition(systemEnv);
|
||||||
@@ -274,7 +273,7 @@ describe('General Commands New Scenario', () => {
|
|||||||
const g1_after_retire = world.getGeneral(1)!;
|
const g1_after_retire = world.getGeneral(1)!;
|
||||||
expect(g1_after_retire.age).toBe(20);
|
expect(g1_after_retire.age).toBe(20);
|
||||||
// General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다.
|
// General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다.
|
||||||
expect(g1_after_retire.experience).toBe(142);
|
expect(g1_after_retire.experience).toBe(Math.round(gToRetire.experience * 0.5));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should execute employ and sabotage commands', async () => {
|
it('should execute employ and sabotage commands', async () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface CanonicalTurnSnapshot {
|
|||||||
engine: CanonicalEngine;
|
engine: CanonicalEngine;
|
||||||
world: Record<string, unknown>;
|
world: Record<string, unknown>;
|
||||||
generals: Array<Record<string, unknown>>;
|
generals: Array<Record<string, unknown>>;
|
||||||
|
rankData: Array<Record<string, unknown>>;
|
||||||
cities: Array<Record<string, unknown>>;
|
cities: Array<Record<string, unknown>>;
|
||||||
nations: Array<Record<string, unknown>>;
|
nations: Array<Record<string, unknown>>;
|
||||||
diplomacy: Array<Record<string, unknown>>;
|
diplomacy: Array<Record<string, unknown>>;
|
||||||
@@ -91,6 +92,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
|||||||
meta: unknown;
|
meta: unknown;
|
||||||
};
|
};
|
||||||
generals: Array<Record<string, unknown>>;
|
generals: Array<Record<string, unknown>>;
|
||||||
|
rankData: Array<Record<string, unknown>>;
|
||||||
cities: Array<Record<string, unknown>>;
|
cities: Array<Record<string, unknown>>;
|
||||||
nations: Array<Record<string, unknown>>;
|
nations: Array<Record<string, unknown>>;
|
||||||
diplomacy: Array<Record<string, unknown>>;
|
diplomacy: Array<Record<string, unknown>>;
|
||||||
@@ -99,6 +101,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
|||||||
logs: Array<Record<string, unknown>>;
|
logs: Array<Record<string, unknown>>;
|
||||||
}): CanonicalTurnSnapshot => {
|
}): CanonicalTurnSnapshot => {
|
||||||
const worldMeta = asRecord(rows.world.meta);
|
const worldMeta = asRecord(rows.world.meta);
|
||||||
|
const legacyRankTypes = new Set<string>(LEGACY_RANK_DATA_TYPES);
|
||||||
const generals = rows.generals.map((row) => {
|
const generals = rows.generals.map((row) => {
|
||||||
const meta = asRecord(row.meta);
|
const meta = asRecord(row.meta);
|
||||||
return {
|
return {
|
||||||
@@ -235,6 +238,14 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
|||||||
isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')),
|
isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')),
|
||||||
},
|
},
|
||||||
generals,
|
generals,
|
||||||
|
rankData: rows.rankData
|
||||||
|
.filter((row) => typeof row.type === 'string' && legacyRankTypes.has(row.type))
|
||||||
|
.map((row) => ({
|
||||||
|
generalId: row.generalId,
|
||||||
|
nationId: row.nationId,
|
||||||
|
type: row.type,
|
||||||
|
value: row.value,
|
||||||
|
})),
|
||||||
cities,
|
cities,
|
||||||
nations,
|
nations,
|
||||||
diplomacy,
|
diplomacy,
|
||||||
@@ -248,3 +259,4 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export interface SnapshotComparisonOptions {
|
|||||||
type FlatSnapshot = Map<string, unknown>;
|
type FlatSnapshot = Map<string, unknown>;
|
||||||
|
|
||||||
const entityKey = (value: Record<string, unknown>, index: number): string => {
|
const entityKey = (value: Record<string, unknown>, index: number): string => {
|
||||||
|
if (
|
||||||
|
(typeof value.generalId === 'number' || typeof value.generalId === 'string') &&
|
||||||
|
typeof value.type === 'string'
|
||||||
|
) {
|
||||||
|
return `${String(value.generalId)}:${value.type}`;
|
||||||
|
}
|
||||||
for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) {
|
for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) {
|
||||||
const candidate = value[key];
|
const candidate = value[key];
|
||||||
if (typeof candidate === 'number' || typeof candidate === 'string') {
|
if (typeof candidate === 'number' || typeof candidate === 'string') {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import type {
|
|||||||
TurnWorldSnapshot,
|
TurnWorldSnapshot,
|
||||||
TurnWorldState,
|
TurnWorldState,
|
||||||
} from '@sammo-ts/game-engine/turn/types.js';
|
} from '@sammo-ts/game-engine/turn/types.js';
|
||||||
|
import {
|
||||||
|
applyPersistedRankRowsToMeta,
|
||||||
|
buildLegacyComparableRankRows,
|
||||||
|
} from '@sammo-ts/game-engine/turn/rankData.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
canonicalizeTurnCommandArgs,
|
canonicalizeTurnCommandArgs,
|
||||||
@@ -47,6 +51,7 @@ export interface TurnCommandFixtureRequest {
|
|||||||
};
|
};
|
||||||
isolateWorld?: boolean;
|
isolateWorld?: boolean;
|
||||||
generals?: Array<Record<string, unknown>>;
|
generals?: Array<Record<string, unknown>>;
|
||||||
|
rankData?: Array<{ generalId: number; type: string; value: number }>;
|
||||||
nations?: Array<Record<string, unknown>>;
|
nations?: Array<Record<string, unknown>>;
|
||||||
cities?: Array<Record<string, unknown>>;
|
cities?: Array<Record<string, unknown>>;
|
||||||
troops?: Array<Record<string, unknown>>;
|
troops?: Array<Record<string, unknown>>;
|
||||||
@@ -295,6 +300,17 @@ const buildWorldInput = (
|
|||||||
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1);
|
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1);
|
||||||
const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
|
const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
|
||||||
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
|
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
|
||||||
|
for (const general of generals) {
|
||||||
|
applyPersistedRankRowsToMeta(
|
||||||
|
general.meta,
|
||||||
|
referenceBefore.rankData
|
||||||
|
.filter((row) => readNumber(row, 'generalId') === general.id)
|
||||||
|
.map((row) => ({
|
||||||
|
type: readString(row, 'type', ''),
|
||||||
|
value: readNumber(row, 'value'),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns)
|
const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns)
|
||||||
? referenceBefore.world.generalCooldowns
|
? referenceBefore.world.generalCooldowns
|
||||||
: [];
|
: [];
|
||||||
@@ -350,6 +366,7 @@ const buildWorldInput = (
|
|||||||
baseRice: 2_000,
|
baseRice: 2_000,
|
||||||
generalMinimumGold: 0,
|
generalMinimumGold: 0,
|
||||||
generalMinimumRice: 500,
|
generalMinimumRice: 500,
|
||||||
|
npcSeizureMessageProb: 0.01,
|
||||||
maxResourceActionAmount: 10_000,
|
maxResourceActionAmount: 10_000,
|
||||||
maxTechLevel: 12,
|
maxTechLevel: 12,
|
||||||
maxLevel: 255,
|
maxLevel: 255,
|
||||||
@@ -523,6 +540,11 @@ const projectWorld = (
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
generals,
|
generals,
|
||||||
|
rankData: world
|
||||||
|
.listGenerals()
|
||||||
|
.filter((general) => selector.generalIds.has(general.id))
|
||||||
|
.flatMap(buildLegacyComparableRankRows)
|
||||||
|
.map((row) => ({ ...row })),
|
||||||
cities: world
|
cities: world
|
||||||
.listCities()
|
.listCities()
|
||||||
.filter((city) => selector.cityIds.has(city.id))
|
.filter((city) => selector.cityIds.has(city.id))
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ export const readCoreDatabaseSnapshot = async (
|
|||||||
try {
|
try {
|
||||||
const db = connector.prisma;
|
const db = connector.prisma;
|
||||||
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||||
const [generals, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
|
const [generals, rankData, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
|
||||||
db.general.findMany({
|
db.general.findMany({
|
||||||
where: { id: { in: selector.generalIds } },
|
where: { id: { in: selector.generalIds } },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
}),
|
}),
|
||||||
|
db.rankData.findMany({
|
||||||
|
where: { generalId: { in: selector.generalIds } },
|
||||||
|
orderBy: [{ generalId: 'asc' }, { type: 'asc' }],
|
||||||
|
}),
|
||||||
db.city.findMany({
|
db.city.findMany({
|
||||||
where: { id: { in: selector.cityIds } },
|
where: { id: { in: selector.cityIds } },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
@@ -54,6 +58,7 @@ export const readCoreDatabaseSnapshot = async (
|
|||||||
return projectCoreDatabaseSnapshot({
|
return projectCoreDatabaseSnapshot({
|
||||||
world,
|
world,
|
||||||
generals,
|
generals,
|
||||||
|
rankData,
|
||||||
cities,
|
cities,
|
||||||
nations,
|
nations,
|
||||||
diplomacy,
|
diplomacy,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||||
|
|
||||||
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
|
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
|
||||||
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
|
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
|
||||||
@@ -72,6 +73,7 @@ interface FixturePatches {
|
|||||||
troops?: Array<Record<string, unknown>>;
|
troops?: Array<Record<string, unknown>>;
|
||||||
diplomacy?: Record<string, Record<string, unknown>>;
|
diplomacy?: Record<string, Record<string, unknown>>;
|
||||||
randomFoundingCandidateCityIds?: number[];
|
randomFoundingCandidateCityIds?: number[];
|
||||||
|
rankData?: Array<{ generalId: number; type: string; value: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildRequest = (
|
const buildRequest = (
|
||||||
@@ -166,6 +168,7 @@ const buildRequest = (
|
|||||||
{ ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] },
|
{ ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] },
|
||||||
{ ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] },
|
{ ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] },
|
||||||
],
|
],
|
||||||
|
...(fixturePatches.rankData ? { rankData: fixturePatches.rankData } : {}),
|
||||||
...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}),
|
...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}),
|
||||||
...(fixturePatches.randomFoundingCandidateCityIds
|
...(fixturePatches.randomFoundingCandidateCityIds
|
||||||
? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds }
|
? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds }
|
||||||
@@ -382,6 +385,67 @@ integration('general command success matrix', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
integration('명장일람 rank_data command parity', () => {
|
||||||
|
it('화계 increments firenum from the same seeded value as legacy', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_화계',
|
||||||
|
{ destCityID: 70 },
|
||||||
|
{ intelligence: 100 },
|
||||||
|
{
|
||||||
|
generals: { 2: { intelligence: 10 } },
|
||||||
|
rankData: [{ generalId: 1, type: 'firenum', value: 17 }],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-injury-4';
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.after.rankData).toContainEqual(
|
||||||
|
expect.objectContaining({ generalId: 1, type: 'firenum', value: 18 })
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('은퇴 resets every legacy RankColumn row exactly like legacy', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_은퇴',
|
||||||
|
undefined,
|
||||||
|
{ age: 65, lastTurn: { command: '은퇴', term: 1 } },
|
||||||
|
{
|
||||||
|
rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({
|
||||||
|
generalId: 1,
|
||||||
|
type,
|
||||||
|
value: index + 1,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.after.rankData.filter((row) => row.generalId === 1)).toHaveLength(
|
||||||
|
LEGACY_RANK_DATA_TYPES.length
|
||||||
|
);
|
||||||
|
expect(reference.after.rankData.filter((row) => row.generalId === 1).every((row) => row.value === 0)).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
|
|
||||||
type GeneralFailureCase = {
|
type GeneralFailureCase = {
|
||||||
action: string;
|
action: string;
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRo
|
|||||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||||
|
|
||||||
const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0);
|
const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0);
|
||||||
|
const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...';
|
||||||
|
|
||||||
const ignoredLifecyclePaths = [
|
const ignoredLifecyclePaths = [
|
||||||
/^generalTurns/,
|
/^generalTurns/,
|
||||||
@@ -627,3 +628,48 @@ integration('nation command resource balance and target boundaries', () => {
|
|||||||
120_000
|
120_000
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
integration('nation seizure NPC public message parity', () => {
|
||||||
|
it('matches the legacy fixed-seed RNG and public message side effect', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_몰수',
|
||||||
|
{ isGold: true, amount: 100, destGeneralID: 3 },
|
||||||
|
{
|
||||||
|
world: { hiddenSeed: 'seizure-message-37' },
|
||||||
|
generals: { 3: { name: '몰수NPC', npcState: 2 } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||||
|
expect(reference.rng).toHaveLength(2);
|
||||||
|
expect(reference.rng.map((call) => call.operation)).toEqual(['nextFloat1', 'nextInt']);
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
const referenceMessages = reference.after.messages.slice(reference.before.messages.length);
|
||||||
|
expect(referenceMessages).toHaveLength(1);
|
||||||
|
expect(core.after.messages).toHaveLength(1);
|
||||||
|
expect(referenceMessages[0]).toMatchObject({
|
||||||
|
mailbox: 9999,
|
||||||
|
type: 'public',
|
||||||
|
sourceId: 3,
|
||||||
|
destinationId: 9999,
|
||||||
|
payload: {
|
||||||
|
src: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
|
||||||
|
dest: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
|
||||||
|
text: NPC_SEIZURE_MESSAGE_TEXT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(core.after.messages[0]).toMatchObject({
|
||||||
|
payload: {
|
||||||
|
msgType: 'public',
|
||||||
|
src: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
|
||||||
|
dest: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
|
||||||
|
text: NPC_SEIZURE_MESSAGE_TEXT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const snapshot = (
|
|||||||
engine,
|
engine,
|
||||||
world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
|
world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
|
||||||
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
|
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
|
||||||
|
rankData: [],
|
||||||
cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }],
|
cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }],
|
||||||
nations: [{ id: 1, gold: 0, rice: 0 }],
|
nations: [{ id: 1, gold: 0, rice: 0 }],
|
||||||
diplomacy: [],
|
diplomacy: [],
|
||||||
@@ -44,6 +45,23 @@ describe('turn snapshot differential comparator', () => {
|
|||||||
expect(compareTurnSnapshots(reference, core)).toEqual([]);
|
expect(compareTurnSnapshots(reference, core)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('compares rank rows by general and type instead of array position', () => {
|
||||||
|
const reference = snapshot('ref', {
|
||||||
|
rankData: [
|
||||||
|
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
|
||||||
|
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const core = snapshot('core2026', {
|
||||||
|
rankData: [
|
||||||
|
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
|
||||||
|
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compareTurnSnapshots(reference, core)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes legacy ID argument spelling at the trace boundary', () => {
|
it('normalizes legacy ID argument spelling at the trace boundary', () => {
|
||||||
expect(
|
expect(
|
||||||
canonicalizeTurnCommandArgs({
|
canonicalizeTurnCommandArgs({
|
||||||
|
|||||||
Reference in New Issue
Block a user