From d8e94c428c349e7630ebc26b576616da83f7fddb Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 03:59:16 +0000 Subject: [PATCH 1/6] Add sabotage value boundary parity --- .../general-command-differential-testing.md | 16 +++- .../turn-state-differential-testing.md | 5 + .../src/actions/turn/general/che_탈취.ts | 8 +- .../src/actions/turn/general/che_파괴.ts | 1 - ...rnCommandGeneralMatrix.integration.test.ts | 95 +++++++++++++++++++ 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index 70a48e9..1fcce3f 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -9,7 +9,7 @@ 현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개, 실행 중 확률 실패 9개, full constraint fallback 7개와 모략 확률 clamp -8개가 구현됐다. +8개, 모략 결과값 경계 5개가 구현됐다. 실패 9개는 내정 critical `주민선정/정착장려/상업투자/기술연구/물자조달`과 모략 `화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패 @@ -17,9 +17,11 @@ 금·쌀 부족과 민심 상한을 대표하며 휴식 fallback과 RNG/state delta를 비교한다. clamp 8개는 `화계/선동/파괴/탈취` 각각의 계산 확률 0과 0.5 경계에서 성공 판정 RNG의 무소비 또는 `nextBits(1)` 소비와 전체 -state delta를 비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 전체 core -PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 전체의 동적 -호환 상태를 `확인`으로 올리지 않는다. +state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치안·민심, +파괴 수비·성벽의 0 바닥과 탈취의 대상 국가 자원 상한·미보급 도시 최종 +저장 상태를 비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 +전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 +전체의 동적 호환 상태를 `확인`으로 올리지 않는다. ## 결정 요약 @@ -588,6 +590,12 @@ fixture가 명시한 필드만 비교하면 예상하지 못한 side effect를 `city.trust FLOAT` 재조회 정밀도 차이도 발견해 부분 patch와 6자리 유효숫자 저장 경계로 바로잡았다. +결과값 경계 fixture에서는 파괴의 전체 city spread도 선동과 같이 +불필요한 front 재계산을 일으키는 문제를 발견해 수비·성벽·state 부분 +patch로 좁혔다. 미보급 탈취는 레거시가 자원 감소 update 뒤 원본 +`destCity.agri/comm`을 다시 저장하므로 관찰 가능한 최종 자원은 변하지 +않는다. 버그로 보이지만 호환 계약으로 보존하고 `state=32`만 남긴다. + ## 55개 명령 coverage manifest `manifest.json`은 PHP command inventory와 TS registry의 합집합을 기준으로 diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 5b7b8b2..d1cc99e 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -187,6 +187,11 @@ fire attack, agitation, destruction and seizure. The zero boundary skips the success RNG primitive, while exactly 0.5 consumes `nextBits(1)`; the complete RNG trace and semantic delta match. These cases also fixed fire-attack city state persistence and agitation front/trust persistence differences. +Five sabotage value-boundary cases cover zero floors for fire-attack, +agitation and destruction city values, the supplied-nation resource cap for +seizure, and the legacy final state of unsupplied seizure. They also remove +destruction's unintended front recalculation and preserve the legacy +unsupplied-seizure overwrite that leaves city resources unchanged. This is not yet a claim that every command-specific constraint, clamp, alternative and persistence boundary has been dynamically compared. diff --git a/packages/logic/src/actions/turn/general/che_탈취.ts b/packages/logic/src/actions/turn/general/che_탈취.ts index 3fb35cd..7084481 100644 --- a/packages/logic/src/actions/turn/general/che_탈취.ts +++ b/packages/logic/src/actions/turn/general/che_탈취.ts @@ -140,14 +140,12 @@ export class ActionResolver< ) ); } else { - const commDmg = stolenGold / 12; - const agriDmg = stolenRice / 12; - + // 레거시는 미보급 도시 자원을 먼저 감소시키지만 같은 명령 끝의 + // 원본 destCity 전체 저장이 이를 덮어쓴다. 관찰 가능한 최종 + // 상태는 자원 변화 없이 state 32만 남는다. effects.push( createCityPatchEffect( { - commerce: Math.round(Math.max(0, destCity.commerce - commDmg)), - agriculture: Math.round(Math.max(0, destCity.agriculture - agriDmg)), state: 32, }, args.destCityId diff --git a/packages/logic/src/actions/turn/general/che_파괴.ts b/packages/logic/src/actions/turn/general/che_파괴.ts index 0af57fd..1ae1a35 100644 --- a/packages/logic/src/actions/turn/general/che_파괴.ts +++ b/packages/logic/src/actions/turn/general/che_파괴.ts @@ -106,7 +106,6 @@ export class ActionResolver< effects.push( createCityPatchEffect( { - ...destCity, defence: newDef, wall: newWall, state: 32, // Legacy sabotage state diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index b6fdf07..344bb73 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -554,6 +554,101 @@ integration('general sabotage probability clamp matrix', () => { ); }); +type SabotageValueBoundaryCase = { + name: string; + action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취'; + stat: 'leadership' | 'strength' | 'intelligence'; + fixturePatches: FixturePatches; +}; + +const sabotageValueBoundaryCases: SabotageValueBoundaryCase[] = [ + { + name: 'fire attack does not reduce agriculture or commerce below zero', + action: 'che_화계', + stat: 'intelligence', + fixturePatches: { cities: { 70: { agriculture: 1, commerce: 1 } } }, + }, + { + name: 'agitation does not reduce security or trust below zero', + action: 'che_선동', + stat: 'leadership', + fixturePatches: { cities: { 70: { security: 1, trust: 1 } } }, + }, + { + name: 'destruction does not reduce defence or wall below zero', + action: 'che_파괴', + stat: 'strength', + fixturePatches: { cities: { 70: { defence: 1, wall: 1 } } }, + }, + { + name: 'seizure does not take more than supplied nation resources', + action: 'che_탈취', + stat: 'strength', + fixturePatches: { nations: { 2: { gold: 1, rice: 1 } } }, + }, + { + name: 'seizure does not reduce unsupplied city resources below zero', + action: 'che_탈취', + stat: 'strength', + fixturePatches: { + cities: { 70: { agriculture: 1, commerce: 1, supplyState: 0 } }, + }, + }, +]; + +const hasSuccessfulSabotageLog = (logs: Array>): boolean => + logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('성공했습니다.')); + +integration('general sabotage value boundary matrix', () => { + it.each(sabotageValueBoundaryCases)( + '$name matches the legacy clamped state delta', + async ({ action, stat, fixturePatches }) => { + const request = buildRequest( + action, + { destCityID: 70 }, + { [stat]: 100 }, + { + ...fixturePatches, + generals: { + ...fixturePatches.generals, + 2: { ...fixturePatches.generals?.[2], [stat]: 10 }, + }, + cities: { + ...fixturePatches.cities, + 70: { + ...fixturePatches.cities?.[70], + security: 0, + securityMax: 2_000, + }, + }, + } + ); + request.setup!.world!.hiddenSeed = 'general-value-0'; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true); + expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true); + 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; From 2017da001b19e40e00890a3f8e9e475467655e08 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:04:01 +0000 Subject: [PATCH 2/6] Add legacy-compatible in-game information pages --- app/game-api/src/maps/mapLayout.ts | 32 +- .../router/nation/endpoints/getNationInfo.ts | 127 ++++ app/game-api/src/router/nation/index.ts | 3 +- app/game-api/src/router/world/index.ts | 211 +++++- app/game-api/test/inGameInfoRouter.test.ts | 185 +++++ app/game-frontend/e2e/inGameInfo.spec.ts | 258 +++++++ app/game-frontend/e2e/playwright.config.mjs | 2 +- app/game-frontend/src/router/index.ts | 21 + .../src/views/CurrentCityView.vue | 240 ++++++ .../src/views/GlobalInfoView.vue | 243 ++++++ app/game-frontend/src/views/MainView.vue | 3 + .../src/views/NationCitiesView.vue | 707 ++++-------------- .../src/views/NationInfoView.vue | 177 +++++ 13 files changed, 1637 insertions(+), 572 deletions(-) create mode 100644 app/game-api/src/router/nation/endpoints/getNationInfo.ts create mode 100644 app/game-api/test/inGameInfoRouter.test.ts create mode 100644 app/game-frontend/e2e/inGameInfo.spec.ts create mode 100644 app/game-frontend/src/views/CurrentCityView.vue create mode 100644 app/game-frontend/src/views/GlobalInfoView.vue create mode 100644 app/game-frontend/src/views/NationInfoView.vue diff --git a/app/game-api/src/maps/mapLayout.ts b/app/game-api/src/maps/mapLayout.ts index c33bdda..a867703 100644 --- a/app/game-api/src/maps/mapLayout.ts +++ b/app/game-api/src/maps/mapLayout.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { loadMapDefinitionByName } from './mapDefinition.js'; export interface MapLayoutCity { id: number; @@ -30,8 +31,7 @@ const LEGACY_CITY_CONST = path.resolve(process.cwd(), 'legacy/hwe/sammo/CityCons const layoutCache = new Map(); -const stripComments = (value: string): string => - value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); +const stripComments = (value: string): string => value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); const extractPhpArray = (source: string, marker: string): string | null => { const idx = source.indexOf(marker); @@ -196,11 +196,7 @@ const parseCityConstFile = async (filePath: string): Promise => const resolveScenarioFile = async (scenario: string): Promise => { const normalized = scenario.replace(/\.json$/i, ''); - const candidates = [ - `${normalized}.json`, - `scenario_${normalized}.json`, - 'default.json', - ]; + const candidates = [`${normalized}.json`, `scenario_${normalized}.json`, 'default.json']; for (const candidate of candidates) { const fullPath = path.join(LEGACY_SCENARIO_ROOT, candidate); @@ -272,15 +268,15 @@ const normalizeInitCity = ( typeof levelLabel === 'number' ? levelLabel : typeof levelLabel === 'string' - ? levelMap.nameToId[levelLabel] ?? Number(levelLabel) - : 0; + ? (levelMap.nameToId[levelLabel] ?? Number(levelLabel)) + : 0; const regionValue = typeof regionLabel === 'number' ? regionLabel : typeof regionLabel === 'string' - ? regionMap.nameToId[regionLabel] ?? Number(regionLabel) - : 0; + ? (regionMap.nameToId[regionLabel] ?? Number(regionLabel)) + : 0; const pathNames = Array.isArray(path) ? (path as string[]) : []; const pathIds = pathNames @@ -324,7 +320,19 @@ export const loadMapLayout = async (scenario: string): Promise => { const levelMap = buildLookupMap(levelMapRaw); const initCity = map.initCity ?? base.initCity ?? []; - const cityList = normalizeInitCity(initCity, levelMap, regionMap); + let cityList = normalizeInitCity(initCity, levelMap, regionMap); + if (cityList.length === 0) { + const resourceMap = await loadMapDefinitionByName(mapName); + cityList = resourceMap.cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + region: city.region, + x: city.position.x, + y: city.position.y, + path: [...city.connections], + })); + } const layout: MapLayout = { mapName, diff --git a/app/game-api/src/router/nation/endpoints/getNationInfo.ts b/app/game-api/src/router/nation/endpoints/getNationInfo.ts new file mode 100644 index 0000000..bc3aa93 --- /dev/null +++ b/app/game-api/src/router/nation/endpoints/getNationInfo.ts @@ -0,0 +1,127 @@ +import { TRPCError } from '@trpc/server'; + +import { asRecord } from '@sammo-ts/common'; +import { LogCategory, LogScope } from '@sammo-ts/infra'; +import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome } from '@sammo-ts/logic'; + +import { authedProcedure } from '../../../trpc.js'; +import { getMyGeneral } from '../../shared/general.js'; +import { + assertNationAccess, + buildNationIncomeContext, + resolveNationBill, + resolveNationRate, + resolveOfficerCity, + toIncomeCity, +} from '../shared.js'; + +export const getNationInfo = authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + + const [nation, cities, generals, history] = await Promise.all([ + ctx.db.nation.findUnique({ where: { id: me.nationId } }), + ctx.db.city.findMany({ where: { nationId: me.nationId }, orderBy: { id: 'asc' } }), + ctx.db.general.findMany({ where: { nationId: me.nationId } }), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: me.nationId, + }, + select: { id: true, year: true, month: true, text: true }, + orderBy: { id: 'asc' }, + }), + ]); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + + const officerCntByCity = new Map(); + for (const general of generals) { + const officerCity = resolveOfficerCity(asRecord(general.meta)); + if ( + general.officerLevel >= 2 && + general.officerLevel <= 4 && + officerCity > 0 && + general.cityId === officerCity + ) { + officerCntByCity.set(officerCity, (officerCntByCity.get(officerCity) ?? 0) + 1); + } + } + + const incomeContext = await buildNationIncomeContext(nation); + const incomeCities = cities.map(toIncomeCity); + const rate = resolveNationRate(nation); + const bill = resolveNationBill(asRecord(nation.meta)); + const goldCity = getGoldIncome( + incomeContext, + incomeCities, + officerCntByCity, + nation.capitalCityId ?? 0, + nation.level + ); + const goldWar = getWarGoldIncome(incomeContext, incomeCities); + const riceCity = getRiceIncome( + incomeContext, + incomeCities, + officerCntByCity, + nation.capitalCityId ?? 0, + nation.level + ); + const riceWall = getWallIncome( + incomeContext, + incomeCities, + officerCntByCity, + nation.capitalCityId ?? 0, + nation.level + ); + const outcome = getOutcome( + bill, + generals.filter((general) => general.npcState !== 5) + ); + const population = cities.reduce((sum, city) => sum + city.population, 0); + const populationMax = cities.reduce((sum, city) => sum + city.populationMax, 0); + const crewGenerals = generals.filter((general) => general.npcState !== 5); + const crew = crewGenerals.reduce((sum, general) => sum + general.crew, 0); + const crewMax = crewGenerals.reduce((sum, general) => sum + general.leadership * 100, 0); + const meta = asRecord(nation.meta); + + return { + nation: { + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + power: typeof meta.power === 'number' ? meta.power : 0, + gold: nation.gold, + rice: nation.rice, + tech: Math.floor(nation.tech), + rate, + bill, + capitalCityId: nation.capitalCityId ?? 0, + generalCount: generals.length, + }, + population: { current: population, max: populationMax }, + crew: { current: crew, max: crewMax }, + income: { + goldCity, + goldWar, + goldTotal: goldCity + goldWar, + riceCity, + riceWall, + riceTotal: riceCity + riceWall, + outcome, + }, + budget: { + gold: nation.gold + goldCity + goldWar - outcome, + rice: nation.rice + riceCity + riceWall - outcome, + }, + cities: cities.map((city) => ({ + id: city.id, + name: city.name, + capital: city.id === nation.capitalCityId, + })), + history, + }; +}); diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index c2c2d35..e810b96 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -6,6 +6,7 @@ import { getChiefCenter } from './endpoints/getChiefCenter.js'; import { getCityOverview } from './endpoints/getCityOverview.js'; import { getGeneralList } from './endpoints/getGeneralList.js'; import { getGeneralLog } from './endpoints/getGeneralLog.js'; +import { getNationInfo } from './endpoints/getNationInfo.js'; import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js'; import { getStratFinan } from './endpoints/getStratFinan.js'; import { kick } from './endpoints/kick.js'; @@ -18,6 +19,7 @@ import { setScoutMsg } from './endpoints/setScoutMsg.js'; import { setSecretLimit } from './endpoints/setSecretLimit.js'; export const nationRouter = router({ + getNationInfo, getGeneralList, getCityOverview, getPersonnelInfo, @@ -36,4 +38,3 @@ export const nationRouter = router({ kick, appoint, }); - diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 7d4540d..5719247 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -1,15 +1,37 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { - type WorldStateRow, - zWorldStateConfig, - zWorldStateMeta, -} from '../../context.js'; +import { type WorldStateRow, zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { procedure, router } from '../../trpc.js'; +import { authedProcedure } from '../../trpc.js'; +import { asRecord, isRecord } from '@sammo-ts/common'; import { loadWorldMap } from '../../maps/worldMap.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; -import { getOwnedGeneral } from '../shared/general.js'; +import { getMyGeneral, getOwnedGeneral } from '../shared/general.js'; + +const isWorldAdmin = (roles: readonly string[]): boolean => + roles.some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser'); + +const numberRecord = (value: unknown): Record => { + if (!isRecord(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .map(([key, item]) => [Number(key), typeof item === 'number' ? item : Number.NaN] as const) + .filter(([key, item]) => Number.isFinite(key) && Number.isFinite(item)) + ); +}; + +const officerCity = (meta: unknown): number => { + const value = asRecord(meta); + const raw = value.officerCity ?? value.officer_city; + return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; +}; + +const defenceTrain = (meta: unknown): number => { + const value = asRecord(meta); + const raw = value.defenceTrain ?? value.defence_train; + return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; +}; const toWorldStateSnapshot = (row: WorldStateRow) => ({ scenarioCode: row.scenarioCode, @@ -22,6 +44,183 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({ }); export const worldRouter = router({ + getGlobalInfo: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + const [nations, cities, diplomacy, map] = await Promise.all([ + ctx.db.nation.findMany({ where: { level: { gt: 0 } } }), + ctx.db.city.findMany({ orderBy: { id: 'asc' } }), + ctx.db.diplomacy.findMany({ where: { isDead: false, isShowing: true } }), + loadWorldMap(ctx, { generalId: me.id, neutralView: false, showMe: true }), + ]); + if (!map) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); + } + const nationRows = nations + .map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId ?? 0, + level: nation.level, + power: typeof asRecord(nation.meta).power === 'number' ? Number(asRecord(nation.meta).power) : 0, + cities: cities.filter((city) => city.nationId === nation.id).map((city) => city.name), + })) + .sort((left, right) => right.power - left.power || left.id - right.id); + const matrix: Record> = {}; + for (const nation of nationRows) { + matrix[nation.id] = {}; + for (const other of nationRows) matrix[nation.id]![other.id] = 2; + } + for (const relation of diplomacy) { + if (!matrix[relation.srcNationId]) continue; + const related = relation.srcNationId === me.nationId || relation.destNationId === me.nationId; + matrix[relation.srcNationId]![relation.destNationId] = related + ? relation.stateCode + : [3, 4, 5, 6, 7].includes(relation.stateCode) + ? 2 + : relation.stateCode; + } + const conflict = cities.flatMap((city) => { + const raw = numberRecord(city.conflict); + const entries = Object.entries(raw); + if (entries.length < 2) return []; + const sum = entries.reduce((total, [, value]) => total + value, 0); + if (sum <= 0) return []; + return [ + { + cityId: city.id, + cityName: city.name, + nations: Object.fromEntries( + entries.map(([id, value]) => [id, Math.round((value * 1000) / sum) / 10]) + ), + }, + ]; + }); + return { myNationId: me.nationId, nations: nationRows, diplomacy: matrix, conflict, map }; + }), + getCurrentCity: authedProcedure + .input(z.object({ cityId: z.number().int().positive().optional() }).optional()) + .query(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + const admin = isWorldAdmin(ctx.auth?.user.roles ?? []); + const [cities, nation, nationGenerals, nations, world, layout] = await Promise.all([ + ctx.db.city.findMany({ orderBy: { id: 'asc' } }), + me.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: me.nationId } }) : null, + me.nationId > 0 + ? ctx.db.general.findMany({ where: { nationId: me.nationId }, select: { cityId: true } }) + : [], + ctx.db.nation.findMany(), + ctx.db.worldState.findFirst(), + loadMapLayout(ctx.profile.scenario), + ]); + const cityById = new Map(cities.map((city) => [city.id, city])); + const requested = input?.cityId && cityById.has(input.cityId) ? input.cityId : me.cityId; + const selected = cityById.get(requested); + if (!selected) throw new TRPCError({ code: 'NOT_FOUND', message: 'City not found' }); + const spy = numberRecord(asRecord(nation?.meta).spyList ?? asRecord(nation?.meta).spy); + const selectable = new Set([me.cityId]); + if (me.officerLevel > 0 && me.nationId > 0) { + cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id)); + nationGenerals.forEach((general) => selectable.add(general.cityId)); + Object.keys(spy).forEach((id) => selectable.add(Number(id))); + } + if (admin) cities.forEach((city) => selectable.add(city.id)); + const full = admin || selectable.has(selected.id); + const ownCities = new Set( + cities.filter((city) => city.nationId === me.nationId && me.nationId > 0).map((city) => city.id) + ); + const layoutCity = layout.cityList.find((city) => city.id === selected.id); + const detailed = full || Boolean(layoutCity?.path.some((id) => ownCities.has(id))); + const generals = detailed + ? await ctx.db.general.findMany({ where: { cityId: selected.id }, orderBy: { turnTime: 'asc' } }) + : []; + const generalIds = generals + .filter((general) => general.nationId === me.nationId && general.npcState <= 1) + .map((general) => general.id); + const turns = generalIds.length + ? await ctx.db.generalTurn.findMany({ + where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }) + : []; + const turnMap = new Map(); + for (const turn of turns) { + const list = turnMap.get(turn.generalId) ?? []; + list[turn.turnIdx] = turn.actionCode; + turnMap.set(turn.generalId, list); + } + const nationMap = new Map(nations.map((item) => [item.id, item])); + const officers = await ctx.db.general.findMany({ + where: { officerLevel: { in: [2, 3, 4] } }, + select: { name: true, officerLevel: true, meta: true }, + }); + const selectedOfficers = Object.fromEntries( + officers + .filter((item) => officerCity(item.meta) === selected.id) + .map((item) => [item.officerLevel, item.name]) + ); + const redact = (value: T): T | null => (full ? value : null); + const mappedGenerals = generals.map((general) => { + const ours = admin || (me.nationId > 0 && general.nationId === me.nationId); + return { + id: general.id, + name: general.name, + npcState: general.npcState, + picture: general.picture, + imageServer: general.imageServer, + nationId: general.nationId, + nationName: nationMap.get(general.nationId)?.name ?? '재야', + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + injury: general.injury, + officerLevel: general.officerLevel, + defenceTrain: ours ? defenceTrain(general.meta) : null, + crewTypeId: ours ? general.crewTypeId : null, + crew: ours || full ? general.crew : null, + train: ours ? general.train : null, + atmos: ours ? general.atmos : null, + turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [], + }; + }); + return { + me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin }, + options: [...selectable] + .map((id) => cityById.get(id)) + .filter((city): city is NonNullable => Boolean(city)) + .map((city) => ({ id: city.id, name: city.name, nationId: city.nationId })), + visibility: { full, detailed }, + city: { + id: selected.id, + name: selected.name, + nationId: selected.nationId, + level: selected.level, + region: selected.region, + population: redact(selected.population), + populationMax: selected.populationMax, + agriculture: redact(selected.agriculture), + agricultureMax: selected.agricultureMax, + commerce: redact(selected.commerce), + commerceMax: selected.commerceMax, + security: redact(selected.security), + securityMax: selected.securityMax, + trust: redact(selected.trust), + trade: selected.trade, + defence: full || selected.nationId === 0 ? selected.defence : null, + defenceMax: selected.defenceMax, + wall: full || selected.nationId === 0 ? selected.wall : null, + wallMax: selected.wallMax, + officers: { + 4: selectedOfficers[4] ?? '-', + 3: selectedOfficers[3] ?? '-', + 2: selectedOfficers[2] ?? '-', + }, + }, + generals: mappedGenerals, + lastExecute: + typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '', + }; + }), getState: procedure.query(async ({ ctx }) => { const state = await ctx.db.worldState.findFirst(); return state ? toWorldStateSnapshot(state) : null; diff --git a/app/game-api/test/inGameInfoRouter.test.ts b/app/game-api/test/inGameInfoRouter.test.ts new file mode 100644 index 0000000..8a788cd --- /dev/null +++ b/app/game-api/test/inGameInfoRouter.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const now = new Date('2026-01-01T00:00:00Z'); +const general = (overrides: Partial = {}): GeneralRow => ({ + id: 1, + 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: 60, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 1000, + rice: 1000, + crew: 500, + crewTypeId: 1, + train: 90, + atmos: 90, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { defence_train: 80 }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); +const auth = (roles: string[] = []): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86400000).toISOString(), + sessionId: 'session', + user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles }, + sanctions: {}, +}); +const city = (id: number, nationId: number) => ({ + id, + name: `도시${id}`, + level: 6, + nationId, + supplyState: 1, + frontState: 0, + population: 1000, + populationMax: 2000, + agriculture: 10, + agricultureMax: 20, + commerce: 11, + commerceMax: 21, + security: 12, + securityMax: 22, + trust: 50, + trade: 100, + defence: 13, + defenceMax: 23, + wall: 14, + wallMax: 24, + region: 2, + conflict: {}, + meta: {}, +}); + +const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record } = {}) => { + const me = options.me ?? general(); + const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)]; + const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 }); + const db = { + general: { + findFirst: vi.fn(async () => me), + findMany: vi.fn(async (args: { where?: Record; select?: Record }) => { + if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }]; + if (args.where?.cityId === 2) return [foreign]; + if (args.where?.cityId === 3) return [foreign]; + if (args.where?.officerLevel) return []; + return []; + }), + }, + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '아국', + color: '#008000', + level: 1, + capitalCityId: 1, + meta: options.nationMeta ?? {}, + })), + findMany: vi.fn(async () => [ + { id: 1, name: '아국', color: '#008000', level: 1, capitalCityId: 1, meta: { power: 100 } }, + { id: 2, name: '적국', color: '#800000', level: 1, capitalCityId: 2, meta: { power: 90 } }, + ]), + }, + city: { findMany: vi.fn(async () => cities) }, + worldState: { findFirst: vi.fn(async () => ({ meta: { turntime: '2026-01-01' } })) }, + generalTurn: { findMany: vi.fn(async () => []) }, + diplomacy: { findMany: vi.fn(async () => []) }, + }; + const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; + const accessTokenStore = new RedisAccessTokenStore(redis, 'che:default'); + return { + db: db as unknown as DatabaseClient, + redis, + turnDaemon: {} as GameApiContext['turnDaemon'], + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: auth(options.roles), + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'secret', + } satisfies GameApiContext; +}; + +describe('in-game information permissions', () => { + it('does not expose nation-only pages to a wandering general', async () => { + const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) })); + await expect(caller.nation.getNationInfo()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + await expect(caller.nation.getCityOverview()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + }); + + it('lets a wandering general select only the current city', async () => { + const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) })); + const result = await caller.world.getCurrentCity({ cityId: 2 }); + expect(result.city.id).toBe(2); + expect(result.options.map((entry) => entry.id)).toEqual([1]); + expect(result.visibility.full).toBe(false); + expect(result.city.population).toBeNull(); + expect(result.generals).toEqual([]); + }); + + it('keeps adjacent foreign detail redacted and never reveals military fields', async () => { + const result = await appRouter + .createCaller(context({ me: general({ cityId: 80 }) })) + .world.getCurrentCity({ cityId: 2 }); + expect(result.visibility).toEqual({ full: false, detailed: true }); + expect(result.city.agriculture).toBeNull(); + expect(result.city.defence).toBeNull(); + expect(result.generals[0]).toMatchObject({ crew: null, train: null, atmos: null, crewTypeId: null }); + }); + + it('allows a spied city in full but still redacts foreign-general private details', async () => { + const result = await appRouter + .createCaller(context({ nationMeta: { spy: { 2: 2 } } })) + .world.getCurrentCity({ cityId: 2 }); + expect(result.visibility.full).toBe(true); + expect(result.city.population).toBe(1000); + expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null }); + }); + + it('allows administrative roles to inspect all city and general fields', async () => { + const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 }); + expect(result.options).toHaveLength(4); + expect(result.visibility.full).toBe(true); + expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 }); + }); +}); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts new file mode 100644 index 0000000..6b1de47 --- /dev/null +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -0,0 +1,258 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); +const city = { + id: 1, + name: '업', + level: 8, + region: 1, + population: 150000, + populationMax: 620500, + agriculture: 1000, + agricultureMax: 12500, + commerce: 1000, + commerceMax: 11300, + security: 1000, + securityMax: 10000, + trust: 80, + trade: 100, + defence: 5000, + defenceMax: 11700, + wall: 5000, + wallMax: 12200, + supplyState: 1, + frontState: 0, + incomes: { gold: 1000, rice: 900, wall: 800 }, + officers: { 2: null, 3: null, 4: { id: 1, name: '태수', npcState: 0, officerLevel: 4, cityId: 1, cityName: '업' } }, +}; +const map = { + result: true, + version: 0, + startYear: 180, + year: 200, + month: 1, + cityList: [[1, 8, 0, 1, 1, 1]], + nationList: [[1, '아국', '#008000', 1]], + spyList: {}, + shownByGeneralList: [], + myCity: 1, + myNation: 1, +}; +const layout = { + mapName: 'che', + cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 345, y: 130, path: [] }], + regionMap: { 1: '하북' }, + levelMap: { 8: '특' }, +}; + +const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_info'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/image/game/**', (route) => + route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) + ); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'nation.getNationInfo') + return response({ + nation: { + id: 1, + name: '아국', + color: '#008000', + level: 1, + power: 1234, + gold: 10000, + rice: 9000, + tech: 100, + rate: 20, + bill: 100, + capitalCityId: 1, + generalCount: 2, + }, + population: { current: 150000, max: 620500 }, + crew: { current: 500, max: 7000 }, + income: { + goldCity: 1000, + goldWar: 200, + goldTotal: 1200, + riceCity: 900, + riceWall: 800, + riceTotal: 1700, + outcome: 300, + }, + budget: { gold: 10900, rice: 10400 }, + cities: [{ id: 1, name: '업', capital: true }], + history: [{ id: 1, year: 200, month: 1, text: '건국했습니다.' }], + }); + if (operation === 'nation.getCityOverview') + return response({ + me: { id: 1, officerLevel: 1 }, + nation: { + id: 1, + name: '아국', + color: '#008000', + level: 1, + typeCode: 'che_중립', + capitalCityId: 1, + rate: 20, + }, + chiefStatMin: 65, + cities: [city], + generals: [ + { + id: 1, + name: '장수', + npcState: 0, + officerLevel: 1, + cityId: 1, + officerCity: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + }, + ], + }); + if (operation === 'world.getGlobalInfo') + return response({ + myNationId: 1, + nations: [ + { + id: 1, + name: '아국', + color: '#008000', + capitalCityId: 1, + level: 1, + power: 1234, + cities: ['업'], + }, + { + id: 2, + name: '적국', + color: '#800000', + capitalCityId: 2, + level: 1, + power: 1000, + cities: ['허창'], + }, + ], + diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } }, + conflict: [], + map, + }); + if (operation === 'world.getMapLayout') return response(layout); + if (operation === 'world.getCurrentCity') + return response({ + me: { + id: 1, + nationId: mode === 'wanderer' ? 0 : 1, + officerLevel: mode === 'wanderer' ? 0 : 1, + admin: mode === 'admin', + }, + options: [{ id: 1, name: '업', nationId: 1 }], + visibility: { full: mode !== 'wanderer', detailed: mode !== 'wanderer' }, + city: { + id: 1, + name: '업', + nationId: 1, + level: 8, + region: 1, + population: mode === 'wanderer' ? null : 150000, + populationMax: 620500, + agriculture: mode === 'wanderer' ? null : 1000, + agricultureMax: 12500, + commerce: mode === 'wanderer' ? null : 1000, + commerceMax: 11300, + security: mode === 'wanderer' ? null : 1000, + securityMax: 10000, + trust: mode === 'wanderer' ? null : 80, + trade: 100, + defence: mode === 'wanderer' ? null : 5000, + defenceMax: 11700, + wall: mode === 'wanderer' ? null : 5000, + wallMax: 12200, + officers: { 2: '-', 3: '-', 4: '태수' }, + }, + generals: + mode === 'wanderer' + ? [] + : [ + { + id: 1, + name: '장수', + npcState: 0, + picture: null, + imageServer: 0, + nationId: 1, + nationName: '아국', + leadership: 70, + strength: 60, + intelligence: 50, + injury: 0, + officerLevel: 1, + defenceTrain: 80, + crewTypeId: 1, + crew: 500, + train: 90, + atmos: 90, + turns: ['징병'], + }, + ], + lastExecute: '2026-07-26', + }); + return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; +const go = async (page: Page, path: string) => { + const ready = page.waitForResponse((r) => r.url().includes('/trpc/lobby.info')); + await page.goto(path); + await ready; +}; + +test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + for (const [path, selector] of [ + ['nation/info', '.legacy-info-page'], + ['nation/cities', '.nation-cities-page'], + ['global-info', '.global-page'], + ['current-city', '.city-page'], + ] as const) { + await go(page, path); + await expect(page.locator(selector)).toBeVisible(); + const box = await page.locator(selector).evaluate((el) => { + const r = el.getBoundingClientRect(); + const s = getComputedStyle(el); + return { x: r.x, width: r.width, fontSize: s.fontSize, fontFamily: s.fontFamily }; + }); + expect(box.width).toBe(1000); + expect(box.x).toBe(100); + expect(box.fontSize).toBe('14px'); + expect(box.fontFamily).toContain('Pretendard'); + expect( + await page + .locator('table') + .first() + .evaluate((el) => getComputedStyle(el).borderCollapse) + ).toBe('collapse'); + } +}); + +test('current-city hides values and general rows for a wandering user', async ({ page }) => { + await install(page, 'wanderer'); + await go(page, 'current-city'); + await expect(page.locator('.stats')).toContainText('?/620,500'); + await expect(page.locator('.generals')).toHaveCount(0); +}); + +test('current-city exposes own general details to a member and admin fixture', async ({ page }) => { + await install(page, 'admin'); + await go(page, 'current-city'); + await expect(page.locator('.generals')).toContainText('장수'); + await expect(page.locator('.generals')).toContainText('90'); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index eddbfbd..4fd93c5 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../. export default defineConfig({ testDir: '.', - testMatch: 'troop.spec.ts', + testMatch: ['troop.spec.ts', 'inGameInfo.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index d82fe60..ee29b09 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -6,6 +6,9 @@ import JoinView from '../views/JoinView.vue'; import InheritView from '../views/InheritView.vue'; import AuctionView from '../views/AuctionView.vue'; import NationCitiesView from '../views/NationCitiesView.vue'; +import NationInfoView from '../views/NationInfoView.vue'; +import GlobalInfoView from '../views/GlobalInfoView.vue'; +import CurrentCityView from '../views/CurrentCityView.vue'; import NationGeneralsView from '../views/NationGeneralsView.vue'; import NationPersonnelView from '../views/NationPersonnelView.vue'; import NationStratFinanView from '../views/NationStratFinanView.vue'; @@ -83,6 +86,12 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/nation/info', + name: 'nation-info', + component: NationInfoView, + meta: { requiresAuth: true, requiresGeneral: true }, + }, { path: '/nation/cities', name: 'nation-cities', @@ -92,6 +101,18 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/global-info', + name: 'global-info', + component: GlobalInfoView, + meta: { requiresAuth: true, requiresGeneral: true }, + }, + { + path: '/current-city', + name: 'current-city', + component: CurrentCityView, + meta: { requiresAuth: true, requiresGeneral: true }, + }, { path: '/nation/affairs', name: 'nation-affairs', diff --git a/app/game-frontend/src/views/CurrentCityView.vue b/app/game-frontend/src/views/CurrentCityView.vue new file mode 100644 index 0000000..a3d068b --- /dev/null +++ b/app/game-frontend/src/views/CurrentCityView.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/app/game-frontend/src/views/GlobalInfoView.vue b/app/game-frontend/src/views/GlobalInfoView.vue new file mode 100644 index 0000000..6abd86c --- /dev/null +++ b/app/game-frontend/src/views/GlobalInfoView.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index a1284f0..5c4bf7b 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -91,7 +91,10 @@ watch(

{{ statusLine }}

+ 세력 정보 세력 도시 + 중원 정보 + 현재 도시 세력 장수 인사부 부대 편성 diff --git a/app/game-frontend/src/views/NationCitiesView.vue b/app/game-frontend/src/views/NationCitiesView.vue index bf0d509..ff781cb 100644 --- a/app/game-frontend/src/views/NationCitiesView.vue +++ b/app/game-frontend/src/views/NationCitiesView.vue @@ -1,582 +1,185 @@ diff --git a/app/game-frontend/src/views/NationInfoView.vue b/app/game-frontend/src/views/NationInfoView.vue new file mode 100644 index 0000000..aab5fa5 --- /dev/null +++ b/app/game-frontend/src/views/NationInfoView.vue @@ -0,0 +1,177 @@ + + + + + From 63c80fb49ed2082e89698db3a17bc6faf16c5199 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:06:39 +0000 Subject: [PATCH 3/6] Stabilize in-game information E2E navigation --- app/game-frontend/e2e/inGameInfo.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 6b1de47..98e9421 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -209,9 +209,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb }); }; const go = async (page: Page, path: string) => { - const ready = page.waitForResponse((r) => r.url().includes('/trpc/lobby.info')); await page.goto(path); - await ready; }; test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => { From ac711e66f59a7c4a68c7c6d3c5542b08b98189c8 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:07:42 +0000 Subject: [PATCH 4/6] Add sabotage injury boundary parity --- .../general-command-differential-testing.md | 15 ++-- .../turn-state-differential-testing.md | 5 ++ .../src/actions/turn/general/che_선동.ts | 4 ++ .../src/actions/turn/general/che_파괴.ts | 4 ++ .../src/actions/turn/general/che_화계.ts | 8 +-- ...rnCommandGeneralMatrix.integration.test.ts | 68 +++++++++++++++++++ 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index 1fcce3f..1d5ba6b 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -9,7 +9,7 @@ 현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개, 실행 중 확률 실패 9개, full constraint fallback 7개와 모략 확률 clamp -8개, 모략 결과값 경계 5개가 구현됐다. +8개, 모략 결과값 경계 5개, 부상 경계 3개가 구현됐다. 실패 9개는 내정 critical `주민선정/정착장려/상업투자/기술연구/물자조달`과 모략 `화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패 @@ -19,9 +19,11 @@ 0.5 경계에서 성공 판정 RNG의 무소비 또는 `nextBits(1)` 소비와 전체 state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치안·민심, 파괴 수비·성벽의 0 바닥과 탈취의 대상 국가 자원 상한·미보급 도시 최종 -저장 상태를 비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 -전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 -전체의 동적 호환 상태를 `확인`으로 올리지 않는다. +저장 상태를 비교한다. 부상 3개는 화계·선동·파괴의 대상 장수별 판정, +부상도 80 상한, 병력·훈련·사기 0.98 정수 저장과 대상 장수 로그를 +비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 전체 core +PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 전체의 동적 +호환 상태를 `확인`으로 올리지 않는다. ## 결정 요약 @@ -596,6 +598,11 @@ patch로 좁혔다. 미보급 탈취는 레거시가 자원 감소 update 뒤 `destCity.agri/comm`을 다시 저장하므로 관찰 가능한 최종 자원은 변하지 않는다. 버그로 보이지만 호환 계약으로 보존하고 `state=32`만 남긴다. +부상 경계 fixture에서는 화계의 `계략로 인해` 조사를 레거시 +`계략으로 인해`로 수정하고, 선동·파괴에 누락된 대상 장수 부상 로그를 +추가했다. 병력·훈련·사기의 0.98 감소는 ref 정수 DB 저장처럼 내림이 아닌 +반올림을 적용한다. + ## 55개 명령 coverage manifest `manifest.json`은 PHP command inventory와 TS registry의 합집합을 기준으로 diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index d1cc99e..3c44ac4 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -192,6 +192,11 @@ agitation and destruction city values, the supplied-nation resource cap for seizure, and the legacy final state of unsupplied seizure. They also remove destruction's unintended front recalculation and preserve the legacy unsupplied-seizure overwrite that leaves city resources unchanged. +Three sabotage injury-boundary cases cover per-defender rolls, the injury cap +of 80, integer persistence after multiplying crew/train/atmosphere by 0.98, +and the target-general injury log for fire attack, agitation and destruction. +They restore the legacy Korean particle in the log, add the two missing logs, +and round rather than floor the integer fields. This is not yet a claim that every command-specific constraint, clamp, alternative and persistence boundary has been dynamically compared. diff --git a/packages/logic/src/actions/turn/general/che_선동.ts b/packages/logic/src/actions/turn/general/che_선동.ts index 4334367..77cb928 100644 --- a/packages/logic/src/actions/turn/general/che_선동.ts +++ b/packages/logic/src/actions/turn/general/che_선동.ts @@ -136,6 +136,10 @@ export class ActionResolver< consumeSuccessfulStrategyItem(this.pipeline, context); for (const injured of result.injuredGenerals) { effects.push(createGeneralPatchEffect(injured.patch, injured.id)); + ctx.addLog('계략으로 인해 부상을 당했습니다.', { + generalId: injured.id, + format: LogFormat.MONTH, + }); } return { effects }; diff --git a/packages/logic/src/actions/turn/general/che_파괴.ts b/packages/logic/src/actions/turn/general/che_파괴.ts index 1ae1a35..b208b70 100644 --- a/packages/logic/src/actions/turn/general/che_파괴.ts +++ b/packages/logic/src/actions/turn/general/che_파괴.ts @@ -117,6 +117,10 @@ export class ActionResolver< consumeSuccessfulStrategyItem(this.pipeline, context); for (const injured of result.injuredGenerals) { effects.push(createGeneralPatchEffect(injured.patch, injured.id)); + ctx.addLog('계략으로 인해 부상을 당했습니다.', { + generalId: injured.id, + format: LogFormat.MONTH, + }); } return { effects }; diff --git a/packages/logic/src/actions/turn/general/che_화계.ts b/packages/logic/src/actions/turn/general/che_화계.ts index 4df3066..60aa2ba 100644 --- a/packages/logic/src/actions/turn/general/che_화계.ts +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -212,9 +212,9 @@ export class CommandResolver${ACTION_KEY}로 인해 부상을 당했습니다.`, { + context.addLog('계략으로 인해 부상을 당했습니다.', { generalId: injured.id, format: LogFormat.MONTH, }); diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 344bb73..5994c8b 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -649,6 +649,74 @@ integration('general sabotage value boundary matrix', () => { ); }); +type SabotageInjuryBoundaryCase = { + action: 'che_화계' | 'che_선동' | 'che_파괴'; + stat: 'leadership' | 'strength' | 'intelligence'; + hiddenSeed: string; +}; + +const sabotageInjuryBoundaryCases: SabotageInjuryBoundaryCase[] = [ + { action: 'che_화계', stat: 'intelligence', hiddenSeed: 'general-injury-4' }, + { action: 'che_선동', stat: 'leadership', hiddenSeed: 'general-injury-13' }, + { action: 'che_파괴', stat: 'strength', hiddenSeed: 'general-injury-4' }, +]; + +const injuryLogTexts = (logs: Array>): string[] => + logs + .map((entry) => entry.text) + .filter((text): text is string => typeof text === 'string' && text.includes('부상을 당했습니다.')); + +const legacyInjuryLogBody = (text: string): string => text.replace(/^●<\/>\d+월:/, ''); + +integration('general sabotage injury boundary matrix', () => { + it.each(sabotageInjuryBoundaryCases)( + '$action matches legacy injury cap, integer persistence, and log', + async ({ action, stat, hiddenSeed }) => { + const request = buildRequest( + action, + { destCityID: 70 }, + { [stat]: 100 }, + { + generals: { + 2: { + [stat]: 10, + injury: 79, + crew: 101, + atmos: 51, + train: 51, + }, + }, + cities: { 70: { security: 0, securityMax: 2_000 } }, + } + ); + request.setup!.world!.hiddenSeed = hiddenSeed; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(injuryLogTexts(reference.after.logs)).toHaveLength(1); + expect(injuryLogTexts(core.after.logs)).toEqual( + injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody) + ); + 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; From 36c5dacb8327372099b5ad6cb8ee270d88074175 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:13:09 +0000 Subject: [PATCH 5/6] Add sabotage constraint parity --- .../general-command-differential-testing.md | 10 +++--- .../turn-state-differential-testing.md | 10 +++--- ...rnCommandGeneralMatrix.integration.test.ts | 34 +++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index 1d5ba6b..81eb564 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -8,14 +8,16 @@ - 범위: 명령 결과, RNG 소비, 로그, 예약 턴 lifecycle, DB 영속화 현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개, -실행 중 확률 실패 9개, full constraint fallback 7개와 모략 확률 clamp +실행 중 확률 실패 9개, full constraint fallback 12개와 모략 확률 clamp 8개, 모략 결과값 경계 5개, 부상 경계 3개가 구현됐다. 실패 9개는 내정 critical `주민선정/정착장려/상업투자/기술연구/물자조달`과 모략 `화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패 -로그 본문을 비교한다. 제약 7개는 무소속, 방랑국, 타국 도시, 보급 단절, -금·쌀 부족과 민심 상한을 대표하며 휴식 fallback과 RNG/state delta를 -비교한다. clamp 8개는 `화계/선동/파괴/탈취` 각각의 계산 확률 0과 +로그 본문을 비교한다. 공통 제약 7개는 무소속, 방랑국, 타국 도시, 보급 +단절, 금·쌀 부족과 민심 상한을 대표한다. 모략 제약 5개는 동일 도시, +중립 도시, 불가침국, 계략 비용 금·쌀 부족을 고정한다. 모두 휴식 +fallback과 RNG/state delta를 비교한다. clamp 8개는 +`화계/선동/파괴/탈취` 각각의 계산 확률 0과 0.5 경계에서 성공 판정 RNG의 무소비 또는 `nextBits(1)` 소비와 전체 state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치안·민심, 파괴 수비·성벽의 0 바닥과 탈취의 대상 국가 자원 상한·미보급 도시 최종 diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 3c44ac4..0ea9860 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -179,10 +179,12 @@ responses pass a separate reference trace and core resolver/API boundary. Nine general in-action failure cases now also pass the common differential: five domestic critical failures and four sabotage failures. They compare the full command RNG trace, semantic state delta and the exact action-log body. -Seven full-constraint cases cover neutral status, wandering nation, city -ownership, supply, gold, rice and trust-cap rejection. Both engines replace -the denied command with rest, consume the same RNG and produce the same -semantic delta. Eight sabotage clamp cases cover probability zero and 0.5 for +Seven common full-constraint cases cover neutral status, wandering nation, +city ownership, supply, gold, rice and trust-cap rejection. Five sabotage +constraint cases add the occupied-city target, neutral target, non-aggression +status and insufficient sabotage gold or rice. Both engines replace the denied +command with rest, consume the same RNG and produce the same semantic delta. +Eight sabotage clamp cases cover probability zero and 0.5 for fire attack, agitation, destruction and seizure. The zero boundary skips the success RNG primitive, while exactly 0.5 consumes `nextBits(1)`; the complete RNG trace and semantic delta match. These cases also fixed fire-attack city diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 5994c8b..387c47d 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -763,6 +763,40 @@ const constraintCases: GeneralConstraintCase[] = [ action: 'che_주민선정', fixturePatches: { cities: { 3: { trust: 100 } } }, }, + { + name: 'sabotage targets the occupied city', + action: 'che_화계', + args: { destCityID: 3 }, + }, + { + name: 'sabotage targets a neutral city', + action: 'che_화계', + args: { destCityID: 70 }, + fixturePatches: { cities: { 70: { nationId: 0 } } }, + }, + { + name: 'sabotage targets a non-aggression nation', + action: 'che_화계', + args: { destCityID: 70 }, + fixturePatches: { + diplomacy: { + '1:2': { state: 7 }, + '2:1': { state: 7 }, + }, + }, + }, + { + name: 'insufficient sabotage gold', + action: 'che_화계', + args: { destCityID: 70 }, + actorPatch: { gold: 0 }, + }, + { + name: 'insufficient sabotage rice', + action: 'che_화계', + args: { destCityID: 70 }, + actorPatch: { rice: 0 }, + }, ]; integration('general command full-constraint fallback matrix', () => { From c2a3b5b7976d085118744f375ad6ad247189b3ec Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:17:56 +0000 Subject: [PATCH 6/6] Implement legacy-compatible survey and unique rewards --- app/game-api/src/router/vote/index.ts | 55 +- app/game-api/test/voteRouter.test.ts | 292 ++++ .../src/turn/reservedTurnHandler.ts | 9 + app/game-engine/src/turn/turnDaemon.ts | 15 + .../src/turn/worldCommandHandler.ts | 16 + .../test/uniqueLotteryCommand.test.ts | 134 ++ app/game-engine/test/voteReward.test.ts | 76 + app/game-frontend/src/views/SurveyView.vue | 1341 +++++++---------- docs/architecture/todo.md | 5 +- docs/frontend-legacy-parity.md | 29 +- packages/logic/src/rewards/uniqueLottery.ts | 18 + packages/logic/test/uniqueLottery.test.ts | 17 + .../fixtures/canonical.ts | 58 + .../visual-parity.spec.ts | 135 ++ 14 files changed, 1408 insertions(+), 792 deletions(-) create mode 100644 app/game-api/test/voteRouter.test.ts diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 15d59dc..249b9f5 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -5,6 +5,7 @@ import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; import { ITEM_KEYS, + addOccupiedUniqueItemKeys, buildVoteUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, @@ -22,7 +23,12 @@ const hasAdminRole = (roles: string[], profileName: string): boolean => { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { return true; } - return roles.some((role) => role === 'admin.survey' || role === `admin.survey:${profileName}`); + return roles.some( + (role) => + role === 'admin.survey.open' || + role === 'admin.survey.open:*' || + role === `admin.survey.open:${profileName}` + ); }; const adminProcedure = authedProcedure.use(({ ctx, next }) => { @@ -66,9 +72,7 @@ const normalizeCode = (value: string | null | undefined): string | null => { }; const normalizeOptions = (options: string[]): string[] => - options - .map((option) => option.trim()) - .filter((option) => option.length > 0); + options.map((option) => option.trim()).filter((option) => option.length > 0); const readMetaNumber = (meta: Record, key: string, fallback: number): number => { const value = meta[key]; @@ -225,9 +229,7 @@ export const voteRouter = router({ const pollEnded = Boolean(row.closed_at) || (row.end_at ? row.end_at <= new Date() : false); const userId = ctx.auth?.user.id; - const general = userId - ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) - : null; + const general = userId ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) : null; const [comments, userCnt, myVoteRow] = await Promise.all([ ctx.db.$queryRaw(GamePrisma.sql` @@ -257,9 +259,8 @@ export const voteRouter = router({ const myVote = myVoteRow[0]?.selection ? parseSelection(myVoteRow[0].selection) : null; - const canReveal = row.reveal_mode === 'after_vote' - ? Boolean(myVote) || pollEnded - : pollEnded; + // 레거시는 투표 전에도 현재 집계를 보여준다. after_end만 명시적으로 숨긴다. + const canReveal = row.reveal_mode === 'after_end' ? pollEnded : true; const voteResults = canReveal ? await ctx.db.$queryRaw(GamePrisma.sql` @@ -355,6 +356,9 @@ export const voteRouter = router({ } const sortedSelection = [...selection].sort((a, b) => a - b); + if (new Set(sortedSelection).size !== sortedSelection.length) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 올바르지 않습니다.' }); + } const general = await getMyGeneral(ctx); const rows = await ctx.db.$queryRaw>(GamePrisma.sql` @@ -394,14 +398,24 @@ export const voteRouter = router({ const itemRegistry = await getItemRegistry(); const uniqueConfig = resolveUniqueConfig(constValues); - const generalRows = await ctx.db.general.findMany({ - select: { - horseCode: true, - weaponCode: true, - bookCode: true, - itemCode: true, - }, - }); + const [generalRows, reservedUniqueRows] = await Promise.all([ + ctx.db.general.findMany({ + select: { + horseCode: true, + weaponCode: true, + bookCode: true, + itemCode: true, + }, + }), + ctx.db.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }), + ]); const generalItems: GeneralItemSlots[] = generalRows.map((row) => ({ horse: normalizeCode(row.horseCode), weapon: normalizeCode(row.weaponCode), @@ -410,6 +424,11 @@ export const voteRouter = router({ })); const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry); + addOccupiedUniqueItemKeys( + occupiedUniqueCounts, + reservedUniqueRows.map((row) => row.targetCode), + itemRegistry + ); const userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } }); const rngSeed = buildVoteUniqueSeed( diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts new file mode 100644 index 0000000..69ba616 --- /dev/null +++ b/app/game-api/test/voteRouter.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { GamePrisma, 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 poll = { + id: 1, + title: '선호하는 병종', + body: '', + options: ['보병', '기병'], + multiple_options: 1, + reveal_mode: 'after_vote', + opener_general_id: 1, + opener_name: '관리자', + start_at: new Date('2026-07-26T00:00:00Z'), + end_at: null, + closed_at: null, +}; + +const buildGeneral = (overrides: Partial = {}): 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: 50, + strength: 50, + intel: 50, + 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: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-26T00:00:00Z'), + updatedAt: new Date('2026-07-26T00:00:00Z'), + ...overrides, +}); + +const buildAuth = (roles: string[] = [], 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 sqlText = (query: GamePrisma.Sql): string => query.strings.join(' '); + +const buildContext = (options: { + auth?: GameSessionTokenPayload | null; + general?: GeneralRow | null; + myVote?: number[] | null; + voteRows?: Array<{ selection: number[]; cnt: number }>; + pollRow?: typeof poll; + configConst?: Record; + auctionTargets?: string[]; +}) => { + const auth = options.auth === undefined ? buildAuth() : options.auth; + const general = options.general === undefined ? buildGeneral() : options.general; + const requestCommand = vi.fn(async () => ({ + type: 'voteReward' as const, + ok: true as const, + voteId: 1, + generalId: general?.id ?? 0, + awardedUnique: false, + })); + const queryRaw = vi.fn(async (query: GamePrisma.Sql) => { + const text = sqlText(query); + if (text.includes('FROM vote_poll') && text.includes('LIMIT 1')) { + return [options.pollRow ?? poll]; + } + if (text.includes('INSERT INTO vote (')) { + return [{ id: 11 }]; + } + if (text.includes('FROM vote_comment')) { + return []; + } + if (text.includes('SELECT selection') && text.includes('general_id')) { + return options.myVote ? [{ selection: options.myVote }] : []; + } + if (text.includes('GROUP BY selection')) { + return options.voteRows ?? [{ selection: [0], cnt: 2 }]; + } + if (text.includes('INSERT INTO vote_comment')) { + return []; + } + return []; + }); + const db = { + $queryRaw: queryRaw, + worldState: { + findFirst: vi.fn(async () => ({ + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 3600, + config: { const: { develCost: 18, allItems: {}, ...(options.configConst ?? {}) } }, + meta: { + hiddenSeed: 'seed', + scenarioId: 200, + initYear: 180, + initMonth: 1, + scenarioMeta: { startYear: 180 }, + }, + updatedAt: new Date(), + })), + }, + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + general?.userId === where.userId ? general : null + ), + findMany: vi.fn(async () => [ + { + horseCode: general?.horseCode ?? 'None', + weaponCode: general?.weaponCode ?? 'None', + bookCode: general?.bookCode ?? 'None', + itemCode: general?.itemCode ?? 'None', + }, + ]), + count: vi.fn(async () => 2), + }, + nation: { + findFirst: vi.fn(async () => ({ name: '촉' })), + }, + auction: { + findMany: vi.fn(async () => (options.auctionTargets ?? []).map((targetCode) => ({ targetCode }))), + }, + }; + 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, queryRaw, db }; +}; + +describe('vote router actor and permission boundaries', () => { + it('rejects unauthenticated survey access', async () => { + const fixture = buildContext({ auth: null }); + + await expect(appRouter.createCaller(fixture.context).vote.getVoteList()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => { + const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' }); + const fixture = buildContext({ general: owned }); + + await expect( + appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] }) + ).resolves.toEqual({ ok: true, wonLottery: false }); + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'voteReward', + voteId: 1, + generalId: 7, + goldReward: 90, + }) + ); + }); + + it('includes active unique auctions in the API-side reward expectation', async () => { + const fixture = buildContext({ + configConst: { + allItems: { weapon: { che_무기_12_칠성검: 1 } }, + maxUniqueItemLimit: [[-1, 1]], + uniqueTrialCoef: 10, + maxUniqueTrialProb: 10, + minMonthToAllowInheritItem: 0, + }, + auctionTargets: ['che_무기_12_칠성검'], + }); + + await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] }); + + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + unique: { expected: false, itemKey: null }, + }) + ); + }); + + it('rejects voting and comments when the authenticated user owns no general', async () => { + const fixture = buildContext({ auth: buildAuth([], 'user-2'), general: buildGeneral({ userId: 'user-1' }) }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.vote.submitVote({ voteId: 1, selection: [0] })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'General not found', + }); + await expect(caller.vote.addComment({ voteId: 1, text: '댓글' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'General not found', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('rejects duplicate selections before persisting a vote', async () => { + const fixture = buildContext({ pollRow: { ...poll, multiple_options: 2 } }); + + await expect( + appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0, 0] }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '선택한 항목이 올바르지 않습니다.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('shows legacy-compatible aggregate results before the current general votes', async () => { + const fixture = buildContext({ myVote: null, voteRows: [{ selection: [0], cnt: 2 }] }); + + const result = await appRouter.createCaller(fixture.context).vote.getVoteDetail({ voteId: 1 }); + + expect(result.myVote).toBeNull(); + expect(result.votes).toEqual([{ selection: [0], count: 2 }]); + }); + + it.each([ + ['global survey permission', ['admin.survey.open'], true], + ['wildcard survey permission', ['admin.survey.open:*'], true], + ['matching profile permission', ['admin.survey.open:che:default'], true], + ['different profile permission', ['admin.survey.open:hwe:default'], false], + ['ordinary user', ['user'], false], + ])('%s controls the administrator panel', async (_label, roles, allowed) => { + const fixture = buildContext({ auth: buildAuth(roles) }); + const request = appRouter.createCaller(fixture.context).vote.getAdminStatus(); + + if (allowed) { + await expect(request).resolves.toEqual({ ok: true }); + } else { + await expect(request).rejects.toMatchObject({ code: 'FORBIDDEN' }); + } + }); +}); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 5fe0a1b..5a0a976 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -24,6 +24,7 @@ import { evaluateConstraints, resolveGeneralAction, ITEM_KEYS, + addOccupiedUniqueItemKeys, buildGenericUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, @@ -382,6 +383,7 @@ const buildUniqueLotteryRunner = (options: { seedBase: string; itemRegistry: Map; uniqueConfig: ReturnType; + getAdditionalOccupiedUniqueItemKeys?: () => Iterable; }): UniqueLotteryRunner => { if (!options.worldView) { return () => null; @@ -408,6 +410,11 @@ const buildUniqueLotteryRunner = (options: { entry.id === general.id ? general.role.items : entry.role.items ); const occupiedUniqueCounts = countOccupiedUniqueItems(generalItemsList, options.itemRegistry); + addOccupiedUniqueItemKeys( + occupiedUniqueCounts, + options.getAdditionalOccupiedUniqueItemKeys?.() ?? [], + options.itemRegistry + ); const rngSeed = buildGenericUniqueSeed( options.seedBase, world.currentYear, @@ -723,6 +730,7 @@ export const createReservedTurnHandler = async (options: { commandProfile?: TurnCommandProfile; commandEnv?: TurnCommandEnv; commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil; + getAdditionalOccupiedUniqueItemKeys?: () => Iterable; onActionResolved?: (payload: { kind: 'nation' | 'general'; generalId: number; @@ -944,6 +952,7 @@ export const createReservedTurnHandler = async (options: { seedBase, itemRegistry, uniqueConfig, + getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys, }); let baseContext: ActionContextBase = { general: currentGeneral, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 6517527..2362c52 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -473,6 +473,8 @@ const createTurnDaemonRuntimeWithLease = async ( neutralAuctionRegistrar.handler, frontStateHandler ); + let occupiedAuctionUniqueItemKeys: string[] = []; + let refreshOccupiedAuctionUniqueItemKeys = async (): Promise => {}; const worldOptions: InMemoryTurnWorldOptions = { schedule, generalTurnHandler: @@ -486,6 +488,7 @@ const createTurnDaemonRuntimeWithLease = async ( getWorld: () => worldRef, commandProfile, commandEnv: monthlyCommandEnv, + getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys, })), calendarHandler: calendarHandler ?? undefined, autoAdvanceDiplomacyMonth: false, @@ -501,6 +504,7 @@ const createTurnDaemonRuntimeWithLease = async ( ? async (general) => { const promises: Promise[] = []; promises.push(reservedTurnStoreHandle.store.refreshGeneralTurns(general.id)); + promises.push(refreshOccupiedAuctionUniqueItemKeys()); if (general.nationId > 0 && general.officerLevel >= 5) { promises.push( reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel) @@ -648,6 +652,17 @@ const createTurnDaemonRuntimeWithLease = async ( if (commandConnector && databaseCommandQueue) { await commandConnector.connect(); await databaseCommandQueue.initialize(); + refreshOccupiedAuctionUniqueItemKeys = async () => { + const rows = await commandConnector.prisma.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }); + occupiedAuctionUniqueItemKeys = rows.flatMap((row) => (row.targetCode ? [row.targetCode] : [])); + }; } const baseClose = close; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 3be3c7c..b4c9109 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -11,6 +11,7 @@ import { LogFormat, LogScope, ITEM_KEYS, + addOccupiedUniqueItemKeys, buildVoteUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, @@ -1162,6 +1163,21 @@ async function handleVoteReward( generals.map((entry) => entry.role.items), itemRegistry ); + if (ctx.commandDb) { + const reservedUniqueRows = await ctx.commandDb.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }); + addOccupiedUniqueItemKeys( + occupiedUniqueCounts, + reservedUniqueRows.map((row) => row.targetCode), + itemRegistry + ); + } const userCount = generals.filter((entry) => entry.npcState < 2).length; const rngSeed = buildVoteUniqueSeed( typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), diff --git a/app/game-engine/test/uniqueLotteryCommand.test.ts b/app/game-engine/test/uniqueLotteryCommand.test.ts index c09664e..e293455 100644 --- a/app/game-engine/test/uniqueLotteryCommand.test.ts +++ b/app/game-engine/test/uniqueLotteryCommand.test.ts @@ -173,4 +173,138 @@ describe('unique lottery on general commands', () => { const logTexts = (result.logs ?? []).map((entry) => entry.text); expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true); }); + + it('does not award a unique item reserved by an active auction', async () => { + const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + const generals = [buildGeneral(1)]; + const snapshot: TurnWorldSnapshot = { + generals: generals as any, + cities: [ + { + id: 1, + name: 'City_1', + nationId: 1, + viewName: 'City_1', + agriculture: 100, + agricultureMax: 2000, + commerce: 100, + commerceMax: 2000, + security: 100, + securityMax: 100, + def: 100, + defMax: 100, + wall: 100, + wallMax: 100, + pop: 10000, + popMax: 50000, + trust: 50, + supplyState: 1, + frontState: 0, + tradepoint: 0, + level: 1, + meta: {}, + }, + ] as any, + nations: [ + { + id: 1, + name: 'TestNation', + color: '#FF0000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10000, + rice: 10000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + }, + ] as any, + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'test_map', + name: 'TestMap', + cities: [ + { + id: 1, + name: 'City_1', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [], + max: {} as any, + initial: {} as any, + }, + ], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + } as any, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + allItems: { + weapon: { + che_무기_12_칠성검: 1, + }, + }, + maxUniqueItemLimit: [[-1, 1]], + uniqueTrialCoef: 10, + maxUniqueTrialProb: 10, + minMonthToAllowInheritItem: 0, + }, + environment: { mapName: 'test_map', unitSet: 'default' }, + }, + scenarioMeta: { + startYear: 180, + } as any, + unitSet: {} as any, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 180, + currentMonth: 1, + tickSeconds: 3600, + lastTurnTime: new Date('0180-01-01T00:00:00Z'), + meta: { + hiddenSeed: 'seed', + scenarioId: 200, + initYear: 180, + initMonth: 1, + scenarioMeta: { startYear: 180 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const reservedTurns = new InMemoryReservedTurnStore( + { + generalTurn: { findMany: async () => [] }, + nationTurn: { findMany: async () => [] }, + } as any, + { maxGeneralTurns: 30, maxNationTurns: 12 } + ); + reservedTurns.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} }; + const handler = await createReservedTurnHandler({ + reservedTurns, + scenarioConfig: snapshot.scenarioConfig, + scenarioMeta: snapshot.scenarioMeta, + map: snapshot.map, + unitSet: snapshot.unitSet, + getWorld: () => world, + getAdditionalOccupiedUniqueItemKeys: () => ['che_무기_12_칠성검'], + }); + + const result = handler.execute({ + general: world.getGeneralById(1)!, + city: world.getCityById(1)!, + nation: world.getNationById(1)!, + world: world.getState(), + schedule, + }); + + expect(result.general?.role.items.weapon).toBeNull(); + expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false); + }); }); diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts index e713b6f..402f9d0 100644 --- a/app/game-engine/test/voteReward.test.ts +++ b/app/game-engine/test/voteReward.test.ts @@ -223,4 +223,80 @@ describe('voteReward command', () => { const afterSecond = world.getGeneralById(1); expect(afterSecond?.gold).toBe(1500); }); + + it('treats an active unique auction as occupied when revalidating the lottery', async () => { + const general = buildGeneral(1); + const snapshot: TurnWorldSnapshot = { + generals: [general] as any, + cities: [] as any, + nations: [] as any, + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'test_map', + name: 'TestMap', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + } as any, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + allItems: { weapon: { che_무기_12_칠성검: 1 } }, + maxUniqueItemLimit: [[-1, 1]], + uniqueTrialCoef: 10, + maxUniqueTrialProb: 10, + minMonthToAllowInheritItem: 0, + }, + environment: { mapName: 'test_map', unitSet: 'default' }, + }, + scenarioMeta: { startYear: 180 } as any, + unitSet: {} as any, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 180, + currentMonth: 1, + tickSeconds: 3600, + lastTurnTime: new Date('0180-01-01T00:00:00Z'), + meta: { + hiddenSeed: 'seed', + scenarioId: 200, + initYear: 180, + initMonth: 1, + scenarioMeta: { startYear: 180 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const handler = createTurnDaemonCommandHandler({ world }); + const commandDb = { + auction: { + findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }], + }, + }; + + const result = await handler.handle( + { + type: 'voteReward', + voteId: 1, + generalId: 1, + goldReward: 500, + unique: { expected: false, itemKey: null }, + }, + { db: commandDb as any } + ); + + expect(result).toMatchObject({ + type: 'voteReward', + ok: true, + awardedUnique: false, + }); + expect(world.getGeneralById(1)?.gold).toBe(1500); + expect(world.getGeneralById(1)?.role.items.weapon).toBeNull(); + }); }); diff --git a/app/game-frontend/src/views/SurveyView.vue b/app/game-frontend/src/views/SurveyView.vue index ec59caa..22e4e71 100644 --- a/app/game-frontend/src/views/SurveyView.vue +++ b/app/game-frontend/src/views/SurveyView.vue @@ -1,865 +1,682 @@