feat: 플레이 감사 월별 상태와 국가 집계 기반 추가

This commit is contained in:
2026-09-16 02:25:25 +00:00
parent 5ac961dfd1
commit 4de3175279
4 changed files with 432 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
import { asNumber } from '@sammo-ts/common';
import type { City, Nation } from '@sammo-ts/logic';
import { resolveAppliedNationRate } from '../turn/nationTaxRate.js';
import type { TurnGeneral } from '../turn/types.js';
export const AUDIT_DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const;
export type AuditDex = Record<(typeof AUDIT_DEX_KEYS)[number], number>;
export type AuditPopulation = 'human' | 'npc' | 'troopNpc';
export interface AuditPopulationSummary {
count: number;
gold: number;
rice: number;
dex: AuditDex;
averageGold: number | null;
averageRice: number | null;
averageDex: Record<keyof AuditDex, number | null>;
}
/** 정산 처리에서 관측한 값만 전달한다. prev_income_*는 이번 달 흐름이 아니다. */
export interface AuditSettlement {
nationId: number;
resource: 'gold' | 'rice';
income: number;
paid: number;
}
export interface AuditNationSnapshot {
id: number;
name: string;
color: string;
gold: number;
rice: number;
tech: number;
appliedRate: number;
incomeGold: number | null;
incomeRice: number | null;
paidGold: number | null;
paidRice: number | null;
populations: Record<AuditPopulation, AuditPopulationSummary>;
}
const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 });
const emptyPopulation = (): AuditPopulationSummary => ({
count: 0,
gold: 0,
rice: 0,
dex: emptyDex(),
averageGold: null,
averageRice: null,
averageDex: { dex1: null, dex2: null, dex3: null, dex4: null, dex5: null },
});
export const classifyAuditPopulation = (npcState: number): AuditPopulation =>
npcState === 5 ? 'troopNpc' : npcState < 2 ? 'human' : 'npc';
export interface AuditGeneralSnapshot extends Pick<
TurnGeneral,
| 'id'
| 'name'
| 'nationId'
| 'cityId'
| 'troopId'
| 'npcState'
| 'gold'
| 'rice'
| 'stats'
| 'experience'
| 'dedication'
| 'officerLevel'
| 'injury'
| 'age'
| 'crew'
| 'crewTypeId'
| 'train'
| 'atmos'
| 'role'
> {
userId: string | null;
population: AuditPopulation;
dex: AuditDex;
}
export const projectAuditGeneral = (general: TurnGeneral): AuditGeneralSnapshot => ({
id: general.id,
name: general.name,
userId: general.userId ?? null,
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
npcState: general.npcState,
population: classifyAuditPopulation(general.npcState),
gold: general.gold,
rice: general.rice,
stats: { ...general.stats },
experience: general.experience,
dedication: general.dedication,
officerLevel: general.officerLevel,
injury: general.injury,
age: general.age,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
role: { ...general.role, items: { ...general.role.items } },
dex: {
dex1: asNumber(general.meta.dex1, 0),
dex2: asNumber(general.meta.dex2, 0),
dex3: asNumber(general.meta.dex3, 0),
dex4: asNumber(general.meta.dex4, 0),
dex5: asNumber(general.meta.dex5, 0),
},
});
export interface AuditCitySnapshot extends Omit<City, 'meta' | 'conflict'> {
trust: number;
}
export const projectAuditCity = (city: City): AuditCitySnapshot => ({
id: city.id,
name: city.name,
nationId: city.nationId,
level: city.level,
state: city.state,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
wall: city.wall,
wallMax: city.wallMax,
defence: city.defence,
defenceMax: city.defenceMax,
supplyState: city.supplyState,
frontState: city.frontState,
trust: asNumber(city.meta.trust, 50),
});
/** 이미 로드한 world를 한 번씩 순회한다. DB/RNG/가변 world 객체는 보관하지 않는다. */
export const buildAuditSnapshot = (input: {
nations: Iterable<Nation>;
cities: Iterable<City>;
generals: Iterable<TurnGeneral>;
settlements: Iterable<AuditSettlement>;
settlementsComplete: boolean;
}): { nations: AuditNationSnapshot[]; cities: AuditCitySnapshot[]; generals: AuditGeneralSnapshot[] } => {
const nations = new Map<number, AuditNationSnapshot>();
for (const nation of input.nations) {
const flow = input.settlementsComplete ? 0 : null;
nations.set(nation.id, {
id: nation.id,
name: nation.name,
color: nation.color,
gold: nation.gold,
rice: nation.rice,
tech: asNumber(nation.meta.tech, 0),
appliedRate: resolveAppliedNationRate(nation.meta),
incomeGold: flow,
incomeRice: flow,
paidGold: flow,
paidRice: flow,
populations: { human: emptyPopulation(), npc: emptyPopulation(), troopNpc: emptyPopulation() },
});
}
const generals: AuditGeneralSnapshot[] = [];
for (const general of input.generals) {
const projected = projectAuditGeneral(general);
generals.push(projected);
const population = nations.get(general.nationId)?.populations[projected.population];
if (!population) continue;
population.count++;
population.gold += projected.gold;
population.rice += projected.rice;
for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key];
}
for (const nation of nations.values()) {
for (const population of Object.values(nation.populations)) {
if (!population.count) continue;
population.averageGold = population.gold / population.count;
population.averageRice = population.rice / population.count;
for (const key of AUDIT_DEX_KEYS) population.averageDex[key] = population.dex[key] / population.count;
}
}
for (const settlement of input.settlements) {
const nation = nations.get(settlement.nationId);
if (!nation || !input.settlementsComplete) continue;
if (settlement.resource === 'gold') {
nation.incomeGold = (nation.incomeGold ?? 0) + settlement.income;
nation.paidGold = (nation.paidGold ?? 0) + settlement.paid;
} else {
nation.incomeRice = (nation.incomeRice ?? 0) + settlement.income;
nation.paidRice = (nation.paidRice ?? 0) + settlement.paid;
}
}
return { nations: [...nations.values()], cities: Array.from(input.cities, projectAuditCity), generals };
};
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic';
import type { TurnGeneral } from '../src/turn/types.js';
import { buildAuditSnapshot } from '../src/playAudit/snapshot.js';
const turnTime = new Date('0200-01-01T00:00:00.000Z');
const buildGeneral = (id: number, nationId: number): TurnGeneral => ({
id,
name: `장수${id}`,
nationId,
cityId: nationId,
troopId: 0,
stats: { leadership: 80, strength: 70, intelligence: 60 },
experience: 1_000,
dedication: 900,
officerLevel: 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 2_000,
rice: 2_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: nationId === 0 ? 2 : 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
turnTime,
});
const buildCity = (id: number, nationId: number): City => ({
id,
name: `도시${id}`,
nationId,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: {},
});
const buildNation = (id: number, power: number, meta: Nation['meta']): Nation => ({
id,
name: id === 0 ? '재야' : `국가${id}`,
color: '#777777',
capitalCityId: id === 0 ? null : id,
chiefGeneralId: null,
gold: 10_000,
rice: 20_000,
power,
level: id === 0 ? 0 : 1,
typeCode: 'che_중립',
meta,
});
describe('play audit monthly projection', () => {
it('separates humans, NPCs and troop NPCs and retains empty nations and neutral generals', () => {
const humans = [buildGeneral(1, 1), { ...buildGeneral(2, 1), npcState: 1, gold: 0 }];
humans[0]!.meta.dex1 = 10;
const result = buildAuditSnapshot({
nations: [buildNation(0, 0, {}), buildNation(1, 0, {}), buildNation(2, 0, {})],
cities: [buildCity(1, 2)],
generals: [
...humans,
{ ...buildGeneral(3, 1), npcState: 2 },
{ ...buildGeneral(4, 1), npcState: 5 },
buildGeneral(5, 0),
],
settlements: [],
settlementsComplete: true,
});
const nation = result.nations.find((row) => row.id === 1)!;
expect(nation.populations.human).toMatchObject({
count: 2,
gold: 2000,
averageGold: 1000,
averageDex: { dex1: 5 },
});
expect(nation.populations.npc.count).toBe(1);
expect(nation.populations.troopNpc.count).toBe(1);
expect(result.nations.find((row) => row.id === 2)!.populations.human.averageGold).toBeNull();
expect(result.nations.find((row) => row.id === 0)!.populations.npc.count).toBe(1);
expect(result.cities[0]!.nationId).toBe(2);
expect(result.generals[0]!.nationId).toBe(1);
expect(result.generals[0]!.cityId).toBe(1);
});
it('uses observed settlements, preserves fractions and does not reuse stale income', () => {
const input = {
nations: [buildNation(1, 0, { prev_income_gold: 999999 })],
cities: [],
generals: [],
settlements: [{ nationId: 1, resource: 'gold' as const, income: 943.5, paid: 123 }],
};
expect(buildAuditSnapshot({ ...input, settlementsComplete: true }).nations[0]).toMatchObject({
incomeGold: 943.5,
paidGold: 123,
incomeRice: 0,
paidRice: 0,
});
expect(
buildAuditSnapshot({ ...input, settlements: [], settlementsComplete: true }).nations[0]!.incomeGold
).toBe(0);
expect(buildAuditSnapshot({ ...input, settlementsComplete: false }).nations[0]).toMatchObject({
incomeGold: null,
paidGold: null,
incomeRice: null,
paidRice: null,
});
});
it('takes detached allowlisted state without credentials or mutable metadata', () => {
const general = buildGeneral(1, 1);
general.userId = 'owner';
general.meta.secret = 'must-not-copy';
general.role.items.horse = 'horse';
const city = buildCity(1, 1);
const result = buildAuditSnapshot({
nations: [buildNation(1, 0, {})],
cities: [city],
generals: [general],
settlements: [],
settlementsComplete: true,
});
general.name = 'renamed';
general.stats.strength = 1;
general.role.items.horse = null;
city.population = 0;
expect(result.generals[0]).toMatchObject({
name: '장수1',
userId: 'owner',
stats: { strength: 70 },
role: { items: { horse: 'horse' } },
});
expect(JSON.stringify(result)).not.toContain('must-not-copy');
expect(result.cities[0]!.population).toBe(10000);
});
it('consumes each source once without per-nation scans', () => {
function once<T>(rows: T[]): Iterable<T> {
let used = false;
return {
*[Symbol.iterator]() {
if (used) throw new Error('second full scan');
used = true;
yield* rows;
},
};
}
const result = buildAuditSnapshot({
nations: once([buildNation(1, 0, {})]),
cities: once([buildCity(1, 1)]),
generals: once([buildGeneral(1, 1)]),
settlements: once([]),
settlementsComplete: true,
});
expect(result.generals).toHaveLength(1);
});
});
+53
View File
@@ -0,0 +1,53 @@
# 플레이 감사 구현 기록과 수집 inventory
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
아래 순수 projection은 아직 runtime 수집·DB·API·화면에 연결되지 않았다.
## 현재 구현
`app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로
허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각
한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도
복사하여 이후 개명·이동·장비 변경으로 과거 표본이 변하지 않게 한다. 임의 meta,
triggerState, credential과 전체 world는 복사하지 않는다.
장수 분류는 human(`npcState < 2`), npc(`>= 2`, 5 제외), troopNpc(5)이다.
빈 집단은 합계 0, 평균 null이다. nation 0도 입력에 있으면 일반 국가와 별도로
집계한다. 장수의 국가와 도시 소유국을 일치시키지 않으므로 외국 주둔을 보존한다.
정산은 당월에 실제 관측한 `income/paid`만 별도 입력으로 받는다. 수집 완료 월에
정산이 없으면 0, 도입 월처럼 완전 수집을 증명하지 못한 기간은 null이다.
`prev_income_gold/rice`는 과거 정산 metadata이므로 집계하지 않는다. 정산 원장의
국가 전후값·적용 세율·보정액은 이후 원장 구현에서 보존해야 하며 이 projection만으로
R1을 완료했다고 판단하지 않는다.
## 수집 지점과 쓰기 재검토
기준 Core commit은 `5ac961dfd17738dc4c39e6401f975296f39403a5`이다.
SQL/bytes는 아직 실측하지 않았으며 아래는 현재 소스에서 확인한 연결 지점과 구현 경계다.
| 자료 | 실제 source / 관측할 값 | 구현·비용 결정 | 남은 검증 |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| 월별 장수·도시·국가 | `turn/yearbookHandler.ts``beforeMonthChanged`; `playAudit/snapshot.ts`의 필드 allowlist | 메모리 한 순회, 추가 SELECT 없이 수집. 상세는 도시/국가/장수로 페이지 조회 가능한 행에 batch 저장하며 큰 월 JSON 전체를 목록 조회하지 않음 | 월 pending/rollback 연결, migration, DB reload, 종료 부분 표본 |
| 세율 적용 수입·급여 | `turn/incomeHandler.ts``applyIncome`, `incomeValue`, `current`, `next`, `ratio`, 장수별 `pay`; `turn/nationTaxRate.ts` | 이미 계산한 수치만 관측. 국가 수입과 실제 급여 합계 분리, 과거 metadata 재누적 금지. 정산 원장을 월집계 입력으로 재사용 | 정수화·최저 자원 보정, 원장/집계 원자 저장, 도입 월 coverage |
| 월별 내구성 | `turn/inMemoryWorld.ts`의 capture/restore, peek/acknowledge와 pending yearbook; `turn/databaseHooks.ts``persistChanges` | 별도 audit pending을 같은 transaction과 savepoint에 포함. 기존 연감의 장기보존 테이블에 상세 감사를 넣지 않음 | 실패·중복·재시작, bounded 삭제 |
| 기수 identity | `scenario/scenarioSeeder.ts``install.serverId`, `GameHistory` 충돌 검사 | profile명으로 대체하지 않음. 외부 install 입력을 만드는 지점과 RESET 전체 경로를 추가 추적한 뒤 수집 활성화 | 신규 identity 생성, 재시도, 기존 설치에 identity 누락 시 처리 |
| 외교 | game-api `router/diplomacy/index.ts`, engine 월간 외교 처리 | 불변 문서는 참조, 갱신되는 내용만 당시 버전 저장. 현재 상태 월복사만으로 사건을 대신하지 않음 | 모든 API/engine mutation별 inventory |
| NPC 정책 | `turn/worldCommandHandler.ts``turn/npcPolicyMutation.ts` | CAS 성공하고 실제 값이 달라진 경우에만 불변 버전. 무변경/거부는 적용 버전에서 제외 | 초기 버전, actor/직책, 국방 mutation inventory |
| 권한 | Gateway `adminCapabilities.ts`, `adminAuth.ts`; game-api `trpc.ts` 인증·제재 middleware | scoped 감사 권한과 공통 계정 추가 권한 분리. `getMyGeneral` 요구 없이 서버에서 검사 | catalog/token/flush/HTTP matrix 전체 연결 |
월간 실행은 이전 월 snapshot → 달 변경 → 새달 `onMonthChanged` 순서다.
1월 금/7월 쌀 정산은 새로 진입한 월의 흐름으로 누적하고 그 월 마감에 집계한다.
입력 목록은 이미 로드된 world를 사용하며 기존 연감의 로그 SELECT를 새 감사 수집의
필수 입력으로 만들지 않는다.
## 검증 진입점
- 순수 projection: `app/game-engine/test/playAuditSnapshot.test.ts`.
- 월말·저장 경계: `monthlyBoundaryPrePersistence.integration.test.ts`.
- 수입: `monthlySemiAnnualPersistence.integration.test.ts`, `monthlyWarIncomePersistence.integration.test.ts`.
- 원자성: `inputEventAtomicity.test.ts`, `readModelChangeJournalPersistence.integration.test.ts`.
현재 순수 fixture는 분모, 0/null, 소수 수입, 미수집, 외국 주둔, 과거 값의 독립성,
민감 meta 제외와 단일 순회를 검증한다. PostgreSQL SQL count/WAL/실행계획,
권한 HTTP, CHE/HWE Chromium과 전체 source inventory는 아직 남아 있다.
+1
View File
@@ -5,6 +5,7 @@
**설계 기준: 2026-09-16. 제품 기능은 미구현이다.** 이 문서는 관리자 플레이
감사의 구현 goal과 완료 판정 기준이다. 문서 작성 완료는 기능 구현 완료가 아니다.
후속 작업은 아래 요구사항 ID, 단계와 증거 표를 유지하며 진행 상태를 갱신한다.
현재 구현 진행과 수집 지점은 [구현 inventory](play-audit-implementation.md)에 기록한다.
사용자 결정으로 고정한 범위: