Merge branch 'main' into feature/tournament-lifecycle-e2e
This commit is contained in:
@@ -3,7 +3,14 @@ import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
|
||||
import { loadWarTraitModules, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey } from '@sammo-ts/logic';
|
||||
import {
|
||||
ItemLoader,
|
||||
isItemKey,
|
||||
loadWarTraitModules,
|
||||
WarTraitLoader,
|
||||
WAR_TRAIT_KEYS,
|
||||
isWarTraitKey,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InheritBuffType } from '@sammo-ts/logic';
|
||||
import {
|
||||
appendInheritanceLog,
|
||||
@@ -23,8 +30,8 @@ const BUFF_KEYS: InheritBuffType[] = [
|
||||
'warAvoidRatio',
|
||||
'warCriticalRatio',
|
||||
'warMagicTrialProb',
|
||||
'success',
|
||||
'fail',
|
||||
'domesticSuccessProb',
|
||||
'domesticFailProb',
|
||||
'warAvoidRatioOppose',
|
||||
'warCriticalRatioOppose',
|
||||
'warMagicTrialProbOppose',
|
||||
@@ -34,8 +41,8 @@ const BUFF_LABELS: Record<InheritBuffType, string> = {
|
||||
warAvoidRatio: '회피 확률 증가',
|
||||
warCriticalRatio: '필살 확률 증가',
|
||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||
success: '내정 성공률 증가',
|
||||
fail: '내정 실패율 감소',
|
||||
domesticSuccessProb: '내정 성공률 증가',
|
||||
domesticFailProb: '내정 실패율 감소',
|
||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||
@@ -58,6 +65,37 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||
|
||||
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
|
||||
|
||||
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
|
||||
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
|
||||
};
|
||||
|
||||
const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
|
||||
const configuredItems = asRecord(asRecord(worldState.config).const).allItems;
|
||||
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
|
||||
for (const entries of Object.values(asRecord(configuredItems))) {
|
||||
for (const [key, amount] of Object.entries(asRecord(entries))) {
|
||||
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
|
||||
enabledKeys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loader = new ItemLoader();
|
||||
const items = await Promise.all(
|
||||
[...new Set(enabledKeys)].map(async (key) => {
|
||||
const item = await loader.load(key);
|
||||
return {
|
||||
key,
|
||||
name: item.name,
|
||||
rawName: item.rawName,
|
||||
info: item.info ?? '',
|
||||
};
|
||||
})
|
||||
);
|
||||
return items.sort((left, right) => left.name.localeCompare(right.name, 'ko'));
|
||||
};
|
||||
|
||||
const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<unknown> } } }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState || typeof worldState !== 'object') {
|
||||
@@ -199,6 +237,9 @@ export const inheritRouter = router({
|
||||
special2Code: true,
|
||||
meta: true,
|
||||
turnTime: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -219,7 +260,7 @@ export const inheritRouter = router({
|
||||
const inheritConst = resolveInheritConstants(worldState);
|
||||
const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
||||
const buffLevels = BUFF_KEYS.reduce<Record<string, number>>((acc, key) => {
|
||||
acc[key] = Math.max(0, Math.min(5, Math.floor(buffState[key] ?? 0)));
|
||||
acc[key] = readBuffLevel(buffState, key);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -240,11 +281,14 @@ export const inheritRouter = router({
|
||||
info: trait.info ?? '',
|
||||
}));
|
||||
|
||||
const others = await ctx.db.general.findMany({
|
||||
where: { id: { not: general.id }, userId: { not: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const [others, availableUnique] = await Promise.all([
|
||||
ctx.db.general.findMany({
|
||||
where: { id: { not: general.id }, npcState: { lt: 2 }, userId: { not: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
loadAvailableUniqueItems(worldState),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
@@ -260,10 +304,16 @@ export const inheritRouter = router({
|
||||
resetTurnTime: resetTurnLevel,
|
||||
},
|
||||
availableSpecialWar: warSpecials,
|
||||
availableUnique,
|
||||
availableTargetGenerals: others,
|
||||
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
|
||||
isUnited,
|
||||
currentSpecialWar: general.special2Code ?? 'None',
|
||||
currentStat: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intel: general.intel,
|
||||
},
|
||||
};
|
||||
}),
|
||||
getLogs: authedProcedure
|
||||
@@ -285,7 +335,7 @@ export const inheritRouter = router({
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: 30,
|
||||
select: { id: true, year: true, month: true, text: true },
|
||||
select: { id: true, year: true, month: true, text: true, createdAt: true },
|
||||
});
|
||||
return logs;
|
||||
}),
|
||||
@@ -318,7 +368,7 @@ export const inheritRouter = router({
|
||||
}
|
||||
|
||||
const buff = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
||||
const prevLevel = Math.max(0, Math.min(5, Math.floor(buff[input.type] ?? 0)));
|
||||
const prevLevel = readBuffLevel(buff, input.type);
|
||||
if (input.level === prevLevel) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' });
|
||||
}
|
||||
@@ -417,7 +467,12 @@ export const inheritRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritSpecificSpecialPoint);
|
||||
await setInheritancePoint(
|
||||
ctx.db,
|
||||
userId,
|
||||
'previous',
|
||||
currentPoint - inheritConst.inheritSpecificSpecialPoint
|
||||
);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
@@ -460,7 +515,8 @@ export const inheritRouter = router({
|
||||
}
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const prevList = parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
||||
const prevList =
|
||||
parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
||||
prevList.push(general.special2Code);
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
@@ -473,7 +529,13 @@ export const inheritRouter = router({
|
||||
});
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
||||
await appendInheritanceLog(ctx.db, userId, worldState.currentYear, worldState.currentMonth, `${cost} 포인트로 전투 특기 초기화`);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`${cost} 포인트로 전투 특기 초기화`
|
||||
);
|
||||
return { ok: true };
|
||||
}),
|
||||
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
|
||||
@@ -624,9 +686,7 @@ export const inheritRouter = router({
|
||||
const finalBonus =
|
||||
bonusSum === 0
|
||||
? buildRandomBonus(
|
||||
new LiteHashDRBG(
|
||||
`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`
|
||||
),
|
||||
new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`),
|
||||
[input.leadership, input.strength, input.intel]
|
||||
)
|
||||
: (bonus as [number, number, number]);
|
||||
@@ -674,9 +734,7 @@ export const inheritRouter = router({
|
||||
if (seasonValue !== null) {
|
||||
const userState = await readUserStateMeta(ctx.db, userId);
|
||||
const resetSeasons = readResetSeasons(userState);
|
||||
const nextSeasons = resetSeasons.includes(seasonValue)
|
||||
? resetSeasons
|
||||
: [...resetSeasons, seasonValue];
|
||||
const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue];
|
||||
await writeUserStateMeta(ctx.db, userId, {
|
||||
...userState,
|
||||
last_stat_reset: nextSeasons,
|
||||
@@ -709,7 +767,10 @@ export const inheritRouter = router({
|
||||
}
|
||||
const meta = asRecord(general.meta);
|
||||
if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' });
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
|
||||
});
|
||||
}
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
@@ -803,7 +864,9 @@ export const inheritRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const ownerName = typeof asRecord(target.meta).ownerName === 'string' ? (asRecord(target.meta).ownerName as string) : target.userId;
|
||||
const rawOwnerName = asRecord(target.meta).ownerName;
|
||||
const ownerName =
|
||||
typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음';
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint);
|
||||
await appendInheritanceLog(
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
id: 7,
|
||||
userId: 'user-1',
|
||||
name: '유비',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity: null,
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
leadership: 70,
|
||||
strength: 45,
|
||||
intel: 85,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
horseCode: 'None',
|
||||
itemCode: 'None',
|
||||
turnTime: new Date('2026-07-26T00:00:00Z'),
|
||||
recentWarTime: null,
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'che_선봉',
|
||||
lastTurn: {},
|
||||
meta: {},
|
||||
penalty: {},
|
||||
createdAt: new Date('2026-07-26T00:00:00Z'),
|
||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: `session-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName: userId,
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const worldState = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 3600,
|
||||
config: {
|
||||
const: {
|
||||
availableSpecialWar: ['che_선봉'],
|
||||
allItems: {
|
||||
weapon: {
|
||||
che_무기_12_칠성검: 1,
|
||||
che_무기_01_단도: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 },
|
||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||
};
|
||||
|
||||
const buildContext = (options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
general?: GeneralRow | null;
|
||||
target?: GeneralRow | null;
|
||||
inheritancePoint?: number;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
const target =
|
||||
options.target === undefined
|
||||
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
|
||||
: options.target;
|
||||
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
|
||||
type: command.type,
|
||||
ok: true,
|
||||
generalId: command.generalId,
|
||||
}));
|
||||
const pointUpsert = vi.fn(async () => ({}));
|
||||
const logCreate = vi.fn(async () => ({}));
|
||||
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||
const db = {
|
||||
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => worldState),
|
||||
},
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
general?.userId === where.userId ? general : null
|
||||
),
|
||||
findMany,
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
target?.id === where.id ? target : null
|
||||
),
|
||||
},
|
||||
inheritancePoint: {
|
||||
upsert: pointUpsert,
|
||||
},
|
||||
inheritanceLog: {
|
||||
create: logCreate,
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
inheritanceUserState: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
upsert: vi.fn(async () => ({})),
|
||||
},
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
},
|
||||
'che:default'
|
||||
);
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: {} as RedisConnector['client'],
|
||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore,
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
return { context, requestCommand, pointUpsert, logCreate, findMany };
|
||||
};
|
||||
|
||||
describe('inherit router actor and permission boundaries', () => {
|
||||
it('rejects unauthenticated status and mutations', async () => {
|
||||
const fixture = buildContext({ auth: null });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds status only from the authenticated user general and filters target generals like ref', async () => {
|
||||
const fixture = buildContext({});
|
||||
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
|
||||
|
||||
expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 });
|
||||
expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]);
|
||||
expect(status.availableUnique).toEqual([
|
||||
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
|
||||
]);
|
||||
expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0);
|
||||
expect(fixture.findMany).toHaveBeenCalledWith({
|
||||
where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch or charge when the authenticated user owns no general', async () => {
|
||||
const fixture = buildContext({
|
||||
auth: buildAuth('user-2'),
|
||||
general: buildGeneral({ userId: 'user-1' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||
type: 'domesticSuccessProb',
|
||||
level: 1,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '장수가 존재하지 않습니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mutates only the authenticated user general and inheritance balance', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 1000 });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||
type: 'domesticSuccessProb',
|
||||
level: 1,
|
||||
})
|
||||
).resolves.toEqual({ ok: true, remainPoint: 800 });
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: expect.objectContaining({
|
||||
meta: expect.objectContaining({
|
||||
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||
update: { value: 800 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 1500 });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
ownerName: '위유저',
|
||||
targetName: '조조',
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||
update: { value: 500 },
|
||||
})
|
||||
);
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '1000 포인트로 장수 소유자 확인',
|
||||
},
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
|
||||
@@ -12,8 +10,8 @@ type BuffKey =
|
||||
| 'warAvoidRatio'
|
||||
| 'warCriticalRatio'
|
||||
| 'warMagicTrialProb'
|
||||
| 'success'
|
||||
| 'fail'
|
||||
| 'domesticSuccessProb'
|
||||
| 'domesticFailProb'
|
||||
| 'warAvoidRatioOppose'
|
||||
| 'warCriticalRatioOppose'
|
||||
| 'warMagicTrialProbOppose';
|
||||
@@ -22,8 +20,8 @@ const buffKeys: BuffKey[] = [
|
||||
'warAvoidRatio',
|
||||
'warCriticalRatio',
|
||||
'warMagicTrialProb',
|
||||
'success',
|
||||
'fail',
|
||||
'domesticSuccessProb',
|
||||
'domesticFailProb',
|
||||
'warAvoidRatioOppose',
|
||||
'warCriticalRatioOppose',
|
||||
'warMagicTrialProbOppose',
|
||||
@@ -33,8 +31,8 @@ const buffLabels: Record<BuffKey, string> = {
|
||||
warAvoidRatio: '회피 확률 증가',
|
||||
warCriticalRatio: '필살 확률 증가',
|
||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||
success: '내정 성공률 증가',
|
||||
fail: '내정 실패율 감소',
|
||||
domesticSuccessProb: '내정 성공 확률 증가',
|
||||
domesticFailProb: '내정 실패 확률 감소',
|
||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||
@@ -43,20 +41,21 @@ const buffLabels: Record<BuffKey, string> = {
|
||||
const pointLabels: Record<string, string> = {
|
||||
previous: '보유',
|
||||
lived_month: '생존 턴',
|
||||
max_domestic_critical: '내정 최고치',
|
||||
active_action: '활동',
|
||||
combat: '전투',
|
||||
sabotage: '계략',
|
||||
dex: '숙련',
|
||||
unifier: '통일 보상',
|
||||
max_domestic_critical: '최대 연속 내정 성공',
|
||||
active_action: '능동 행동 수',
|
||||
combat: '전투 횟수',
|
||||
sabotage: '계략 성공 횟수',
|
||||
dex: '숙련도',
|
||||
unifier: '천통 기여',
|
||||
tournament: '토너먼트',
|
||||
betting: '베팅',
|
||||
max_belong: '최대 충성',
|
||||
betting: '베팅 당첨',
|
||||
max_belong: '최대 임관년 수',
|
||||
};
|
||||
|
||||
const pointOrder = [
|
||||
'previous',
|
||||
'lived_month',
|
||||
'max_belong',
|
||||
'max_domestic_critical',
|
||||
'active_action',
|
||||
'combat',
|
||||
@@ -65,9 +64,33 @@ const pointOrder = [
|
||||
'unifier',
|
||||
'tournament',
|
||||
'betting',
|
||||
'max_belong',
|
||||
] as const;
|
||||
|
||||
const pointHelp: Record<string, string> = {
|
||||
previous: '이전에 물려받은 포인트입니다.',
|
||||
lived_month: '살아남은 기간입니다. (1개월 단위)',
|
||||
max_belong: '가장 오래 임관했던 국가의 연도입니다.',
|
||||
max_domestic_critical: '성공한 내정 중 최대 연속값입니다.',
|
||||
active_action: '장수 동향에 본인의 이름이 직접 나타난 수입니다. 일부 사령턴은 제외됩니다.',
|
||||
combat: '전투 횟수입니다.',
|
||||
sabotage: '계략 성공 횟수입니다.',
|
||||
unifier: '천통에 기여한 포인트입니다. 각 국의 군주, 천통 수뇌, 천통 군주가 받습니다.',
|
||||
dex: '총 숙련도합입니다. 최대 숙련 이후에는 상승량이 1/3로 감소합니다.',
|
||||
tournament: '토너먼트 입상 포인트입니다.',
|
||||
betting: '성공적인 베팅을 했습니다. 수익율과 베팅 성공 횟수를 따릅니다.',
|
||||
};
|
||||
|
||||
const buffHelp: Record<BuffKey, string> = {
|
||||
warAvoidRatio: '전투 시 회피 확률이 1%p ~ 5%p 증가합니다.',
|
||||
warCriticalRatio: '전투 시 필살 확률이 1%p ~ 5%p 증가합니다.',
|
||||
warMagicTrialProb: '전투 시 계략을 시도할 확률이 1%p ~ 5%p 증가합니다. 무장도 계략을 시도합니다.',
|
||||
domesticSuccessProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 성공 확률이 증가합니다.',
|
||||
domesticFailProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 실패 확률이 감소합니다.',
|
||||
warAvoidRatioOppose: '전투 시 상대의 회피 확률이 1%p ~ 5%p 감소합니다.',
|
||||
warCriticalRatioOppose: '전투 시 상대의 필살 확률이 1%p ~ 5%p 감소합니다.',
|
||||
warMagicTrialProbOppose: '전투 시 상대의 계략 시도 확률이 1%p ~ 5%p 감소합니다.',
|
||||
};
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const status = ref<InheritStatus | null>(null);
|
||||
@@ -86,8 +109,8 @@ const buffTargets = reactive<Record<BuffKey, number>>({
|
||||
warAvoidRatio: 1,
|
||||
warCriticalRatio: 1,
|
||||
warMagicTrialProb: 1,
|
||||
success: 1,
|
||||
fail: 1,
|
||||
domesticSuccessProb: 1,
|
||||
domesticFailProb: 1,
|
||||
warAvoidRatioOppose: 1,
|
||||
warCriticalRatioOppose: 1,
|
||||
warMagicTrialProbOppose: 1,
|
||||
@@ -115,7 +138,7 @@ const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
|
||||
const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel);
|
||||
const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
|
||||
const resetStatCost = computed(() =>
|
||||
resetBonusSum.value > 0 ? status.value?.inheritConst.inheritBornStatPoint ?? 0 : 0
|
||||
resetBonusSum.value > 0 ? (status.value?.inheritConst.inheritBornStatPoint ?? 0) : 0
|
||||
);
|
||||
|
||||
const resetStatErrors = computed(() => {
|
||||
@@ -166,6 +189,9 @@ const pointEntries = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const previousPoint = computed(() => status.value?.items.previous ?? 0);
|
||||
const newPoint = computed(() => (status.value?.totalPoint ?? 0) - previousPoint.value);
|
||||
|
||||
const specialNameMap = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of status.value?.availableSpecialWar ?? []) {
|
||||
@@ -174,29 +200,12 @@ const specialNameMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const currentSpecialName = computed(() => {
|
||||
if (!status.value) {
|
||||
return '-';
|
||||
}
|
||||
return specialNameMap.value.get(status.value.currentSpecialWar) ?? status.value.currentSpecialWar ?? '-';
|
||||
});
|
||||
|
||||
const buffCost = (key: BuffKey, target: number): number => {
|
||||
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
|
||||
const current = status.value?.buffLevels[key] ?? 0;
|
||||
return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0));
|
||||
};
|
||||
|
||||
const buffTargetOptions = (key: BuffKey): number[] => {
|
||||
const current = status.value?.buffLevels[key] ?? 0;
|
||||
const start = Math.min(5, Math.max(1, current + 1));
|
||||
const result: number[] = [];
|
||||
for (let level = start; level <= 5; level += 1) {
|
||||
result.push(level);
|
||||
}
|
||||
return result.length > 0 ? result : [5];
|
||||
};
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
@@ -207,17 +216,6 @@ const resolveErrorMessage = (value: unknown): string => {
|
||||
return 'unknown_error';
|
||||
};
|
||||
|
||||
const applyResetBalanced = () => {
|
||||
const rules = statRules.value;
|
||||
if (!rules) {
|
||||
return;
|
||||
}
|
||||
const base = Math.floor(rules.total / 3);
|
||||
resetStatForm.leadership = rules.total - base * 2;
|
||||
resetStatForm.strength = base;
|
||||
resetStatForm.intel = base;
|
||||
};
|
||||
|
||||
const syncSelections = () => {
|
||||
if (!status.value) {
|
||||
return;
|
||||
@@ -235,6 +233,14 @@ const syncSelections = () => {
|
||||
if (!uniqueForm.amount) {
|
||||
uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint;
|
||||
}
|
||||
if (!uniqueForm.itemId) {
|
||||
uniqueForm.itemId = status.value.availableUnique[0]?.key ?? '';
|
||||
}
|
||||
if (resetStatForm.leadership === 0 && resetStatForm.strength === 0 && resetStatForm.intel === 0) {
|
||||
resetStatForm.leadership = status.value.currentStat.leadership;
|
||||
resetStatForm.strength = status.value.currentStat.strength;
|
||||
resetStatForm.intel = status.value.currentStat.intel;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
@@ -377,7 +383,7 @@ const buyRandomUnique = async () => {
|
||||
|
||||
const openUniqueAuction = async () => {
|
||||
if (!uniqueForm.itemId.trim()) {
|
||||
actionError.value = '유니크 아이템 ID를 입력해주세요.';
|
||||
actionError.value = '유니크를 선택해주세요.';
|
||||
return;
|
||||
}
|
||||
const amount = Math.max(0, Math.floor(uniqueForm.amount));
|
||||
@@ -412,12 +418,6 @@ const checkOwner = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
watch(statRules, (rules) => {
|
||||
if (rules) {
|
||||
applyResetBalanced();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadStatus();
|
||||
void loadJoinConfig();
|
||||
@@ -426,464 +426,561 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="inherit-page">
|
||||
<header class="inherit-header">
|
||||
<div>
|
||||
<h1 class="inherit-title">유산 포인트</h1>
|
||||
<p class="inherit-subtitle">숨김 강화와 유산 상점 기능을 관리합니다.</p>
|
||||
</div>
|
||||
<div class="inherit-actions">
|
||||
<button class="ghost" @click="loadStatus">새로고침</button>
|
||||
<button class="ghost" @click="loadLogs(true)">로그 갱신</button>
|
||||
</div>
|
||||
</header>
|
||||
<header class="top-back-bar legacy-bg0">
|
||||
<RouterLink class="top-button legacy-button" to="/">돌아가기</RouterLink>
|
||||
<strong>유산 관리</strong>
|
||||
<button class="top-button legacy-button" type="button" :disabled="loading" @click="loadStatus">갱신</button>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="inherit-error">{{ error }}</div>
|
||||
<div v-if="actionError" class="inherit-error">{{ actionError }}</div>
|
||||
<div v-if="actionMessage" class="inherit-message">{{ actionMessage }}</div>
|
||||
<main id="container" class="inherit-page legacy-bg0">
|
||||
<div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
|
||||
<div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
|
||||
<div v-if="loading" class="loading-state">불러오는 중...</div>
|
||||
|
||||
<div v-if="loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<template v-else-if="status">
|
||||
<section id="inheritance_list" class="point-grid">
|
||||
<article id="inherit_sum" class="inherit-item">
|
||||
<label for="inherit_sum_value">총 포인트</label>
|
||||
<input id="inherit_sum_value" :value="Math.floor(status.totalPoint).toLocaleString()" readonly />
|
||||
<small>다음 플레이에서 사용할 수 있는 총 포인트입니다.</small>
|
||||
</article>
|
||||
<article id="inherit_previous" class="inherit-item">
|
||||
<label for="inherit_previous_value">기존 포인트</label>
|
||||
<input id="inherit_previous_value" :value="Math.floor(previousPoint).toLocaleString()" readonly />
|
||||
<small>이전에 물려받은 포인트입니다.</small>
|
||||
</article>
|
||||
<article id="inherit_new" class="inherit-item">
|
||||
<label for="inherit_new_value">신규 포인트</label>
|
||||
<input id="inherit_new_value" :value="Math.floor(newPoint).toLocaleString()" readonly />
|
||||
<small>이번 플레이에서 얻은 총 포인트입니다.</small>
|
||||
</article>
|
||||
<div class="divider"></div>
|
||||
<article
|
||||
v-for="entry in pointEntries.filter((item) => item.key !== 'previous')"
|
||||
:id="`inherit_${entry.key}`"
|
||||
:key="entry.key"
|
||||
class="inherit-item"
|
||||
>
|
||||
<label :for="`inherit_${entry.key}_value`">{{ entry.label }}</label>
|
||||
<input
|
||||
:id="`inherit_${entry.key}_value`"
|
||||
:value="Math.floor(entry.value).toLocaleString()"
|
||||
readonly
|
||||
/>
|
||||
<small>{{ pointHelp[entry.key] }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-else class="inherit-grid">
|
||||
<PanelCard title="포인트 요약" subtitle="유산 포인트 구성 현황">
|
||||
<div v-if="!status" class="muted">포인트 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="summary-panel">
|
||||
<div class="summary-head">
|
||||
<div class="summary-total">총 {{ status.totalPoint }} 포인트</div>
|
||||
<div class="summary-state" :class="{ united: status.isUnited }">
|
||||
{{ status.isUnited ? '통일 완료' : '진행 중' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-list">
|
||||
<div v-for="entry in pointEntries" :key="entry.key" class="summary-row">
|
||||
<span>{{ entry.label }}</span>
|
||||
<span>{{ entry.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-footer">
|
||||
<div>현재 전투 특기: {{ currentSpecialName }}</div>
|
||||
<div>특기 초기화 단계: {{ status.resetLevels.resetSpecialWar }}회</div>
|
||||
<div>턴 시간 초기화 단계: {{ status.resetLevels.resetTurnTime }}회</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
<section id="inheritance_store">
|
||||
<h2 class="section-title legacy-bg1">유산 포인트 상점</h2>
|
||||
|
||||
<PanelCard title="숨김 강화" subtitle="숨김 강화 효과를 구입합니다.">
|
||||
<div v-if="!status" class="muted">숨김 강화 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="buff-list">
|
||||
<div v-for="key in buffKeys" :key="key" class="buff-row">
|
||||
<div class="buff-info">
|
||||
<div class="buff-name">{{ buffLabels[key] }}</div>
|
||||
<div class="buff-level">현재 {{ status.buffLevels[key] ?? 0 }} 단계</div>
|
||||
</div>
|
||||
<div class="buff-action">
|
||||
<select v-model.number="buffTargets[key]" class="form-input">
|
||||
<option
|
||||
v-for="level in buffTargetOptions(key)"
|
||||
:key="level"
|
||||
:value="level"
|
||||
>
|
||||
{{ level }} 단계
|
||||
<div class="action-grid leading-actions">
|
||||
<article class="shop-item">
|
||||
<div class="control-row">
|
||||
<label for="next-special">다음 전투 특기 선택</label>
|
||||
<select id="next-special" v-model="nextSpecialKey">
|
||||
<option v-for="entry in status.availableSpecialWar" :key="entry.key" :value="entry.key">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="buff-cost">비용 {{ buffCost(key, buffTargets[key]) }}</div>
|
||||
</div>
|
||||
<small
|
||||
>{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에 얻도록 지정합니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b
|
||||
></small
|
||||
>
|
||||
<button
|
||||
class="legacy-button buy-button"
|
||||
:disabled="isUnited || actionBusy"
|
||||
@click="reserveSpecialWar"
|
||||
>
|
||||
구입
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="shop-item">
|
||||
<div class="control-row">
|
||||
<label for="specific-unique">유니크 경매</label>
|
||||
<select id="specific-unique" v-model="uniqueForm.itemId">
|
||||
<option disabled value="">유니크 선택</option>
|
||||
<option v-for="item in status.availableUnique" :key="item.key" :value="item.key">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label for="specific-unique-amount">입찰 포인트</label>
|
||||
<input
|
||||
id="specific-unique-amount"
|
||||
v-model.number="uniqueForm.amount"
|
||||
type="number"
|
||||
:min="status.inheritConst.inheritItemUniqueMinPoint"
|
||||
:max="previousPoint"
|
||||
/>
|
||||
</div>
|
||||
<small
|
||||
>얻고자 하는 유니크 아이템으로 경매를 시작합니다. 24턴 동안 진행됩니다.<br />{{
|
||||
status.availableUnique.find((item) => item.key === uniqueForm.itemId)?.info
|
||||
}}</small
|
||||
>
|
||||
<button
|
||||
class="legacy-button buy-button"
|
||||
:disabled="isUnited || actionBusy"
|
||||
@click="openUniqueAuction"
|
||||
>
|
||||
경매 시작
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="action-grid">
|
||||
<article class="shop-item simple-item">
|
||||
<div class="control-row">
|
||||
<span>랜덤 턴 초기화</span
|
||||
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetTurnTime">
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
<small
|
||||
>다다음턴부터 시간이 랜덤하게 바뀝니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
|
||||
>필요 포인트: {{ status.resetCosts.resetTurnTime }}</b
|
||||
><template v-if="turnTimeLabel"><br />적용 시간: {{ turnTimeLabel }}</template></small
|
||||
>
|
||||
</article>
|
||||
<article class="shop-item simple-item">
|
||||
<div class="control-row">
|
||||
<span>랜덤 유니크 획득</span
|
||||
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="buyRandomUnique">
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
<small
|
||||
>다음 턴에 랜덤 유니크를 얻습니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritItemRandomPoint }}</b
|
||||
></small
|
||||
>
|
||||
</article>
|
||||
<article class="shop-item simple-item">
|
||||
<div class="control-row">
|
||||
<span>즉시 전투 특기 초기화</span
|
||||
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetSpecialWar">
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
<small
|
||||
>즉시 전투 특기를 초기화합니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
|
||||
>필요 포인트: {{ status.resetCosts.resetSpecialWar }}</b
|
||||
></small
|
||||
>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="buff-grid">
|
||||
<article v-for="key in buffKeys" :key="key" class="shop-item buff-item">
|
||||
<div class="control-row">
|
||||
<label :for="`buff-${key}`">{{ buffLabels[key] }}</label>
|
||||
<input
|
||||
:id="`buff-${key}`"
|
||||
v-model.number="buffTargets[key]"
|
||||
type="number"
|
||||
:min="status.buffLevels[key] ?? 0"
|
||||
max="5"
|
||||
/>
|
||||
</div>
|
||||
<small
|
||||
>{{ buffHelp[key] }}<br /><b>필요 포인트: {{ buffCost(key, buffTargets[key]) }}</b></small
|
||||
>
|
||||
<div class="dual-buttons">
|
||||
<button
|
||||
:disabled="isUnited || actionBusy || (status.buffLevels[key] ?? 0) >= 5"
|
||||
class="legacy-button secondary"
|
||||
:disabled="actionBusy"
|
||||
@click="buffTargets[key] = status.buffLevels[key] ?? 0"
|
||||
>
|
||||
리셋
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button"
|
||||
:disabled="isUnited || actionBusy"
|
||||
@click="buyHiddenBuff(key)"
|
||||
>
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="전투 특기 제어" subtitle="다음 특기 지정 및 초기화">
|
||||
<div v-if="!status" class="muted">전투 특기 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<label class="form-field">
|
||||
<span>다음 전투 특기</span>
|
||||
<select v-model="nextSpecialKey" class="form-input">
|
||||
<option v-for="special in status.availableSpecialWar" :key="special.key" :value="special.key">
|
||||
{{ special.name }}
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="action-grid bottom-actions">
|
||||
<article class="shop-item">
|
||||
<div class="control-row">
|
||||
<label for="owner-target">장수 소유자 확인</label>
|
||||
<select id="owner-target" v-model="ownerTargetId">
|
||||
<option disabled value="">장수 선택</option>
|
||||
<option
|
||||
v-for="general in status.availableTargetGenerals"
|
||||
:key="general.id"
|
||||
:value="String(general.id)"
|
||||
>
|
||||
{{ general.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ status.inheritConst.inheritSpecificSpecialPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="reserveSpecialWar">예약</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<div>
|
||||
<div class="muted">현재 전투 특기: {{ currentSpecialName }}</div>
|
||||
<div class="muted">
|
||||
초기화 비용 {{ status.resetCosts.resetSpecialWar }} 포인트
|
||||
({{ status.resetLevels.resetSpecialWar }}회)
|
||||
</div>
|
||||
<small
|
||||
>장수의 소유자를 찾습니다. 대상에게도 알림이 전송됩니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritCheckOwnerPoint }}</b
|
||||
></small
|
||||
>
|
||||
<button class="legacy-button buy-button" :disabled="isUnited || actionBusy" @click="checkOwner">
|
||||
소유자 찾기
|
||||
</button>
|
||||
<p v-if="ownerResult" class="owner-result">
|
||||
{{ ownerResult.targetName }}의 소유자: {{ ownerResult.ownerName }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="shop-item stat-reset">
|
||||
<div class="stat-layout">
|
||||
<span>능력치 초기화</span>
|
||||
<div>
|
||||
<strong>기본 능력치</strong>
|
||||
<label
|
||||
>통
|
||||
<input
|
||||
v-model.number="resetStatForm.leadership"
|
||||
type="number"
|
||||
:min="statRules?.min"
|
||||
:max="statRules?.max"
|
||||
/></label>
|
||||
<label
|
||||
>무
|
||||
<input
|
||||
v-model.number="resetStatForm.strength"
|
||||
type="number"
|
||||
:min="statRules?.min"
|
||||
:max="statRules?.max"
|
||||
/></label>
|
||||
<label
|
||||
>지
|
||||
<input
|
||||
v-model.number="resetStatForm.intel"
|
||||
type="number"
|
||||
:min="statRules?.min"
|
||||
:max="statRules?.max"
|
||||
/></label>
|
||||
<strong>추가 능력치</strong>
|
||||
<label
|
||||
>통 <input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5"
|
||||
/></label>
|
||||
<label
|
||||
>무 <input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5"
|
||||
/></label>
|
||||
<label
|
||||
>지 <input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5"
|
||||
/></label>
|
||||
</div>
|
||||
</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetSpecialWar">초기화</button>
|
||||
</div>
|
||||
<small
|
||||
>시즌 당 1회에 한 해 능력치를 초기화합니다.<br /><b>필요 포인트: {{ resetStatCost }}</b
|
||||
><br /><span v-if="resetStatErrors.length">{{ resetStatErrors[0] }}</span></small
|
||||
>
|
||||
<button
|
||||
class="legacy-button buy-button"
|
||||
:disabled="isUnited || actionBusy || resetStatErrors.length > 0"
|
||||
@click="resetStats"
|
||||
>
|
||||
능력치 초기화
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
|
||||
<PanelCard title="턴 시간 초기화" subtitle="턴 시간대를 재설정합니다.">
|
||||
<div v-if="!status" class="muted">턴 시간 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<div class="muted">
|
||||
비용 {{ status.resetCosts.resetTurnTime }} 포인트 ({{ status.resetLevels.resetTurnTime }}회)
|
||||
</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetTurnTime">턴 시간 변경</button>
|
||||
</div>
|
||||
<div v-if="turnTimeLabel" class="muted">다음 적용 시각: {{ turnTimeLabel }}</div>
|
||||
<section class="inherit-logs">
|
||||
<h2 class="section-title legacy-bg1">유산 포인트 변경 내역</h2>
|
||||
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
|
||||
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
|
||||
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
|
||||
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
|
||||
<span>{{ entry.text }}</span>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="능력치 초기화" subtitle="능력치를 다시 배분합니다.">
|
||||
<div v-if="!status" class="muted">능력치 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="stat-panel">
|
||||
<div class="stat-grid">
|
||||
<label class="form-field">
|
||||
<span>통솔</span>
|
||||
<input v-model.number="resetStatForm.leadership" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>무력</span>
|
||||
<input v-model.number="resetStatForm.strength" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>지력</span>
|
||||
<input v-model.number="resetStatForm.intel" type="number" class="form-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<label class="form-field">
|
||||
<span>보너스 통솔</span>
|
||||
<input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>보너스 무력</span>
|
||||
<input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>보너스 지력</span>
|
||||
<input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-summary">
|
||||
<div>총합 {{ resetStatTotal }} / {{ statRules?.total ?? '-' }}</div>
|
||||
<div>보너스 합 {{ resetBonusSum }} · 비용 {{ resetStatCost }}</div>
|
||||
<div v-if="resetStatErrors.length" class="stat-errors">
|
||||
<div v-for="item in resetStatErrors" :key="item">{{ item }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button :disabled="isUnited || actionBusy" class="ghost" @click="applyResetBalanced">균형형</button>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetStats">능력치 초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유니크 상점" subtitle="유니크 아이템 관련 기능">
|
||||
<div v-if="!status" class="muted">유니크 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<div class="muted">랜덤 유니크 구매 ({{ status.inheritConst.inheritItemRandomPoint }} 포인트)</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="buyRandomUnique">구입</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<label class="form-field">
|
||||
<span>유니크 아이템 ID</span>
|
||||
<input v-model="uniqueForm.itemId" type="text" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>입찰 포인트</span>
|
||||
<input v-model.number="uniqueForm.amount" type="number" class="form-input" />
|
||||
<small class="muted">최소 {{ status.inheritConst.inheritItemUniqueMinPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="openUniqueAuction">신청</button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="소유자 확인" subtitle="상대 장수의 소유자를 확인합니다.">
|
||||
<div v-if="!status" class="muted">대상 장수 목록을 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<label class="form-field">
|
||||
<span>대상 장수</span>
|
||||
<select v-model="ownerTargetId" class="form-input">
|
||||
<option v-for="general in status.availableTargetGenerals" :key="general.id" :value="String(general.id)">
|
||||
{{ general.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ status.inheritConst.inheritCheckOwnerPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="checkOwner">확인</button>
|
||||
<div v-if="ownerResult" class="muted">
|
||||
{{ ownerResult.targetName }}의 소유자: {{ ownerResult.ownerName }}
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유산 기록" subtitle="최근 유산 로그">
|
||||
<template #actions>
|
||||
<button class="ghost" :disabled="logLoading" @click="loadLogs(true)">갱신</button>
|
||||
</template>
|
||||
<div v-if="logLoading && logs.length === 0">
|
||||
<SkeletonLines :lines="3" />
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="muted">기록이 없습니다.</div>
|
||||
<div v-else class="log-list">
|
||||
<div v-for="entry in logs" :key="entry.id" class="log-entry">
|
||||
<span class="log-date">{{ entry.year }}년 {{ entry.month }}월</span>
|
||||
<span>{{ entry.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-footer">
|
||||
<button class="ghost" :disabled="logLoading || logEnd" @click="loadLogs()">더 보기</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
<button class="legacy-button more-button" :disabled="logLoading || logEnd" @click="loadLogs()">
|
||||
더 가져오기
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inherit-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.inherit-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.inherit-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inherit-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.inherit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inherit-error {
|
||||
border: 1px solid rgba(240, 90, 90, 0.6);
|
||||
padding: 8px 10px;
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.inherit-message {
|
||||
border: 1px solid rgba(120, 190, 120, 0.5);
|
||||
padding: 8px 10px;
|
||||
color: rgba(180, 230, 180, 0.9);
|
||||
}
|
||||
|
||||
.inherit-grid {
|
||||
.top-back-bar {
|
||||
width: min(100%, 1000px);
|
||||
min-height: 38px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #888;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-head {
|
||||
display: flex;
|
||||
grid-template-columns: 100px 1fr 100px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
padding: 3px 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.summary-total {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-state {
|
||||
font-size: 0.75rem;
|
||||
.top-button {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.summary-state.united {
|
||||
border-color: rgba(240, 120, 120, 0.6);
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
.inherit-page {
|
||||
width: min(100%, 1000px);
|
||||
margin: 0 auto;
|
||||
border: 1px solid #888;
|
||||
border-top: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px 10px;
|
||||
color: #fff;
|
||||
font:
|
||||
14px/1.3 Pretendard,
|
||||
'Apple SD Gothic Neo',
|
||||
'Noto Sans KR',
|
||||
'Malgun Gothic',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.notice,
|
||||
.loading-state,
|
||||
.log-empty {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
.notice.error {
|
||||
color: #ffb0b0;
|
||||
}
|
||||
|
||||
.summary-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
.notice.success {
|
||||
color: #b6efb6;
|
||||
}
|
||||
|
||||
.buff-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.buff-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.buff-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.buff-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.buff-level {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.buff-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.buff-cost {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-summary {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.inherit-item,
|
||||
.shop-item {
|
||||
padding: 8px 16px;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-errors {
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
.inherit-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(100px, 1fr);
|
||||
align-items: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
.inherit-item label {
|
||||
text-align: right;
|
||||
padding: 7px 8px 0 0;
|
||||
}
|
||||
|
||||
.inherit-item input,
|
||||
.shop-item input,
|
||||
.shop-item select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 4px;
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inherit-item small,
|
||||
.shop-item small {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 34px;
|
||||
text-align: right;
|
||||
color: #aeb2b6;
|
||||
}
|
||||
|
||||
.inherit-item small {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.22);
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
margin: 0 -8px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.leading-actions .shop-item:first-child {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.control-row > label,
|
||||
.control-row > span {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.shop-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
padding: 6px 8px;
|
||||
color: inherit;
|
||||
.shop-item .buy-button {
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
color: inherit;
|
||||
.simple-item small {
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
.buff-item small {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.dual-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.legacy-button.secondary {
|
||||
border-color: #51585e;
|
||||
background: #5c636a;
|
||||
}
|
||||
|
||||
.bottom-actions .shop-item:first-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.stat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.8rem;
|
||||
.stat-layout > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
.stat-layout label {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-footer {
|
||||
margin-top: 8px;
|
||||
.stat-layout strong {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
font-size: 0.75rem;
|
||||
.owner-result {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inherit-logs {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 20ch) 1fr;
|
||||
gap: 8px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.log-row small {
|
||||
color: #aeb2b6;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.more-button {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.leading-actions .shop-item:first-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.top-back-bar,
|
||||
.inherit-page {
|
||||
width: 500px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.inherit-item,
|
||||
.shop-item {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.log-row small {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개,
|
||||
실행 중 확률 실패 9개, full constraint fallback 12개와 모략 확률 clamp
|
||||
8개, 모략 결과값 경계 5개, 부상 경계 3개, alternative 5개와 pre-required
|
||||
turn 중간 경계 6개가 구현됐다.
|
||||
turn 중간 경계 6개, post-required cooldown 경계 3개가 구현됐다.
|
||||
실패 9개는 내정 critical
|
||||
`주민선정/정착장려/상업투자/기술연구/물자조달`과 모략
|
||||
`화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패
|
||||
@@ -27,9 +27,12 @@ state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치
|
||||
비교한다. alternative 5개는 해산·랜덤임관·무작위건국·출병의 모든 대체
|
||||
분기에서 최초 명령 RNG의 연속 소비와 최종 상태를 비교한다. pre-required
|
||||
turn 6개는 전투태세 1/2/3턴, 내정·전투 특기 초기화 1턴, 은퇴 1턴의
|
||||
`last_turn`과 진행 로그, RNG 무소비를 비교한다. 나머지 명령별 제약
|
||||
실패·값 경계와 전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지
|
||||
55개 명령 전체의 동적 호환 상태를 `확인`으로 올리지 않는다.
|
||||
`last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기
|
||||
초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을
|
||||
ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다.
|
||||
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
|
||||
기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로
|
||||
올리지 않는다.
|
||||
|
||||
## 결정 요약
|
||||
|
||||
|
||||
@@ -206,6 +206,10 @@ instance and current consumption position. Six pre-required-turn cases cover
|
||||
battle-preparation terms 1 through 3 plus the first intermediate turn of both
|
||||
trait resets and retirement. They compare the exact intermediate `lastTurn`,
|
||||
progress log, zero command-RNG consumption and semantic state delta.
|
||||
Three post-required cooldown cases project the legacy `next_execute` KV and
|
||||
core general meta into the same world-level cooldown record. They cover the
|
||||
stored `current + 60 - preReq` value, rejection one turn before availability,
|
||||
and successful execution exactly at the boundary.
|
||||
This is not yet a claim that every command-specific
|
||||
constraint, clamp and persistence boundary has been dynamically
|
||||
compared.
|
||||
|
||||
@@ -43,22 +43,23 @@ storage, route guards, and image loading.
|
||||
|
||||
## Enforced contracts
|
||||
|
||||
| Screen | Ref entry point | Current automated contract |
|
||||
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
||||
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
||||
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
|
||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||
| Screen | Ref entry point | Current automated contract |
|
||||
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
||||
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
||||
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
|
||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
|
||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||
|
||||
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
||||
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
||||
|
||||
@@ -7,8 +7,8 @@ export type InheritBuffType =
|
||||
| 'warAvoidRatio'
|
||||
| 'warCriticalRatio'
|
||||
| 'warMagicTrialProb'
|
||||
| 'success'
|
||||
| 'fail'
|
||||
| 'domesticSuccessProb'
|
||||
| 'domesticFailProb'
|
||||
| 'warAvoidRatioOppose'
|
||||
| 'warCriticalRatioOppose'
|
||||
| 'warMagicTrialProbOppose';
|
||||
@@ -25,7 +25,8 @@ const DOMESTIC_TARGETS = new Set<TriggerDomesticActionType>([
|
||||
]);
|
||||
|
||||
const readBuffLevel = (buff: Record<string, unknown>, key: InheritBuffType): number => {
|
||||
const raw = buff[key];
|
||||
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||
const raw = buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : undefined);
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||
return 0;
|
||||
}
|
||||
@@ -40,7 +41,9 @@ const parseInheritBuff = (value: unknown): Record<string, unknown> => {
|
||||
return asRecord(value);
|
||||
};
|
||||
|
||||
const resolveBuffRecord = (context: { general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } } }): Record<string, unknown> => {
|
||||
const resolveBuffRecord = (context: {
|
||||
general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } };
|
||||
}): Record<string, unknown> => {
|
||||
const fromTrigger = parseInheritBuff(context.general.triggerState.meta.inheritBuff);
|
||||
if (Object.keys(fromTrigger).length > 0) {
|
||||
return fromTrigger;
|
||||
@@ -58,11 +61,11 @@ const applyDomesticBuff = (
|
||||
return value;
|
||||
}
|
||||
if (varType === 'success') {
|
||||
const level = readBuffLevel(buff, 'success');
|
||||
const level = readBuffLevel(buff, 'domesticSuccessProb');
|
||||
return value + level * 0.01;
|
||||
}
|
||||
if (varType === 'fail') {
|
||||
const level = readBuffLevel(buff, 'fail');
|
||||
const level = readBuffLevel(buff, 'domesticFailProb');
|
||||
return value - level * 0.01;
|
||||
}
|
||||
return value;
|
||||
@@ -84,11 +87,7 @@ const applyWarBuff = (buff: Record<string, unknown>, statName: WarStatName, valu
|
||||
return value;
|
||||
};
|
||||
|
||||
const applyOpposeWarBuff = (
|
||||
buff: Record<string, unknown>,
|
||||
statName: WarStatName,
|
||||
value: number | [number, number]
|
||||
) => {
|
||||
const applyOpposeWarBuff = (buff: Record<string, unknown>, statName: WarStatName, value: number | [number, number]) => {
|
||||
if (typeof value !== 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { General } from '../src/domain/entities.js';
|
||||
import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js';
|
||||
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
|
||||
|
||||
const buildGeneral = (inheritBuff: Record<string, number>): General => ({
|
||||
id: 1,
|
||||
name: 'Tester',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: {
|
||||
flags: {},
|
||||
counters: {},
|
||||
modifiers: {},
|
||||
meta: {},
|
||||
},
|
||||
meta: { killturn: 24, inheritBuff: JSON.stringify(inheritBuff) },
|
||||
});
|
||||
|
||||
describe('inheritance buff legacy keys', () => {
|
||||
it('applies the canonical legacy domestic buff names', () => {
|
||||
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
|
||||
const context = {
|
||||
general: buildGeneral({
|
||||
domesticSuccessProb: 3,
|
||||
domesticFailProb: 2,
|
||||
}),
|
||||
};
|
||||
|
||||
expect(pipeline.onCalcDomestic(context, '농업', 'success', 0.5)).toBeCloseTo(0.53);
|
||||
expect(pipeline.onCalcDomestic(context, '상업', 'fail', 0.2)).toBeCloseTo(0.18);
|
||||
});
|
||||
|
||||
it('continues to read the earlier core success and fail aliases', () => {
|
||||
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
|
||||
const context = {
|
||||
general: buildGeneral({
|
||||
success: 2,
|
||||
fail: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
expect(pipeline.onCalcDomestic(context, '치안', 'success', 0.5)).toBeCloseTo(0.52);
|
||||
expect(pipeline.onCalcDomestic(context, '성벽', 'fail', 0.2)).toBeCloseTo(0.19);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, extname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const imageRoot = resolve(repositoryRoot, '../../image');
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`;
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
|
||||
const operations = (route: Route): string[] => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const installImages = async (page: Page): Promise<void> => {
|
||||
await page.route('**/image/**', async (route) => {
|
||||
const relative = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/image\//, '');
|
||||
for (const candidate of [
|
||||
resolve(imageRoot, relative),
|
||||
resolve(imageRoot, 'game', relative),
|
||||
resolve(imageRoot, 'icons', '22.jpg'),
|
||||
]) {
|
||||
try {
|
||||
const body = await readFile(candidate);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg',
|
||||
body,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// 다음 공개 image root 후보를 확인한다.
|
||||
}
|
||||
}
|
||||
await route.abort('failed');
|
||||
});
|
||||
};
|
||||
|
||||
const statusFixture = {
|
||||
items: {
|
||||
previous: 12_000,
|
||||
lived_month: 240,
|
||||
max_domestic_critical: 80,
|
||||
active_action: 35,
|
||||
combat: 150,
|
||||
sabotage: 60,
|
||||
dex: 42,
|
||||
unifier: 0,
|
||||
tournament: 30,
|
||||
betting: 20,
|
||||
max_belong: 8,
|
||||
},
|
||||
totalPoint: 12_665,
|
||||
inheritConst: {
|
||||
minMonthToAllowInheritItem: 4,
|
||||
inheritBornSpecialPoint: 6000,
|
||||
inheritBornTurntimePoint: 2500,
|
||||
inheritBornCityPoint: 1000,
|
||||
inheritBornStatPoint: 1000,
|
||||
inheritItemUniqueMinPoint: 5000,
|
||||
inheritItemRandomPoint: 3000,
|
||||
inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000],
|
||||
inheritSpecificSpecialPoint: 4000,
|
||||
inheritResetAttrPointBase: [1000, 1000, 2000, 3000],
|
||||
inheritCheckOwnerPoint: 1000,
|
||||
},
|
||||
buffLevels: {
|
||||
warAvoidRatio: 0,
|
||||
warCriticalRatio: 1,
|
||||
warMagicTrialProb: 0,
|
||||
domesticSuccessProb: 0,
|
||||
domesticFailProb: 0,
|
||||
warAvoidRatioOppose: 0,
|
||||
warCriticalRatioOppose: 0,
|
||||
warMagicTrialProbOppose: 0,
|
||||
},
|
||||
resetCosts: { resetSpecialWar: 1000, resetTurnTime: 1000 },
|
||||
resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 },
|
||||
availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }],
|
||||
availableUnique: [
|
||||
{
|
||||
key: 'che_무기_12_칠성검',
|
||||
name: '칠성검(+12)',
|
||||
rawName: '칠성검',
|
||||
info: '무력을 올려주는 유니크 무기입니다.',
|
||||
},
|
||||
],
|
||||
availableTargetGenerals: [{ id: 8, name: '조조' }],
|
||||
turnTimeZones: ['00:00'],
|
||||
isUnited: false,
|
||||
currentSpecialWar: 'che_선봉',
|
||||
currentStat: { leadership: 70, strength: 45, intel: 85 },
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
|
||||
let buffMutationCount = 0;
|
||||
await installImages(page);
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
|
||||
window.localStorage.setItem('sammo-game-profile', 'che');
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const names = operations(route);
|
||||
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { message: '의도한 유산 구입 오류' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = names.map((name) => {
|
||||
if (name === 'inherit.getStatus') return response(statusFixture);
|
||||
if (name === 'lobby.info') {
|
||||
return response({
|
||||
profile: { id: 'che', scenario: 'default', name: '체섭' },
|
||||
world: { year: 200, month: 4 },
|
||||
myGeneral: { id: 7, name: '유비', nationId: 1 },
|
||||
});
|
||||
}
|
||||
if (name === 'inherit.getLogs') {
|
||||
return response([
|
||||
{
|
||||
id: 2,
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '1000 포인트로 장수 소유자 확인',
|
||||
createdAt: '2026-07-26T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (name === 'join.getConfig') {
|
||||
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
|
||||
}
|
||||
if (name === 'inherit.buyHiddenBuff') {
|
||||
buffMutationCount += 1;
|
||||
return response({ ok: true, remainPoint: 11_800 });
|
||||
}
|
||||
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(result),
|
||||
});
|
||||
});
|
||||
return { buffMutationCount: () => buffMutationCount };
|
||||
};
|
||||
|
||||
test.describe('inheritance management legacy parity', () => {
|
||||
test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(gameUrl);
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검');
|
||||
|
||||
const desktop = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const box = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: box.x, width: box.width };
|
||||
};
|
||||
const container = getComputedStyle(document.querySelector<HTMLElement>('#container')!);
|
||||
const title = getComputedStyle(document.querySelector<HTMLElement>('.section-title')!);
|
||||
const button = getComputedStyle(document.querySelector<HTMLElement>('.buy-button')!);
|
||||
return {
|
||||
container: rect('#container'),
|
||||
firstPoint: rect('#inherit_sum'),
|
||||
fontFamily: container.fontFamily,
|
||||
fontSize: container.fontSize,
|
||||
backgroundImage: container.backgroundImage,
|
||||
titleBackgroundImage: title.backgroundImage,
|
||||
buttonBackground: button.backgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
expect(desktop.container.width).toBe(1000);
|
||||
expect(desktop.container.x).toBe(140);
|
||||
expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0);
|
||||
expect(desktop.fontFamily).toContain('Pretendard');
|
||||
expect(desktop.fontSize).toBe('14px');
|
||||
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(desktop.titleBackgroundImage).toContain('back_green.jpg');
|
||||
|
||||
const buyButton = page.locator('.buy-button').first();
|
||||
const beforeHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await buyButton.hover();
|
||||
const afterHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
expect(afterHover).not.toBe(beforeHover);
|
||||
await buyButton.focus();
|
||||
await expect(buyButton).toBeFocused();
|
||||
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-desktop.png'), fullPage: true });
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.reload();
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
const mobile = await page.evaluate(() => {
|
||||
const container = document.querySelector<HTMLElement>('#container')!.getBoundingClientRect();
|
||||
const first = document.querySelector<HTMLElement>('#inherit_sum')!.getBoundingClientRect();
|
||||
const second = document.querySelector<HTMLElement>('#inherit_previous')!.getBoundingClientRect();
|
||||
return {
|
||||
containerWidth: container.width,
|
||||
firstWidth: first.width,
|
||||
stacked: second.y > first.y,
|
||||
};
|
||||
});
|
||||
expect(mobile.containerWidth).toBe(500);
|
||||
expect(mobile.firstWidth).toBeCloseTo(482, 0);
|
||||
expect(mobile.stacked).toBe(true);
|
||||
});
|
||||
|
||||
test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => {
|
||||
const fixture = await installFixture(page);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.goto(gameUrl);
|
||||
await page.locator('#buff-warAvoidRatio').fill('1');
|
||||
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
|
||||
await expect.poll(fixture.buffMutationCount).toBe(1);
|
||||
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
|
||||
});
|
||||
|
||||
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
|
||||
await installFixture(page, { failBuff: true });
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.goto(gameUrl);
|
||||
await page.locator('#buff-warAvoidRatio').fill('1');
|
||||
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible();
|
||||
await expect(page.locator('#buff-warAvoidRatio')).toHaveValue('1');
|
||||
await expect(page.locator('#buff-warAvoidRatio')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
'public-gaps.spec.ts',
|
||||
'instant-diplomacy-message.spec.ts',
|
||||
'tournament-betting.spec.ts',
|
||||
'inheritance-management.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
type CanonicalTurnSnapshot,
|
||||
} from './canonical.js';
|
||||
|
||||
interface GeneralCooldownSelector {
|
||||
generalId: number;
|
||||
actionName: string;
|
||||
}
|
||||
|
||||
export interface TurnCommandFixtureRequest {
|
||||
kind: 'general' | 'nation';
|
||||
actorGeneralId: number;
|
||||
@@ -47,6 +52,7 @@ export interface TurnCommandFixtureRequest {
|
||||
troops?: Array<Record<string, unknown>>;
|
||||
diplomacy?: Array<Record<string, unknown>>;
|
||||
randomFoundingCandidateCityIds?: number[];
|
||||
generalCooldowns?: Array<GeneralCooldownSelector & { nextAvailableTurn: number }>;
|
||||
};
|
||||
observe?: {
|
||||
generalIds?: number[];
|
||||
@@ -54,6 +60,7 @@ export interface TurnCommandFixtureRequest {
|
||||
nationIds?: number[];
|
||||
logAfterId?: number;
|
||||
messageAfterId?: number;
|
||||
generalCooldowns?: GeneralCooldownSelector[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,6 +295,19 @@ const buildWorldInput = (
|
||||
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 generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
|
||||
const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns)
|
||||
? referenceBefore.world.generalCooldowns
|
||||
: [];
|
||||
for (const rawCooldown of referenceGeneralCooldowns) {
|
||||
const cooldown = asRecord(rawCooldown);
|
||||
const generalId = readNumber(cooldown, 'generalId');
|
||||
const actionName = readString(cooldown, 'actionName', '');
|
||||
const nextAvailableTurn = cooldown.nextAvailableTurn;
|
||||
const general = generals.find((entry) => entry.id === generalId);
|
||||
if (general && actionName && typeof nextAvailableTurn === 'number' && Number.isFinite(nextAvailableTurn)) {
|
||||
general.meta[`next_execute_${actionName}`] = nextAvailableTurn;
|
||||
}
|
||||
}
|
||||
const fixtureNations = new Map((request.setup?.nations ?? []).map((row) => [readNumber(row, 'id'), row] as const));
|
||||
const nations = referenceBefore.nations.map((row) =>
|
||||
buildNation(
|
||||
@@ -429,6 +449,7 @@ const projectWorld = (
|
||||
generalIds: Set<number>;
|
||||
cityIds: Set<number>;
|
||||
nationIds: Set<number>;
|
||||
generalCooldowns: GeneralCooldownSelector[];
|
||||
}
|
||||
): CanonicalTurnSnapshot => {
|
||||
const state = world.getState();
|
||||
@@ -489,6 +510,15 @@ const projectWorld = (
|
||||
tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)),
|
||||
turnTime: state.lastTurnTime.toISOString(),
|
||||
isUnited: readNumber(state.meta, 'isUnited'),
|
||||
generalCooldowns: selector.generalCooldowns.map(({ generalId, actionName }) => {
|
||||
const general = world.getGeneralById(generalId);
|
||||
const raw = general?.meta[`next_execute_${actionName}`];
|
||||
return {
|
||||
generalId,
|
||||
actionName,
|
||||
nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
generals,
|
||||
cities: world
|
||||
@@ -595,6 +625,7 @@ export const runCoreTurnCommandTrace = async (
|
||||
]),
|
||||
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
|
||||
nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))),
|
||||
generalCooldowns: request.observe?.generalCooldowns ?? [],
|
||||
};
|
||||
const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, {
|
||||
maxGeneralTurns: 10,
|
||||
|
||||
@@ -931,6 +931,133 @@ integration('general command pre-required turn boundary matrix', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const readGeneralCooldown = (
|
||||
snapshot: { world: Record<string, unknown> },
|
||||
generalId: number,
|
||||
actionName: string
|
||||
): number | null => {
|
||||
const cooldowns = Array.isArray(snapshot.world.generalCooldowns) ? snapshot.world.generalCooldowns : [];
|
||||
const matched = cooldowns.find(
|
||||
(entry) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
(entry as Record<string, unknown>).generalId === generalId &&
|
||||
(entry as Record<string, unknown>).actionName === actionName
|
||||
);
|
||||
const value =
|
||||
typeof matched === 'object' && matched !== null ? (matched as Record<string, unknown>).nextAvailableTurn : null;
|
||||
return typeof value === 'number' ? value : null;
|
||||
};
|
||||
|
||||
integration('general command post-required cooldown boundary matrix', () => {
|
||||
it('stores the same 60-turn cooldown after domestic trait reset completion', async () => {
|
||||
const request = buildRequest('che_내정특기초기화', undefined, {
|
||||
specialDomestic: 'che_인덕',
|
||||
lastTurn: { command: '내정 특기 초기화', term: 1 },
|
||||
});
|
||||
request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }];
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const expectedNextAvailableTurn = 190 * 12 + 1 - 1 + 60 - 1;
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_내정특기초기화',
|
||||
actionKey: 'che_내정특기초기화',
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn);
|
||||
expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('blocks domestic trait reset one turn before the cooldown boundary', async () => {
|
||||
const currentYearMonth = 190 * 12 + 1 - 1;
|
||||
const request = buildRequest('che_내정특기초기화', undefined, {
|
||||
specialDomestic: 'che_인덕',
|
||||
lastTurn: { command: '내정 특기 초기화', term: 1 },
|
||||
});
|
||||
request.setup!.generalCooldowns = [
|
||||
{
|
||||
generalId: 1,
|
||||
actionName: '내정 특기 초기화',
|
||||
nextAvailableTurn: currentYearMonth + 1,
|
||||
},
|
||||
];
|
||||
request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }];
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(readGeneralCooldown(reference.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
||||
expect(readGeneralCooldown(core.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_내정특기초기화',
|
||||
actionKey: '휴식',
|
||||
usedFallback: true,
|
||||
blockedReason: '1턴 더 기다려야 합니다',
|
||||
});
|
||||
expect(reference.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true);
|
||||
expect(core.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true);
|
||||
expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
||||
expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('allows war trait reset exactly at the cooldown boundary', async () => {
|
||||
const currentYearMonth = 190 * 12 + 1 - 1;
|
||||
const request = buildRequest('che_전투특기초기화', undefined, {
|
||||
specialWar: 'che_귀병',
|
||||
lastTurn: { command: '전투 특기 초기화', term: 1 },
|
||||
});
|
||||
request.setup!.generalCooldowns = [
|
||||
{
|
||||
generalId: 1,
|
||||
actionName: '전투 특기 초기화',
|
||||
nextAvailableTurn: currentYearMonth,
|
||||
},
|
||||
];
|
||||
request.observe!.generalCooldowns = [{ generalId: 1, actionName: '전투 특기 초기화' }];
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const expectedNextAvailableTurn = currentYearMonth + 60 - 1;
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_전투특기초기화',
|
||||
actionKey: 'che_전투특기초기화',
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(readGeneralCooldown(reference.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn);
|
||||
expect(readGeneralCooldown(core.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
type GeneralConstraintCase = {
|
||||
name: string;
|
||||
action: string;
|
||||
|
||||
Reference in New Issue
Block a user