merge: 최신 main 변경을 NPC 삭턴 사망 복구에 통합

This commit is contained in:
2026-08-21 04:52:41 +00:00
18 changed files with 456 additions and 32 deletions
+25 -2
View File
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { LogCategory, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogScope } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common'; import { asRecord, type RankDataType } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { import {
@@ -63,6 +63,14 @@ const zImmediateActionInput = z
}) })
.optional(); .optional();
const MAIN_RECORD_LIMIT = 15; const MAIN_RECORD_LIMIT = 15;
const PERSONAL_RECORD_TYPES = [
'firenum',
'warnum',
'killnum',
'deathnum',
'killcrew',
'deathcrew',
] as const satisfies readonly RankDataType[];
const NEUTRAL_NATION_CONTEXT = { const NEUTRAL_NATION_CONTEXT = {
id: 0, id: 0,
name: '재야', name: '재야',
@@ -276,7 +284,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
const metaRecord = asRecord(general.meta); const metaRecord = asRecord(general.meta);
const officerCityId = readNumber(metaRecord.officerCity ?? metaRecord.officer_city ?? metaRecord.officerCityId, 0); const officerCityId = readNumber(metaRecord.officerCity ?? metaRecord.officer_city ?? metaRecord.officerCityId, 0);
const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog] = const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog, rankRows] =
await Promise.all([ await Promise.all([
general.cityId > 0 general.cityId > 0
? ctx.db.city.findUnique({ ? ctx.db.city.findUnique({
@@ -346,6 +354,10 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
where: { generalId: general.id }, where: { generalId: general.id },
select: { refreshScore: true, refreshScoreTotal: true }, select: { refreshScore: true, refreshScoreTotal: true },
}), }),
ctx.db.rankData.findMany({
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
select: { type: true, value: true },
}),
]); ]);
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
@@ -466,6 +478,8 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
}; };
const refreshScore = accessLog?.refreshScore ?? 0; const refreshScore = accessLog?.refreshScore ?? 0;
const refreshScoreTotal = accessLog?.refreshScoreTotal ?? 0; const refreshScoreTotal = accessLog?.refreshScoreTotal ?? 0;
const rankValues = new Map(rankRows.map((row) => [row.type, row.value]));
const rankValue = (type: (typeof PERSONAL_RECORD_TYPES)[number]): number => rankValues.get(type) ?? 0;
const troopStatus: 'inactive' | 'present' | 'away' = const troopStatus: 'inactive' | 'present' | 'away' =
troopLeaderFirstTurn?.actionCode !== undefined && troopLeaderFirstTurn.actionCode !== 'che_집합' troopLeaderFirstTurn?.actionCode !== undefined && troopLeaderFirstTurn.actionCode !== 'che_집합'
? 'inactive' ? 'inactive'
@@ -527,6 +541,15 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30), statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)), dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
}, },
records: {
battles: rankValue('warnum'),
strategies: rankValue('firenum'),
serviceYears: readNumber(metaRecord.belong, 0),
wins: rankValue('killnum'),
losses: rankValue('deathnum'),
killedCrew: rankValue('killcrew'),
lostCrew: rankValue('deathcrew'),
},
items: { items: {
horse: normalizeItemCode(general.horseCode), horse: normalizeItemCode(general.horseCode),
weapon: normalizeItemCode(general.weaponCode), weapon: normalizeItemCode(general.weaponCode),
+9 -5
View File
@@ -72,6 +72,11 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff); const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
const readStringList = (raw: unknown): string[] => {
const parsed = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
};
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => { const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null; 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))); return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
@@ -133,7 +138,7 @@ const patchGeneral = async (
strength?: number; strength?: number;
intelligence?: number; intelligence?: number;
}; };
specialWar?: string; specialWar?: string | null;
} }
): Promise<void> => { ): Promise<void> => {
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
@@ -530,16 +535,15 @@ export const inheritRouter = router({
} }
const meta = asRecord(general.meta); const meta = asRecord(general.meta);
const prevList = const prevList = readStringList(meta.prev_types_special2);
parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
prevList.push(general.special2Code); prevList.push(general.special2Code);
await patchGeneral(ctx, general.id, { await patchGeneral(ctx, general.id, {
specialWar: 'None', specialWar: null,
meta: { meta: {
...meta, ...meta,
inheritResetSpecialWar: nextLevel, inheritResetSpecialWar: nextLevel,
prev_types_special2: JSON.stringify(prevList), prev_types_special2: prevList,
}, },
}); });
@@ -83,6 +83,7 @@ const buildContext = (authenticated: boolean, generalAccessTracking = false) =>
general: { general: {
findFirst: findGeneral, findFirst: findGeneral,
}, },
rankData: { findMany: async () => [] },
city: { findUnique: findCity }, city: { findUnique: findCity },
nation: { findUnique: findNation }, nation: { findUnique: findNation },
generalAccessLog: { findUnique: async () => null }, generalAccessLog: { findUnique: async () => null },
@@ -85,6 +85,7 @@ const createContext = (options: {
troopLeaderAction?: string | null; troopLeaderAction?: string | null;
refreshScore?: number; refreshScore?: number;
refreshScoreTotal?: number; refreshScoreTotal?: number;
rankRows?: Array<{ type: string; value: number }>;
requestId?: string; requestId?: string;
transaction?: ReturnType<typeof vi.fn>; transaction?: ReturnType<typeof vi.fn>;
}) => { }) => {
@@ -128,6 +129,9 @@ const createContext = (options: {
refreshScoreTotal: options.refreshScoreTotal ?? 0, refreshScoreTotal: options.refreshScoreTotal ?? 0,
})), })),
}, },
rankData: {
findMany: vi.fn(async () => options.rankRows ?? []),
},
city: { city: {
findUnique: vi.fn(async () => options.city ?? null), findUnique: vi.fn(async () => options.city ?? null),
aggregate: vi.fn(async () => ({ aggregate: vi.fn(async () => ({
@@ -206,6 +210,41 @@ const createContext = (options: {
}; };
describe('in-game my information ownership', () => { describe('in-game my information ownership', () => {
it('returns the owned general battle records from the same rank_data source used by rankings', async () => {
const fixture = createContext({
me: buildGeneral({ meta: { belong: 4, rank_killnum: 999 } }),
rankRows: [
{ type: 'firenum', value: 12 },
{ type: 'warnum', value: 8 },
{ type: 'killnum', value: 5 },
{ type: 'deathnum', value: 3 },
{ type: 'killcrew', value: 12_345 },
{ type: 'deathcrew', value: 6_789 },
],
});
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
general: {
records: {
battles: 8,
strategies: 12,
serviceYears: 4,
wins: 5,
losses: 3,
killedCrew: 12_345,
lostCrew: 6_789,
},
},
});
expect(fixture.db.rankData.findMany).toHaveBeenCalledWith({
where: {
generalId: 7,
type: { in: ['firenum', 'warnum', 'killnum', 'deathnum', 'killcrew', 'deathcrew'] },
},
select: { type: true, value: true },
});
});
it('returns every ref progress-bar input from the owned general and current city read model', async () => { it('returns every ref progress-bar input from the owned general and current city read model', async () => {
const fixture = createContext({ const fixture = createContext({
me: buildGeneral({ me: buildGeneral({
+84
View File
@@ -331,6 +331,90 @@ describe('inherit router actor and permission boundaries', () => {
); );
}); });
it('reserves the selected Ref war trait and charges the authenticated owner once', async () => {
const fixture = buildContext({
inheritancePoint: 5_000,
configConst: { availableSpecialWar: ['che_의술'] },
});
await expect(
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } },
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '4000 포인트로 다음 전투 특기로 의술 지정',
},
});
});
it('does not dispatch or charge when a different war trait is already reserved', async () => {
const fixture = buildContext({
inheritancePoint: 5_000,
general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }),
configConst: { availableSpecialWar: ['che_의술'] },
});
await expect(
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('resets the current war trait to the in-memory null sentinel and preserves Ref history as an array', async () => {
const fixture = buildContext({
inheritancePoint: 2_000,
general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }),
});
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: {
specialWar: null,
meta: {
prev_types_special2: ['che_돌격', 'che_선봉'],
marker: 3,
inheritResetSpecialWar: 0,
},
},
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 전투 특기 초기화',
},
});
});
it('does not dispatch or charge when the current war trait is already blank', async () => {
const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) });
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '이미 전투 특기가 공란입니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => { it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => {
const fixture = buildContext({ const fixture = buildContext({
inheritancePoint: 2_000, inheritancePoint: 2_000,
+1 -1
View File
@@ -257,7 +257,7 @@ const zPatchGeneral = z.object({
intelligence: zFiniteNumber.optional(), intelligence: zFiniteNumber.optional(),
}) })
.optional(), .optional(),
specialWar: z.string().optional(), specialWar: z.string().nullable().optional(),
}), }),
}); });
@@ -734,10 +734,10 @@ async function handlePatchGeneral(
...command.patch.stats, ...command.patch.stats,
}; };
} }
if (typeof command.patch.specialWar === 'string') { if (command.patch.specialWar !== undefined) {
patch.role = { patch.role = {
...general.role, ...general.role,
specialWar: command.patch.specialWar, specialWar: command.patch.specialWar === 'None' ? null : command.patch.specialWar,
}; };
} }
@@ -1,11 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { LogCategory, LogFormat } from '@sammo-ts/logic'; import { LogCategory, LogFormat } from '@sammo-ts/logic';
import type { TurnDaemonCommand } from '@sammo-ts/common';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
import { import {
createAddGlobalBetrayHandler, createAddGlobalBetrayHandler,
createAssignGeneralSpecialityHandler, createAssignGeneralSpecialityHandler,
} from '../src/turn/monthlySpecialityBetrayAction.js'; } from '../src/turn/monthlySpecialityBetrayAction.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const event: TurnEvent = { const event: TurnEvent = {
@@ -177,6 +180,84 @@ describe('monthly speciality and betrayal actions', () => {
]); ]);
}); });
it.each([
['고정 후 초기화', ['reserve', 'reset']],
['초기화 후 고정', ['reset', 'reserve']],
] as const)('%s 순서에서도 다음 월에 지정한 전투 특기를 지급한다', async (_label, steps) => {
const world = buildWorld();
const initial = world.getGeneralById(3)!;
const initialMeta = { ...initial.meta };
delete initialMeta.inheritSpecificSpecialWar;
world.updateGeneral(3, {
role: { ...initial.role, specialWar: 'che_신산' },
meta: initialMeta,
});
world.acknowledgeDirtyState(world.peekDirtyState());
const commandHandler = createTurnDaemonCommandHandler({ world });
let requestIndex = 0;
const dispatchPatch = async (patch: Extract<TurnDaemonCommand, { type: 'patchGeneral' }>['patch']) => {
requestIndex += 1;
const command = normalizeTurnDaemonCommand({
requestId: `inherit-war-trait-${requestIndex}`,
sentAt: '2026-08-21T00:00:00.000Z',
command: { type: 'patchGeneral', generalId: 3, patch },
});
expect(command).not.toBeNull();
await expect(commandHandler.handle(command!)).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
};
for (const step of steps) {
const current = world.getGeneralById(3)!;
if (step === 'reserve') {
await dispatchPatch({
meta: { ...current.meta, inheritSpecificSpecialWar: 'che_의술' },
});
} else {
await dispatchPatch({
specialWar: null,
meta: {
...current.meta,
inheritResetSpecialWar: 0,
prev_types_special2: ['che_신산'],
},
});
}
}
expect(world.getGeneralById(3)?.role.specialWar).toBeNull();
expect(world.getGeneralById(3)?.meta).toMatchObject({
inheritSpecificSpecialWar: 'che_의술',
prev_types_special2: ['che_신산'],
});
await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], environment, event);
expect(world.getGeneralById(3)?.role.specialWar).toBe('che_의술');
expect(world.getGeneralById(3)?.meta).not.toHaveProperty('inheritSpecificSpecialWar');
expect(world.getGeneralById(3)?.meta.prev_types_special2).toEqual(['che_신산']);
expect(
world
.peekDirtyState()
.logs.filter((log) => log.generalId === 3)
.map((log) => log.text)
).toEqual(['특기 【<b><C>의술</></b>】을 습득', '특기 【<b><L>의술</></b>】을 익혔습니다!']);
});
it('normalizes the legacy None sentinel before monthly eligibility checks', async () => {
const world = buildWorld();
const target = world.getGeneralById(3)!;
world.updateGeneral(3, { role: { ...target.role, specialWar: 'che_신산' } });
world.acknowledgeDirtyState(world.peekDirtyState());
const commandHandler = createTurnDaemonCommandHandler({ world });
await expect(
commandHandler.handle({ type: 'patchGeneral', generalId: 3, patch: { specialWar: 'None' } })
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
expect(world.getGeneralById(3)?.role.specialWar).toBeNull();
});
it('does nothing before the three-year opening period ends', async () => { it('does nothing before the three-year opening period ends', async () => {
const world = buildWorld(); const world = buildWorld();
await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event); await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event);
@@ -9,6 +9,7 @@ import {
createAddGlobalBetrayHandler, createAddGlobalBetrayHandler,
createAssignGeneralSpecialityHandler, createAssignGeneralSpecialityHandler,
} from '../src/turn/monthlySpecialityBetrayAction.js'; } from '../src/turn/monthlySpecialityBetrayAction.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -105,7 +106,7 @@ integration('monthly speciality and betrayal persistence', () => {
}), }),
buildGeneral(generalIds[1], { buildGeneral(generalIds[1], {
domestic: 'che_경작', domestic: 'che_경작',
war: null, war: 'che_신산',
meta: { meta: {
specage: 99, specage: 99,
specage2: 30, specage2: 30,
@@ -198,6 +199,22 @@ integration('monthly speciality and betrayal persistence', () => {
const hooks = await createDatabaseTurnHooks(databaseUrl!, world); const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try { try {
const reservedGeneral = world.getGeneralById(generalIds[1])!;
const commandHandler = createTurnDaemonCommandHandler({ world });
await expect(
commandHandler.handle({
type: 'patchGeneral',
generalId: reservedGeneral.id,
patch: {
specialWar: null,
meta: {
...reservedGeneral.meta,
inheritResetSpecialWar: 0,
prev_types_special2: ['che_신산'],
},
},
})
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z'));
await hooks.hooks.flushChanges?.({ await hooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(), lastTurnTime: state.lastTurnTime.toISOString(),
@@ -214,7 +231,12 @@ integration('monthly speciality and betrayal persistence', () => {
expect(rows[0]?.specialCode).not.toBe('None'); expect(rows[0]?.specialCode).not.toBe('None');
expect(rows[0]?.meta).toMatchObject({ betray: 2 }); expect(rows[0]?.meta).toMatchObject({ betray: 2 });
expect(rows[1]).toMatchObject({ special2Code: 'che_의술' }); expect(rows[1]).toMatchObject({ special2Code: 'che_의술' });
expect(rows[1]?.meta).toMatchObject({ betray: 3, marker: 2 }); expect(rows[1]?.meta).toMatchObject({
betray: 3,
marker: 2,
inheritResetSpecialWar: 0,
prev_types_special2: ['che_신산'],
});
expect(rows[1]?.meta).not.toHaveProperty('inheritSpecificSpecialWar'); expect(rows[1]?.meta).not.toHaveProperty('inheritSpecificSpecialWar');
expect(await db.logEntry.count({ where: { generalId: { in: [...generalIds] } } })).toBe(4); expect(await db.logEntry.count({ where: { generalId: { in: [...generalIds] } } })).toBe(4);
} finally { } finally {
+12
View File
@@ -112,6 +112,15 @@ const myGeneral = (state: FixtureState) => ({
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [350, 1_375, 3_500, 7_125, 1_275_975], dex: [350, 1_375, 3_500, 7_125, 1_275_975],
}, },
records: {
battles: 8,
strategies: 12,
serviceYears: 4,
wins: 5,
losses: 3,
killedCrew: 12_345,
lostCrew: 6_789,
},
items: { horse: 'che_명마', weapon: null, book: null, item: null }, items: { horse: 'che_명마', weapon: null, book: null, item: null },
itemNames: { horse: '명마', weapon: null, book: null, item: null }, itemNames: { horse: '명마', weapon: null, book: null, item: null },
}, },
@@ -1184,6 +1193,9 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png'); expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관'); await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병'); await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
await expect(page.locator('.legacy-general-details')).toContainText('전투 8 · 계략 12 · 사관 4년');
await expect(page.locator('.legacy-general-details')).toContainText('승률 62.50% · 승리 5 · 패배 3');
await expect(page.locator('.legacy-general-details')).toContainText('살상률 181.84% · 사살 12,345 · 피살 6,789');
await expect(page.locator('.item-group')).toContainText('명마'); await expect(page.locator('.item-group')).toContainText('명마');
await expect(page.locator('#container')).not.toContainText('che_'); await expect(page.locator('#container')).not.toContainText('che_');
await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('.title-row')).toContainText('내 정 보');
+3 -3
View File
@@ -796,12 +796,12 @@ onMounted(() => {
width: min(100%, 1000px); width: min(100%, 1000px);
margin: 0 auto; margin: 0 auto;
border: 1px solid #888; border: 1px solid #888;
overflow: hidden; overflow-x: hidden;
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;
padding: 0 7px; padding: 0 7px;
color: #fff; color: #fff;
height: 1597px; min-height: 1597px;
font: 14px/21px var(--sammo-font-sans); font: 14px/21px var(--sammo-font-sans);
} }
@@ -1017,7 +1017,7 @@ a:not(.legacy-button):focus-visible {
} }
.inherit-page { .inherit-page {
height: 3047.5px; min-height: 3047.5px;
} }
.shop-item .buy-button { .shop-item .buy-button {
+19 -3
View File
@@ -136,6 +136,9 @@ const statusLine = computed(() =>
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0); const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {})); const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const numberText = (value: number): string => value.toLocaleString('ko-KR');
const percentText = (numerator: number, denominator: number): string =>
`${((numerator / Math.max(denominator, 1)) * 100).toFixed(2)}%`;
const noDefencePenaltyWaived = computed(() => { const noDefencePenaltyWaived = computed(() => {
const environment = asRecord(world.value?.config.environment); const environment = asRecord(world.value?.config.environment);
return isDefenceTrainPenaltyWaivedByScenarioEffect( return isDefenceTrainPenaltyWaivedByScenarioEffect(
@@ -437,9 +440,22 @@ onMounted(() => {
}})</strong }})</strong
> >
</div> </div>
<div>전투 0 · 계략 0 · 사관 7</div> <div>
<div>승률 0% · 승리 0 · 패배 0</div> 전투 {{ numberText(data.general.records.battles) }} · 계략
<div>살상률 0% · 사살 0 · 피살 0</div> {{ numberText(data.general.records.strategies) }} · 사관
{{ numberText(data.general.records.serviceYears) }}
</div>
<div>
승률 {{ percentText(data.general.records.wins, data.general.records.battles) }} · 승리
{{ numberText(data.general.records.wins) }} · 패배
{{ numberText(data.general.records.losses) }}
</div>
<div>
살상률
{{ percentText(data.general.records.killedCrew, data.general.records.lostCrew) }} · 사살
{{ numberText(data.general.records.killedCrew) }} · 피살
{{ numberText(data.general.records.lostCrew) }}
</div>
<div> <div>
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종 소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
{{ data.general.crewTypeName ?? '-' }} · 내정특기 {{ data.general.crewTypeName ?? '-' }} · 내정특기
+18 -8
View File
@@ -1,11 +1,20 @@
# Caddy prefix 계약 # Core2026 환경별 Caddy prefix 계약
## 환경과 ingress ## 환경과 ingress
| 환경 | 공개 주소 | 연결 계약 | | 환경 | 공개 주소·prefix | 접속·연결 계약 |
| ---- | --------- | --------- | | ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| 공개 | `dev-sam2026.hided.net` | 실제 외부 Core2026 서비스입니다. 로컬 Docker `14999`의 주소가 아닙니다. | | Ref | `dev-sam-ref.hided.net` | 개발 호스트 `172.30.1.54:3400`의 PHP 기준 구현입니다. |
| E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → `172.30.1.54:14999` HTTP → Docker Caddy입니다. | | 로컬 E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → 개발 호스트 `172.30.1.54:14999` HTTP → Docker Caddy입니다. |
| 공개 개발 | `dev-sam2026.hided.net` | `ssh serv``core2026-dev-sam2026`/`hidche_ng_my`입니다. 로컬 E2E `14999`와 다른 호스트입니다. |
| sam Core 운영 | `sam.hided.net/gateway/`와 일곱 profile prefix | `ssh serv``core2026-sam-production`/`hidche_core2026_my`입니다. |
| sam PHP 운영 | `sam.hided.net/sam/`과 기존 PHP 경로 | `ssh serv`의 별도 `sam_hided_net` project입니다. |
`dev-sam2026.hided.net``sam.hided.net` Core prefix는 같은 Git 구현을 사용할 수
있지만 PostgreSQL, Redis, named volume, release queue와 active commit이 분리된
배포 환경입니다. 한 환경의 release/API/Chromium 결과를 다른 환경의 반영 근거로
사용하지 않습니다. 상위 `sam_rebuild` 작업공간에서는
`docs/docker-environment-routing.md`의 전체 결정 절차도 함께 따릅니다.
외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래 외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래
`Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는 `Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는
@@ -26,7 +35,7 @@ HTTP_PORT=14999
`https://dev-sam-e2e.hided.net/gateway/oauth/callback`을 파생합니다. 도메인을 `https://dev-sam-e2e.hided.net/gateway/oauth/callback`을 파생합니다. 도메인을
바꾼 뒤에는 Caddy뿐 아니라 runtime도 재생성하여 process 환경을 갱신합니다. 바꾼 뒤에는 Caddy뿐 아니라 runtime도 재생성하여 process 환경을 갱신합니다.
## 활성 경로 ## 로컬 E2E 활성 경로
| 서비스 | 공개 prefix | frontend | API | | 서비스 | 공개 prefix | frontend | API |
| ------- | ----------- | -------: | ------: | | ------- | ----------- | -------: | ------: |
@@ -36,8 +45,9 @@ HTTP_PORT=14999
표의 port는 Docker 내부 Caddy가 연결하는 frontend/API listener입니다. 외부 표의 port는 Docker 내부 Caddy가 연결하는 frontend/API listener입니다. 외부
Caddy가 이 port들에 직접 연결하지 않습니다. `kwe`, `pwe`, `twe`, `nya`, Caddy가 이 port들에 직접 연결하지 않습니다. `kwe`, `pwe`, `twe`, `nya`,
`pya`는 resource·profile 이름으로 사용할 수 있지만 활성 Caddy route가 `pya` 로컬 E2E에서 resource·profile 이름으로 사용할 수 있지만 활성 Caddy
아닙니다. route가 아닙니다. `sam.hided.net`의 별도 운영 Core stack에는 이 다섯 profile도
활성 prefix이므로 환경별 계약을 섞지 않습니다.
Caddy는 prefix를 보존해 upstream에 전달합니다. 앱은 root 배포를 가정하지 Caddy는 prefix를 보존해 upstream에 전달합니다. 앱은 root 배포를 가정하지
않고 frontend base, tRPC, SSE, upload와 direct navigation에 같은 prefix를 않고 frontend base, tRPC, SSE, upload와 direct navigation에 같은 prefix를
+1 -1
View File
@@ -86,7 +86,7 @@ storage, route guards, and image loading.
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error | | best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error | | hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | | 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 | | 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, two 30-row history pages that expand the document and keep the last row/load-more button reachable by scrolling |
| 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 | | 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 | | 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 | | 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 |
+1 -1
View File
@@ -230,7 +230,7 @@ export type TurnDaemonCommand =
strength?: number; strength?: number;
intelligence?: number; intelligence?: number;
}; };
specialWar?: string; specialWar?: string | null;
}; };
} }
| { | {
@@ -55,6 +55,18 @@ describe('Ref event domestic traits', () => {
expect(eventMusang!.getWarPowerMultiplier?.(context, unit, unit)).toEqual([1, 1]); expect(eventMusang!.getWarPowerMultiplier?.(context, unit, unit)).toEqual([1, 1]);
}); });
it('applies the persisted victory count to the ordinary 무쌍 battle multiplier', async () => {
const musang = await new WarTraitLoader().load('che_무쌍');
const unit = {
getGeneral: () => ({ meta: { rank_killnum: 40 } }),
} as unknown as WarUnit;
const context = { unit } as unknown as WarActionContext;
const multiplier = musang.getWarPowerMultiplier?.(context, unit, unit);
expect(multiplier?.[0]).toBeCloseTo(1.2, 12);
expect(multiplier?.[1]).toBeCloseTo(0.92, 12);
});
it('keeps event and ordinary 견고 injury-prevention triggers distinct by raise type', async () => { it('keeps event and ordinary 견고 injury-prevention triggers distinct by raise type', async () => {
const [eventGyeongo] = await loadEventDomesticTraitModules(['che_event_견고']); const [eventGyeongo] = await loadEventDomesticTraitModules(['che_event_견고']);
const canonical = await new WarTraitLoader().load('che_견고'); const canonical = await new WarTraitLoader().load('che_견고');
+57
View File
@@ -224,6 +224,63 @@ describe('war triggers', () => {
expect(general.atmos).toBeCloseTo(115.5, 12); expect(general.atmos).toBeCloseTo(115.5, 12);
}); });
it('accumulates battle, victory, loss, and casualty records on the persisted rank meta keys', () => {
const attacker = buildGeneral(80);
attacker.meta = { ...attacker.meta, rank_warnum: 2, rank_killnum: 3, rank_killcrew: 400 };
const defender = {
...buildGeneral(70),
id: 2,
name: 'Defender',
nationId: 2,
meta: { ...buildGeneral(70).meta, rank_warnum: 4, rank_deathnum: 1, rank_deathcrew: 500 },
};
const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!);
const attackerUnit = new WarUnitGeneral(
new RandUtil(new ConstantRNG(0)),
buildConfig(),
attacker,
buildCity(),
buildNation(),
true,
crewType,
new ActionLogger({ generalId: attacker.id, nationId: attacker.nationId }),
new WarActionPipeline([])
);
const defenderUnit = new WarUnitGeneral(
new RandUtil(new ConstantRNG(0)),
buildConfig(),
defender,
{ ...buildCity(), nationId: 2 },
{ ...buildNation(), id: 2 },
false,
crewType,
new ActionLogger({ generalId: defender.id, nationId: defender.nationId }),
new WarActionPipeline([])
);
attackerUnit.setOppose(defenderUnit);
defenderUnit.setOppose(attackerUnit);
attackerUnit.increaseKilled(120);
defenderUnit.decreaseHP(120);
attackerUnit.addWin();
defenderUnit.addLose();
attackerUnit.finishBattle();
defenderUnit.finishBattle();
expect(attacker.meta).toMatchObject({
rank_warnum: 3,
rank_killnum: 4,
rank_killcrew: 520,
rank_killcrew_person: 120,
});
expect(defender.meta).toMatchObject({
rank_warnum: 5,
rank_deathnum: 2,
rank_deathcrew: 620,
rank_deathcrew_person: 120,
});
});
it('updates the legacy experience level and applies item experience modifiers immediately', () => { it('updates the legacy experience level and applies item experience modifiers immediately', () => {
const general = buildGeneral(80); const general = buildGeneral(80);
general.experience = 90; general.experience = 90;
@@ -4,7 +4,7 @@ import { dirname, extname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const imageRoot = resolve(repositoryRoot, '../../image'); const imageRoot = process.env.FRONTEND_PARITY_IMAGE_ROOT ?? resolve(repositoryRoot, '../../image');
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; 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 gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`;
@@ -117,9 +117,21 @@ const statusFixture = {
currentStat: { leadership: 70, strength: 45, intel: 85 }, currentStat: { leadership: 70, strength: 45, intel: 85 },
}; };
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => { interface InheritanceLogFixture {
id: number;
year: number;
month: number;
text: string;
createdAt: string;
}
const installFixture = async (
page: Page,
options: { failBuff?: boolean; logPages?: InheritanceLogFixture[][] } = {}
) => {
let buffMutationCount = 0; let buffMutationCount = 0;
let resetTurnMutationCount = 0; let resetTurnMutationCount = 0;
let logRequestCount = 0;
const uniqueAuctionRequests: unknown[] = []; const uniqueAuctionRequests: unknown[] = [];
await installImages(page); await installImages(page);
await page.addInitScript(() => { await page.addInitScript(() => {
@@ -148,7 +160,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
}); });
} }
if (name === 'inherit.getLogs') { if (name === 'inherit.getLogs') {
return response([ const defaultPage = [
{ {
id: 2, id: 2,
year: 200, year: 200,
@@ -156,7 +168,11 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
text: '1000 포인트로 장수 소유자 확인', text: '1000 포인트로 장수 소유자 확인',
createdAt: '2026-07-26T00:00:00.000Z', createdAt: '2026-07-26T00:00:00.000Z',
}, },
]); ];
const pages = options.logPages ?? [defaultPage];
const pageIndex = Math.min(logRequestCount, pages.length - 1);
logRequestCount += 1;
return response(pages[pageIndex] ?? []);
} }
if (name === 'join.getConfig') { if (name === 'join.getConfig') {
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } }); return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
@@ -184,6 +200,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
return { return {
buffMutationCount: () => buffMutationCount, buffMutationCount: () => buffMutationCount,
resetTurnMutationCount: () => resetTurnMutationCount, resetTurnMutationCount: () => resetTurnMutationCount,
logRequestCount: () => logRequestCount,
uniqueAuctionRequests, uniqueAuctionRequests,
}; };
}; };
@@ -333,6 +350,52 @@ test.describe('inheritance management legacy parity', () => {
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000'); await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
}); });
test('keeps every paged inheritance log reachable by document scrolling', async ({ page }) => {
const buildPage = (firstId: number, count: number): InheritanceLogFixture[] =>
Array.from({ length: count }, (_, index) => {
const id = firstId - index;
return {
id,
year: 200,
month: 4,
text: `유산 포인트 변경 내역 ${id}`,
createdAt: `2026-07-${String((id % 27) + 1).padStart(2, '0')}T00:00:00.000Z`,
};
});
const fixture = await installFixture(page, {
logPages: [buildPage(60, 30), buildPage(30, 30), []],
});
await page.setViewportSize({ width: 500, height: 900 });
await page.goto(gameUrl);
await expect(page.locator('.log-row')).toHaveCount(30);
const firstHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0);
const moreButton = page.getByRole('button', { name: '더 가져오기' });
await moreButton.click();
await expect(page.locator('.log-row')).toHaveCount(60);
await expect(page.locator('.log-row').last()).toContainText('유산 포인트 변경 내역 1');
const expandedHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0);
expect(expandedHeight).toBeGreaterThan(firstHeight);
await page.evaluate(() => window.scrollTo(0, document.scrollingElement?.scrollHeight ?? 0));
await expect(page.locator('.log-row').last()).toBeInViewport();
await expect(moreButton).toBeInViewport();
expect(
await page.evaluate(() =>
Math.abs(
window.scrollY + window.innerHeight - (document.scrollingElement?.scrollHeight ?? window.innerHeight)
)
)
).toBeLessThanOrEqual(1);
if (artifactRoot) {
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-mobile-60-logs.png'), fullPage: true });
}
await moreButton.click();
await expect.poll(fixture.logRequestCount).toBe(3);
await expect(moreButton).toBeDisabled();
});
test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => { test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => {
const fixture = await installFixture(page); const fixture = await installFixture(page);
await page.goto(gameUrl); await page.goto(gameUrl);