merge: 최신 main 변경을 유산 전투 특기 수정에 통합

This commit is contained in:
2026-08-21 04:45:43 +00:00
12 changed files with 302 additions and 40 deletions
+25 -2
View File
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
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 {
@@ -63,6 +63,14 @@ const zImmediateActionInput = z
})
.optional();
const MAIN_RECORD_LIMIT = 15;
const PERSONAL_RECORD_TYPES = [
'firenum',
'warnum',
'killnum',
'deathnum',
'killcrew',
'deathcrew',
] as const satisfies readonly RankDataType[];
const NEUTRAL_NATION_CONTEXT = {
id: 0,
name: '재야',
@@ -276,7 +284,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
const metaRecord = asRecord(general.meta);
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([
general.cityId > 0
? ctx.db.city.findUnique({
@@ -346,6 +354,10 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
where: { generalId: general.id },
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;
@@ -466,6 +478,8 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
};
const refreshScore = accessLog?.refreshScore ?? 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' =
troopLeaderFirstTurn?.actionCode !== undefined && troopLeaderFirstTurn.actionCode !== 'che_집합'
? 'inactive'
@@ -527,6 +541,15 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
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: {
horse: normalizeItemCode(general.horseCode),
weapon: normalizeItemCode(general.weaponCode),
@@ -83,6 +83,7 @@ const buildContext = (authenticated: boolean, generalAccessTracking = false) =>
general: {
findFirst: findGeneral,
},
rankData: { findMany: async () => [] },
city: { findUnique: findCity },
nation: { findUnique: findNation },
generalAccessLog: { findUnique: async () => null },
@@ -85,6 +85,7 @@ const createContext = (options: {
troopLeaderAction?: string | null;
refreshScore?: number;
refreshScoreTotal?: number;
rankRows?: Array<{ type: string; value: number }>;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
}) => {
@@ -128,6 +129,9 @@ const createContext = (options: {
refreshScoreTotal: options.refreshScoreTotal ?? 0,
})),
},
rankData: {
findMany: vi.fn(async () => options.rankRows ?? []),
},
city: {
findUnique: vi.fn(async () => options.city ?? null),
aggregate: vi.fn(async () => ({
@@ -206,6 +210,41 @@ const createContext = (options: {
};
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 () => {
const fixture = createContext({
me: buildGeneral({
@@ -69,7 +69,31 @@ export const do징병 = (ai: GeneralAI) => {
}
const generalMeta = asRecord(ai.general.meta);
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
const recruitContext = {
general: ai.general,
nation,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: {
year: ai.world.currentYear,
month: ai.world.currentMonth,
startYear: ai.startYear,
},
};
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
// The cached AI stat follows the scenario/global classification cap, while
// che_징병 resolves the actual command capacity from the general's current
// stat and modules. NPC recruitment must request that same uncapped amount;
// otherwise a 300-leadership general can be stuck at a 100/140/255 cap.
const fullLeadership = recruitment.resolveFullLeadership(recruitContext);
trace('population-policy', {
population: city.population,
populationMax: city.populationMax,
@@ -175,26 +199,6 @@ export const do징병 = (ai: GeneralAI) => {
// whether to halve the requested crew. In particular, that command caps
// the charge at the actually refillable amount when the selected type is
// already equipped, then applies traits/items and legacy rounding.
const recruitContext = {
general: ai.general,
nation,
...(ai.worldRef
? {
worldView: {
listGenerals: () => ai.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => ai.worldRef!.listNations(),
},
}
: {}),
time: {
year: ai.world.currentYear,
month: ai.world.currentMonth,
startYear: ai.startYear,
},
};
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
const goldCost = recruitment.getCost(recruitContext, crewTypeId, crewAmount, picked).gold;
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
@@ -955,6 +955,57 @@ describe('legacy NPC AI final-decision parity', () => {
});
});
it.each([
[101, 100, 10_100],
[140, 100, 14_000],
[256, 255, 25_600],
[300, 140, 30_000],
[300, 255, 30_000],
])(
'requests the command-resolved full crew above the cached AI cap (leadership=%i, cached=%i)',
(leadership, cachedLeadership, amount) => {
const ai = makeAi({
dipState: 2,
city: { population: 100_000, populationMax: 100_000 },
general: {
stats: { leadership, strength: 70, intelligence: 70 },
gold: 100_000,
rice: 100_000,
meta: { killturn: 100, fullLeadership: cachedLeadership, rank_killcrew: 0, rank_deathcrew: 1 },
},
rng: makeRng([], [0, 0]),
});
expect(do징병(ai)).toMatchObject({
action: 'che_징병',
args: { crewType: 1, amount },
});
}
);
it('uses recruitment stat modules when resolving uncapped NPC crew capacity', () => {
const ai = makeAi({
dipState: 2,
city: { population: 100_000, populationMax: 100_000 },
general: {
stats: { leadership: 240, strength: 70, intelligence: 70 },
gold: 100_000,
rice: 100_000,
meta: { killturn: 100, fullLeadership: 100, rank_killcrew: 0, rank_deathcrew: 1 },
},
generalActionModules: singleActionModuleStack({
eventHandlers: {},
onCalcStat: (_context, statName, value) => (statName === 'leadership' ? value * 1.25 : value),
}),
rng: makeRng([], [0, 0]),
});
expect(do징병(ai)).toMatchObject({
action: 'che_징병',
args: { crewType: 1, amount: 30_000 },
});
});
it('uses the refillable same-type crew amount for the legacy gold-cost halving threshold', () => {
const ai = makeAi({
dipState: 2,
@@ -2291,6 +2291,7 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation
test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => {
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
const editor = page.locator('[data-command-scope="general"]');
@@ -2318,6 +2319,41 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
const recentMenu = editor.locator('details').filter({ has: page.getByText('최근 실행', { exact: true }) });
await recentMenu.locator('summary').click();
const recentBriefButton = recentMenu.getByRole('button', { name: '【허창】에 화계실행', exact: true });
await expect(recentBriefButton).toBeVisible();
await recentBriefButton.hover();
await page.screenshot({
path: test.info().outputPath('advanced-recent-command-brief-desktop-1200.png'),
fullPage: true,
});
await recentMenu.locator('summary').click();
await page.setViewportSize({ width: 500, height: 900 });
await recentMenu.locator('summary').click();
await expect(recentBriefButton).toBeVisible();
await recentBriefButton.focus();
await expect(recentBriefButton).toBeFocused();
const mobileRecentGeometry = await editor.evaluate((element) => {
const menu = element.querySelector<HTMLElement>('details[open] .menu-items');
const recentButton = menu?.querySelector<HTMLElement>('button');
if (!menu || !recentButton) throw new Error('advanced recent command menu is missing');
return {
horizontalOverflow: element.scrollWidth - element.clientWidth,
menuRight: menu.getBoundingClientRect().right,
buttonRight: recentButton.getBoundingClientRect().right,
};
});
expect(mobileRecentGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(mobileRecentGeometry.menuRight).toBeLessThanOrEqual(500);
expect(mobileRecentGeometry.buttonRight).toBeLessThanOrEqual(500);
await page.screenshot({
path: test.info().outputPath('advanced-recent-command-brief-mobile-500.png'),
fullPage: true,
});
await recentMenu.locator('summary').click();
await page.setViewportSize({ width: 1200, height: 900 });
await drag(0, 2);
await editor.locator('details.selected-menu > summary').click();
await editor.getByRole('button', { name: '복사하기', exact: true }).click();
+12
View File
@@ -112,6 +112,15 @@ const myGeneral = (state: FixtureState) => ({
statUpgradeLimit: 20,
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 },
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');
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('전투 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('#container')).not.toContainText('che_');
await expect(page.locator('.title-row')).toContainText('내 정 보');
@@ -142,11 +142,12 @@ const isRecruitmentCommand = computed(
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
);
const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value);
const rowLabel = (row: ReservedCommandRow): string =>
formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) ||
row.label ||
labelMap.value.get(row.action) ||
row.action;
const commandBrief = (entry: { action: string; args: unknown; label?: string }): string =>
formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) ||
entry.label ||
labelMap.value.get(entry.action) ||
entry.action;
const rowLabel = (row: ReservedCommandRow): string => commandBrief(row);
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
const pattern = () => extractPattern(props.rows, selectedIndices());
const touchMenus = () => (menuRevision.value += 1);
@@ -467,7 +468,7 @@ const clickOutsideMenu = (event: Event) => {
clickOutsideMenu($event);
"
>
{{ entry.label ?? labelMap.get(entry.action) ?? entry.action }}
{{ commandBrief(entry) }}
</button>
<span v-if="!storage?.recent.size" class="empty-menu">비어 있음</span>
</div>
+19 -3
View File
@@ -136,6 +136,9 @@ const statusLine = computed(() =>
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
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 environment = asRecord(world.value?.config.environment);
return isDefenceTrainPenaltyWaivedByScenarioEffect(
@@ -437,9 +440,22 @@ onMounted(() => {
}})</strong
>
</div>
<div>전투 0 · 계략 0 · 사관 7</div>
<div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div>
<div>
전투 {{ numberText(data.general.records.battles) }} · 계략
{{ 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>
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
{{ data.general.crewTypeName ?? '-' }} · 내정특기
+18 -8
View File
@@ -1,11 +1,20 @@
# Caddy prefix 계약
# Core2026 환경별 Caddy prefix 계약
## 환경과 ingress
| 환경 | 공개 주소 | 연결 계약 |
| ---- | --------- | --------- |
| 공개 | `dev-sam2026.hided.net` | 실제 외부 Core2026 서비스입니다. 로컬 Docker `14999`의 주소가 아닙니다. |
| E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → `172.30.1.54:14999` HTTP → Docker Caddy입니다. |
| 환경 | 공개 주소·prefix | 접속·연결 계약 |
| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| 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입니다. |
| 공개 개발 | `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`로 전달하고 원래
`Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는
@@ -26,7 +35,7 @@ HTTP_PORT=14999
`https://dev-sam-e2e.hided.net/gateway/oauth/callback`을 파생합니다. 도메인을
바꾼 뒤에는 Caddy뿐 아니라 runtime도 재생성하여 process 환경을 갱신합니다.
## 활성 경로
## 로컬 E2E 활성 경로
| 서비스 | 공개 prefix | frontend | API |
| ------- | ----------- | -------: | ------: |
@@ -36,8 +45,9 @@ HTTP_PORT=14999
표의 port는 Docker 내부 Caddy가 연결하는 frontend/API listener입니다. 외부
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 배포를 가정하지
않고 frontend base, tRPC, SSE, upload와 direct navigation에 같은 prefix를
@@ -55,6 +55,18 @@ describe('Ref event domestic traits', () => {
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 () => {
const [eventGyeongo] = await loadEventDomesticTraitModules(['che_event_견고']);
const canonical = await new WarTraitLoader().load('che_견고');
+57
View File
@@ -224,6 +224,63 @@ describe('war triggers', () => {
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', () => {
const general = buildGeneral(80);
general.experience = 90;