test: audit and strengthen legacy test suite

This commit is contained in:
2026-07-25 06:54:18 +00:00
parent 46ae79dbe7
commit 7ed1b5df7f
27 changed files with 422 additions and 654 deletions
+21 -8
View File
@@ -226,23 +226,36 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
}); });
describe('battle sim processor', () => { describe('battle sim processor', () => {
it('simulates battles and returns summary', () => { it('returns the fixed-seed battle summary instead of only a successful shape', () => {
const payload = buildPayload('battle'); const payload = buildPayload('battle');
const result = processBattleSimJob(payload); const result = processBattleSimJob(payload);
expect(result.result).toBe(true); expect(result).toMatchObject({
expect(result.reason).toBe('success'); result: true,
expect(result.lastWarLog).toBeTruthy(); reason: 'success',
expect(result.phase).toBeTypeOf('number'); datetime: '2026-01-01 00:00:00',
expect(result.attackerRice).toBeTypeOf('number'); avgWar: 1,
phase: 2,
killed: 625,
maxKilled: 625,
minKilled: 625,
dead: 1000,
maxDead: 1000,
minDead: 1000,
attackerRice: 65,
defenderRice: 83,
attackerSkills: { 부상: 1 },
defendersSkills: [{ 회피시도: 1, 회피: 1 }],
});
expect(result.lastWarLog?.generalActionLog).toContain('퇴각했습니다.');
}); });
it('returns defender order for reorder action', () => { it('returns the fixed defender ID order for reorder action', () => {
const payload = buildPayload('reorder'); const payload = buildPayload('reorder');
const result = processBattleSimJob(payload); const result = processBattleSimJob(payload);
expect(result.result).toBe(true); expect(result.result).toBe(true);
expect(result.order?.length).toBe(1); expect(result.order).toEqual([2]);
}); });
it('executes crew trigger handlers in simulator battles', () => { it('executes crew trigger handlers in simulator battles', () => {
+17 -9
View File
@@ -5,12 +5,13 @@ import { createTournamentAutoStartHandler } from '@sammo-ts/common';
import { TournamentStore } from '../src/tournament/store.js'; import { TournamentStore } from '../src/tournament/store.js';
import { buildTournamentKeys } from '../src/tournament/keys.js'; import { buildTournamentKeys } from '../src/tournament/keys.js';
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from '../src/tournament/types.js'; import type {
import { TournamentBetEntry,
applyBattle, TournamentMatchEntry,
applyPreBattleStage, TournamentParticipantEntry,
settleTournamentOutcome, TournamentState,
} from '../src/tournament/worker.js'; } from '../src/tournament/types.js';
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js';
class MemoryRedis { class MemoryRedis {
@@ -208,7 +209,7 @@ const runTournamentToCompletion = async (options: {
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0)); const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
describe('tournament worker (in-memory)', () => { describe('tournament worker (in-memory)', () => {
it('64명(상/중/하 스탯)으로 결승까지 진행된다', async () => { it('64명 고정 seed 대진에서 15번 참가자가 결승을 이긴다', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test')); const store = new TournamentStore(redis, buildTournamentKeys('test'));
const participants = createParticipants(16, 16, 32); const participants = createParticipants(16, 16, 32);
@@ -219,10 +220,17 @@ describe('tournament worker (in-memory)', () => {
const prisma = createPrismaMock({ baseSeed: 'seed' }); const prisma = createPrismaMock({ baseSeed: 'seed' });
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' }); const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });
const matches = await store.getMatches(); const matches = await store.getMatches();
const finalMatches = matches.filter((match) => match.stage === 10);
expect(finalState.stage).toBe(0); expect(finalState.stage).toBe(0);
expect(finalState.winnerId).toBeDefined(); expect(finalState.winnerId).toBe(15);
expect(matches.some((match) => match.stage === 10 && match.winnerId)).toBe(true); expect(finalMatches).toHaveLength(1);
expect(finalMatches[0]).toMatchObject({
attackerId: 15,
defenderId: 1,
winnerId: 15,
lastEnergy: { attacker: -90, defender: -92 },
});
}); });
it('우승 결과에 따라 베팅 정산 명령이 생성된다', async () => { it('우승 결과에 따라 베팅 정산 명령이 생성된다', async () => {
@@ -100,7 +100,7 @@ const createMockPrisma = (initialGeneralRows: any[] = []) => {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('NPC 일반 내정 턴', () => { describe('NPC 일반 내정 턴', () => {
it('예약 턴어도 내정 수치가 증가한다', async () => { it('고정 seed에서 예약 턴 없이 치안 명령을 실행하고 다음 턴으로 이동한다', async () => {
const generals: TurnGeneral[] = [ const generals: TurnGeneral[] = [
{ {
id: 1, id: 1,
@@ -285,15 +285,10 @@ describe('NPC 일반 내정 턴', () => {
}); });
const afterCity = world.getCityById(1)!; const afterCity = world.getCityById(1)!;
const increased = expect(afterCity).toMatchObject({
afterCity.population > beforeStats.population || ...beforeStats,
afterCity.agriculture > beforeStats.agriculture || security: 1050,
afterCity.commerce > beforeStats.commerce || });
afterCity.security > beforeStats.security || expect(world.getGeneralById(1)!.turnTime.getTime()).toBe(addMinutes(mockDate, 10).getTime());
afterCity.defence > beforeStats.defence ||
afterCity.wall > beforeStats.wall;
expect(increased).toBe(true);
expect(world.getGeneralById(1)!.turnTime.getTime()).toBeGreaterThan(mockDate.getTime());
}); });
}); });
@@ -67,24 +67,14 @@ describe('NPC 대형 시뮬레이션', () => {
for (let i = 0; i < npcPerType; i += 1) { for (let i = 0; i < npcPerType; i += 1) {
const cityId = smallMediumCityIds[i % smallMediumCityIds.length]; const cityId = smallMediumCityIds[i % smallMediumCityIds.length];
generals.push( generals.push(
createNpcGeneral( createNpcGeneral(generals.length + 1, cityId, { leadership: 75, strength: 75, intelligence: 10 }, 2)
generals.length + 1,
cityId,
{ leadership: 75, strength: 75, intelligence: 10 },
2
)
); );
} }
for (let i = 0; i < npcPerType; i += 1) { for (let i = 0; i < npcPerType; i += 1) {
const cityId = smallMediumCityIds[i % smallMediumCityIds.length]; const cityId = smallMediumCityIds[i % smallMediumCityIds.length];
generals.push( generals.push(
createNpcGeneral( createNpcGeneral(generals.length + 1, cityId, { leadership: 75, strength: 10, intelligence: 75 }, 2)
generals.length + 1,
cityId,
{ leadership: 75, strength: 10, intelligence: 75 },
2
)
); );
} }
@@ -275,7 +265,7 @@ describe('NPC 대형 시뮬레이션', () => {
: ` ${trace.requestedAction} -> ${trace.actionKey}`; : ` ${trace.requestedAction} -> ${trace.actionKey}`;
console.log( console.log(
`- ${trace.year}-${String(trace.month).padStart(2, '0')} N${trace.nationId} G${trace.generalId} g${round(trace.gold)}/r${round(trace.rice)} c${trace.crew}/t${trace.train}/a${trace.atmos} ` + `- ${trace.year}-${String(trace.month).padStart(2, '0')} N${trace.nationId} G${trace.generalId} g${round(trace.gold)}/r${round(trace.rice)} c${trace.crew}/t${trace.train}/a${trace.atmos} ` +
`${trace.ok ? 'OK' : 'FAIL'} ${action}${extra}` `${trace.ok ? 'OK' : 'FAIL'} ${action}${extra}`
); );
} }
}; };
@@ -302,9 +292,7 @@ describe('NPC 대형 시뮬레이션', () => {
}; };
const assertSmallMediumCitiesFounded = () => { const assertSmallMediumCitiesFounded = () => {
const targetCities = world const targetCities = world.listCities().filter((city) => [4, 5].includes(city.level));
.listCities()
.filter((city) => [4, 5].includes(city.level));
for (const city of targetCities) { for (const city of targetCities) {
expect(city.nationId).toBeGreaterThan(0); expect(city.nationId).toBeGreaterThan(0);
} }
@@ -384,54 +372,13 @@ describe('NPC 대형 시뮬레이션', () => {
expect(afterTotal).toBeGreaterThan(beforeTotal); expect(afterTotal).toBeGreaterThan(beforeTotal);
}; };
const assertDomesticGrowthBy = (year: number, month: number) => { const assertDomesticGrowth = () => {
const citiesNow = world.listCities().filter((city) => city.nationId > 0); const citiesNow = world.listCities().filter((city) => city.nationId > 0);
for (const city of citiesNow) { for (const city of citiesNow) {
const baseline = initialCityStats.get(city.id); const baseline = initialCityStats.get(city.id);
if (!baseline) { if (!baseline) {
continue; continue;
} }
const delta = {
population: city.population - baseline.population,
agriculture: city.agriculture - baseline.agriculture,
commerce: city.commerce - baseline.commerce,
security: city.security - baseline.security,
defence: city.defence - baseline.defence,
wall: city.wall - baseline.wall,
};
console.log('[DEBUG] domestic delta', {
year,
month,
cityId: city.id,
nationId: city.nationId,
delta,
});
const failures: string[] = [];
if (city.population <= baseline.population) failures.push('population');
if (city.agriculture <= baseline.agriculture) failures.push('agriculture');
if (city.commerce <= baseline.commerce) failures.push('commerce');
if (city.security <= baseline.security) failures.push('security');
if (city.defence <= baseline.defence) failures.push('defence');
if (city.wall <= baseline.wall) failures.push('wall');
if (failures.length > 0) {
console.log('[DEBUG] domestic not grown', {
year,
month,
cityId: city.id,
nationId: city.nationId,
failures,
baseline,
current: {
population: city.population,
agriculture: city.agriculture,
commerce: city.commerce,
security: city.security,
defence: city.defence,
wall: city.wall,
},
});
}
// 182년 시점에는 징병이 병행되어 인구가 순감할 수 있으므로, 대규모 붕괴만 방지한다. // 182년 시점에는 징병이 병행되어 인구가 순감할 수 있으므로, 대규모 붕괴만 방지한다.
expect(city.population).toBeGreaterThan(baseline.population - 15000); expect(city.population).toBeGreaterThan(baseline.population - 15000);
expect(city.agriculture).toBeGreaterThan(baseline.agriculture); expect(city.agriculture).toBeGreaterThan(baseline.agriculture);
@@ -446,15 +393,18 @@ describe('NPC 대형 시뮬레이션', () => {
['179-09', () => assertUprisingCount(1)], ['179-09', () => assertUprisingCount(1)],
['179-10', () => assertUprisingCount(2)], ['179-10', () => assertUprisingCount(2)],
['179-11', () => assertFoundedCount(1)], ['179-11', () => assertFoundedCount(1)],
['179-12', () => { [
assertTaxRateUnder(15); '179-12',
maybeSnapshotGold(); () => {
}], assertTaxRateUnder(15);
maybeSnapshotGold();
},
],
['180-01', () => assertGoldIncome()], ['180-01', () => assertGoldIncome()],
['180-07', () => assertSmallMediumCitiesFounded()], ['180-07', () => assertSmallMediumCitiesFounded()],
['180-11', () => assertCityTrust(90)], ['180-11', () => assertCityTrust(90)],
['181-01', () => assertNationGeneralCount(10)], ['181-01', () => assertNationGeneralCount(10)],
['182-01', () => assertDomesticGrowthBy(182, 1)], ['182-01', () => assertDomesticGrowth()],
['182-10', () => assertNationRecruitCount(5)], ['182-10', () => assertNationRecruitCount(5)],
['183-01', () => assertWarReadiness(10, 70, 70)], ['183-01', () => assertWarReadiness(10, 70, 70)],
['183-02', () => assertDispatchRecorded(183, 1, 1)], ['183-02', () => assertDispatchRecorded(183, 1, 1)],
+11 -2
View File
@@ -156,7 +156,10 @@ describe('voteReward command', () => {
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const config = resolveUniqueConfig(snapshot.scenarioConfig.const as Record<string, unknown>); const config = resolveUniqueConfig(snapshot.scenarioConfig.const as Record<string, unknown>);
const occupied = countOccupiedUniqueItems(generals.map((general) => general.role.items), itemRegistry); const occupied = countOccupiedUniqueItems(
generals.map((general) => general.role.items),
itemRegistry
);
const rng = new RandUtil(LiteHashDRBG.build(buildVoteUniqueSeed('seed', 1, 1))); const rng = new RandUtil(LiteHashDRBG.build(buildVoteUniqueSeed('seed', 1, 1)));
const itemKey = rollUniqueLottery({ const itemKey = rollUniqueLottery({
rng, rng,
@@ -199,7 +202,12 @@ describe('voteReward command', () => {
expect(updated?.gold).toBe(1500); expect(updated?.gold).toBe(1500);
expect(updated?.role.items.weapon).toBe('che_무기_12_칠성검'); expect(updated?.role.items.weapon).toBe('che_무기_12_칠성검');
const meta = updated?.meta as Record<string, unknown>; const meta = updated?.meta as Record<string, unknown>;
expect(meta?.voteRewards).toBeTruthy(); expect(meta.voteRewards).toMatchObject({
1: {
awarded: true,
itemKey: 'che_무기_12_칠성검',
},
});
const diff = world.consumeDirtyState(); const diff = world.consumeDirtyState();
const logTexts = diff.logs.map((entry) => entry.text); const logTexts = diff.logs.map((entry) => entry.text);
@@ -211,6 +219,7 @@ describe('voteReward command', () => {
throw new Error('voteReward second result missing'); throw new Error('voteReward second result missing');
} }
expect(second.alreadyApplied).toBe(true); expect(second.alreadyApplied).toBe(true);
expect(second.itemKey).toBe('che_무기_12_칠성검');
const afterSecond = world.getGeneralById(1); const afterSecond = world.getGeneralById(1);
expect(afterSecond?.gold).toBe(1500); expect(afterSecond?.gold).toBe(1500);
}); });
+152
View File
@@ -0,0 +1,152 @@
# Test Suite Audit
## Baseline and method
- Audit date: 2026-07-25
- Code baseline: `main@46ae79dbe7a0fb64aff9bdcc76eadafd75e10c9e`
- Executed test sources: 66 TypeScript `*.test.ts` files
- Excluded from the source count: ignored `dist/` outputs and non-executable
fixtures/helpers
- Historical generated copies removed by this audit:
`packages/common/test/*.{js,js.map,d.ts,d.ts.map}` (12 files)
Each suite was read together with its production entry point. Assertions were
classified by what they can actually establish:
- `contract`: focused input/output, rejection, ordering, or side-effect
regression with concrete assertions.
- `compatibility`: expected values or traces are independently grounded in the
PHP reference implementation.
- `integration`: real database, Redis, process, or HTTP behavior; environment
gating must be reported as a skip rather than a pass.
- `smoke`: a broad scenario that proves only the stated liveness/invariant
bounds. It is useful, but is not compatibility evidence.
`kept` means the suite's current scope and assertions match its stated role.
`corrected` means this audit changed a misleading, non-deterministic, empty, or
environment-dependent check.
## Per-suite disposition
### `packages/common`
| Test source | Disposition | Layer | What it establishes |
| ------------------------ | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/clock.test.ts` | kept | contract | Manual and stepped clock advancement plus invalid step rejection. |
| `test/rng.test.ts` | corrected | compatibility | PHP/Python SHA-512 vectors, byte/bit consumption, integer and weighted selection, explicit boundary outputs, and a genuinely expanded long seed. Removed three assertion-free checks. |
| `test/test-rngs.test.ts` | kept | contract | Exact output sequences for deterministic test RNG implementations. |
### `app/gateway-api`
| Test source | Disposition | Layer | What it establishes |
| ------------------------------- | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `test/authFlow.test.ts` | kept | contract | OAuth-backed registration, gateway session issuance, encrypted game token issuance, and successful validation through the router. Token storage/Redis TTL remains integration scope. |
| `test/orchestratorPlan.test.ts` | kept | contract | Exact start/stop/no-op reconciliation decisions for every profile status represented by the planner. |
| `test/scenarioCatalog.test.ts` | kept | contract | Git ref resolution and preview loading from the checked-out repository. |
### `app/game-api`
| Test source | Disposition | Layer | What it establishes |
| --------------------------------------------- | ----------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/battleSimProcessor.test.ts` | corrected | contract + compatibility support | Fixed-seed numeric battle summary, skill activation, exact defender order, and unknown crew handler rejection. Detailed PHP parity remains in `battleDifferential.test.ts`. |
| `test/battleSimRouter.test.ts` | kept | contract | Queued-to-completed transport state flow through the battle router. |
| `test/commandTable.test.ts` | kept | contract | Minimum constraints, rather than full command constraints, determine command-table availability. This is intentionally a focused positive regression. |
| `test/idempotentTransport.test.ts` | kept | contract | Stable ordered child request IDs derived from one API input event. |
| `test/inputEventBoundary.integration.test.ts` | kept | integration | PostgreSQL atomic commit/rollback, retry, concurrent duplicate rejection, and child payload conflict. Explicitly skipped without `INPUT_EVENT_DATABASE_URL`. |
| `test/realtimeSse.test.ts` | kept | contract | Exact SSE framing, multiline encoding, event parsing rejection, and profile channel namespace. |
| `test/reservedTurns.test.ts` | kept | contract | General/nation queue padding and shift direction. Database constraints are outside this in-memory fake. |
| `test/router.test.ts` | kept | contract | Turn-daemon dispatch and serialization of world/status/reserved-turn responses through tRPC callers. |
| `test/streamKeys.test.ts` | kept | contract | Exact Redis stream namespace contract. |
| `test/tournamentWorker.test.ts` | corrected | contract | Fixed-seed bracket winner and final energies, payout command data, auto-join filtering, NPC/dummy fill, and completion. Replaced winner-exists-only coverage for the fixed bracket. |
### `app/game-engine`
| Test source | Disposition | Layer | What it establishes |
| ----------------------------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
| `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. |
| `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
| `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. |
| `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. |
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. Broad thresholds remain intentional smoke bounds; unconditional diagnostic output was removed. |
| `test/npcNationTechResearch.test.ts` | kept | smoke | Long-running monotonic tech growth and higher later recruitment cost. |
| `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
| `test/npcNationWarDeclaration.test.ts` | kept | smoke | Declaration, war transition, preparation, conquest, unification, and dispatch occurrence in one end-to-end NPC scenario. |
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
### `packages/logic`
| Test source | Disposition | Layer | What it establishes |
| -------------------------------------------------------- | ----------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/actions/instant/nationStopWar.test.ts` | kept | contract | Invalid diplomacy rejection and symmetric neutral diplomacy patches. |
| `test/actions/turn/executionHelper.test.ts` | kept | contract | Successful resolution, fallback loading/log accumulation, and fallback loop termination. |
| `test/actions/turn/nation.test.ts` | corrected | compatibility + contract | Declaration effects/logs, non-neighbor denial, capital/name/city effects, and exact legacy `필사즉생` gains (`+15`, train/morale 100, global delay 9). |
| `test/actions/turn/nationMissing.test.ts` | kept | compatibility + contract | Legacy-backed rejection, message, cooldown, assistance accumulator, and state/resource/meta effects for represented migrated nation commands. Exact full PHP snapshots remain outside this suite. |
| `test/crewType.test.ts` | kept | contract | Unit-set normalization and tech/region/city availability requirements. |
| `test/crewTypeExecution.test.ts` | kept | compatibility | Shipped catalog compilation, unknown handlers, crew action routing, legacy triggers, and numeric pick-score formula. |
| `test/diplomacy.test.ts` | kept | compatibility | Exact monthly declaration/war/non-aggression transitions, casualty extension, and reset behavior. |
| `test/dispatchWarAction.test.ts` | kept | contract | Dispatch emits battle logs and patches for defender general, city, and diplomacy casualty state. Numeric battle parity is delegated to the differential suite. |
| `test/itemInventory.test.ts` | kept | contract | Legacy-slot upgrade, frozen-snapshot reads, exact consumable charge depletion, final unequip, and persisted metadata round-trip for canonical item instances. |
| `test/message.test.ts` | kept | contract | Private sender/receiver copies, national/public single-copy rules, and sender action sanitization. |
| `test/scenarioParser.test.ts` | corrected | contract | Defaults and representative legacy-format rows from tracked `resources/scenario`; removed dependence on an untracked local `legacy/` directory. |
| `test/scenarios/blankStart.test.ts` | kept | smoke | Uprising-to-appointment-to-founding workflow and named founding rejections. |
| `test/scenarios/diplomacy.test.ts` | corrected | contract | Declaration is denied for deployment, then the identical command becomes allowed only after transition to WAR. Removed the previous false check that inspected an always-empty troop list while bypassing constraints. |
| `test/scenarios/domestic.test.ts` | corrected | contract | Representative domestic action state changes and capacity behavior, including the exact `+100` agriculture effect, through the scenario harness. |
| `test/scenarios/general/che_NPC능동.test.ts` | corrected | contract | NPC teleport, non-NPC resolver rejection, and structural argument parsing. Replaced an empty always-passing test. |
| `test/scenarios/general/che_강행.test.ts` | kept | contract | Exact move/cost/experience effects, far-city denial, and wandering subordinate movement. |
| `test/scenarios/general/che_귀환.test.ts` | kept | contract | Capital/officer-city return effects and named location constraints. |
| `test/scenarios/general/che_등용수락.test.ts` | kept | contract | Neutral acceptance, betrayal resource/penalty effects, and monarch/same-nation denials. |
| `test/scenarios/general/che_이동.test.ts` | kept | contract | Exact movement, gold, morale, experience, and leadership-experience effects. |
| `test/scenarios/general/migratedGeneralCommands.test.ts` | kept | contract | Focused state transitions for mutiny, abdication, gift, disband, founding, and duplicate equipment purchase. |
| `test/scenarios/general_commands_new.test.ts` | kept | smoke | Multi-command resolver workflow for procurement/donation/status and employment/sabotage families, including exact one-use strategy-item consumption. Other broad assertions remain workflow smoke only. |
| `test/scenarios/reservedTurnExecution.test.ts` | kept | smoke | Multiple generals consume queued commands over three ticks and produce representative city/general changes. |
| `test/scenarios/troops.test.ts` | corrected | contract | Draft, train, and morale workflow with exact crew, training, and morale results through three commands. |
| `test/specialActions.test.ts` | kept | contract | Trait loading plus exact domestic/stat/heal/battle modifiers. |
| `test/uniqueLottery.test.ts` | kept | contract | Fixed-seed item, eligible acquisition types, founding guarantee, and occupied-item counting. |
| `test/warAftermath.test.ts` | kept | contract | Exact tech/death deltas and conquest/collapse resource/general/city effects. |
| `test/warEngine.test.ts` | kept | contract | Critical trigger multiplier, no-rice defender rout, and exact multi-use/one-use battle-item charge persistence. Detailed phase/RNG parity is differential-test scope. |
| `test/world/distance.test.ts` | kept | contract | Exact shortest distances, range search, and unreachable city handling. |
| `test/worldBootstrap.test.ts` | kept | contract | Scenario/map conversion into exact snapshot and seed fields. |
### `tools/integration-tests`
| Test source | Disposition | Layer | What it establishes |
| --------------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/auctionFlow.test.ts` | kept | integration | Real API/PostgreSQL/Redis/daemon resource and unique-auction bidding, extension, finalization, payout, and ownership constraints. |
| `test/battleDifferential.test.ts` | kept | compatibility | PHP reference metadata and attacker/defender comparisons for all 145 scenario items, plus event order, RNG results, phase values, multi-defender, siege, and no-defender branches. The two exhaustive item cases currently fail on 13 attacker and 2 defender items; this is active compatibility evidence, not a green regression. |
| `test/initialization.test.ts` | kept | integration | Real gateway/game APIs, database/Redis reset, scenario seed, users, joins, turns, and founding state. |
| `test/orchestrator.e2e.test.ts` | kept | integration | Real PM2 game API/daemon startup, command serving, SSE completion, and persisted world update. |
## Shared test support
The following support files were also reviewed because failures in them can
invalidate many suites:
| Support source | Result |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/logic/test/testEnv.ts` | corrected: replaced `Math.random()` with a complete fixed-seed `RandUtil`; missing general IDs now fail setup instead of silently skipping a command. |
| `packages/logic/test/fixtures/minimalMap.ts` | kept: small connected/unreachable map fixture used by focused command tests. |
| `app/game-engine/test/fixtures/largeTestMap.ts` | kept: broad NPC smoke fixture; not a legacy map snapshot. |
| `app/game-engine/test/helpers/turnTestHarness.ts` | kept: engine smoke harness; assertions remain in individual suites. |
## Trust boundary after this audit
This audit establishes that every executable test has a stated, non-empty
purpose and that broad simulations are labeled as smoke rather than silently
treated as exact compatibility proof. It does not promote all product behavior
to legacy-compatible:
- battle compatibility is supported only by the differential fixtures and the
suites explicitly marked `compatibility`;
- broad NPC and multi-command scenarios guard liveness/invariants, not exact
monthly balance or complete side effects;
- DB/Redis/PM2 claims require their integration suites to run rather than skip;
- changing an implementation and observing a green unit suite is still not a
substitute for a new PHP trace when compatibility-sensitive behavior changes.
+7
View File
@@ -39,6 +39,13 @@ An audit must confirm that a test:
Until this audit is recorded, test counts and green status are inventory Until this audit is recorded, test counts and green status are inventory
information only, not evidence of correctness. information only, not evidence of correctness.
The executable TypeScript suite was reviewed on 2026-07-25. Its per-suite
disposition, intended scope, and evidence limits are recorded in
[`test-suite-audit.md`](./test-suite-audit.md). A `kept` or `corrected`
disposition means the test has useful regression value at the recorded layer;
it does not promote smoke or contract coverage into legacy-compatibility
evidence. New suites and material scope changes must update that registry.
## Core Principles ## Core Principles
- Prefer Repository/DB Port interfaces over ORM mocks; split implementations: - Prefer Repository/DB Port interfaces over ORM mocks; split implementations:
-2
View File
@@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=clock.test.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"clock.test.d.ts","sourceRoot":"","sources":["clock.test.ts"],"names":[],"mappings":""}
-44
View File
@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ManualClock, StepClock } from '../src/time/Clock.js';
describe('ManualClock', () => {
it('returns current time without advancing', () => {
const clock = new ManualClock(1000);
expect(clock.nowMs()).toBe(1000);
expect(clock.nowMs()).toBe(1000);
});
it('advances with sleep and manual advance', async () => {
const clock = new ManualClock(0);
await clock.sleepMs(250);
expect(clock.nowMs()).toBe(250);
clock.advanceMs(750);
expect(clock.nowMs()).toBe(1000);
});
it('can set time explicitly', () => {
const clock = new ManualClock(10);
clock.setMs(5000);
expect(clock.nowMs()).toBe(5000);
});
});
describe('StepClock', () => {
it('advances on each nowMs call', () => {
const clock = new StepClock(100, 0);
expect(clock.nowMs()).toBe(100);
expect(clock.nowMs()).toBe(200);
expect(clock.nowMs()).toBe(300);
});
it('advances with sleep', async () => {
const clock = new StepClock(50, 1000);
await clock.sleepMs(200);
expect(clock.nowMs()).toBe(1250);
});
it('advances manually', () => {
const clock = new StepClock(10, 0);
clock.advanceMs(50);
expect(clock.nowMs()).toBe(60);
});
it('rejects non-positive step', () => {
expect(() => new StepClock(0)).toThrow('stepMs must be positive');
expect(() => new StepClock(-5)).toThrow('stepMs must be positive');
});
});
//# sourceMappingURL=clock.test.js.map
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=rng.test.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"rng.test.d.ts","sourceRoot":"","sources":["rng.test.ts"],"names":[],"mappings":""}
-317
View File
@@ -1,317 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
bufferByteSize,
convertBytesLikeToArrayBuffer,
convertBytesLikeToUint8Array as toBytes,
LiteHashDRBG,
RandUtil,
} from '../src/index.js';
const range = (count) => Array.from({ length: count }, (_, idx) => idx);
const expectBytes = (actual, expected) => {
expect(Array.from(actual)).toEqual(Array.from(expected));
};
function fillBlock(body, filler = '\0', length = bufferByteSize) {
const u8Body = toBytes(body);
const u8Filler = toBytes(filler, false);
if (u8Filler.byteLength < 1) {
throw new Error('filler must have length');
}
const buffer = new Uint8Array(length);
buffer.set(u8Body, 0);
let bufferIdx = u8Body.byteLength;
while (bufferIdx + u8Filler.byteLength < length) {
buffer.set(u8Filler, bufferIdx);
bufferIdx += u8Filler.byteLength;
}
if (bufferIdx < length) {
const slice = new Uint8Array(u8Filler.buffer, u8Filler.byteOffset, length - bufferIdx);
buffer.set(slice, bufferIdx);
}
return buffer;
}
class DummyBlockRNG extends LiteHashDRBG {
repeatBlockCnt;
repeatBlock;
constructor(repeatBlock, stateIdx = 0) {
super('x');
this.repeatBlock = [];
for (const rawBlock of repeatBlock) {
const block = convertBytesLikeToArrayBuffer(rawBlock);
if (block.byteLength !== bufferByteSize) {
throw new Error('invalid block size');
}
this.repeatBlock.push(block);
}
this.repeatBlockCnt = this.repeatBlock.length;
this.stateIdx = stateIdx;
this.bufferIdx = 0;
this.genNextBlock();
}
genNextBlock() {
if (!this.repeatBlock) {
return;
}
this.buffer = this.repeatBlock[this.stateIdx];
this.bufferIdx = 0;
this.stateIdx = (this.stateIdx + 1) % this.repeatBlockCnt;
}
}
const fixedKey = 'HelloWorld';
describe('RNGtestDummy', () => {
const rng = new DummyBlockRNG([fillBlock('', '\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff')]);
it('BasicConvert', () => {
expect(toBytes('\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff', false).length).toBe(16);
});
it('SimpleByte', () => {
expectBytes(toBytes('\x00', false), rng.nextBytes(1));
expectBytes(toBytes('\x11\x22', false), rng.nextBytes(2));
expectBytes(toBytes('\x33\x44\x55', false), rng.nextBytes(3));
expectBytes(toBytes('\x66\x77\x88\x99', false), rng.nextBytes(4));
});
it('OverflowBlock', () => {
for (let idx = 0; idx < 16; idx += 1) {
expectBytes(
toBytes('\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99', false),
rng.nextBytes(16)
);
}
});
it('MultiBlock', () => {
expectBytes(
fillBlock('', '\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99', bufferByteSize * 2),
rng.nextBytes(bufferByteSize * 2)
);
});
it('bitTest', () => {
expectBytes(toBytes('\x00', false), rng.nextBits(1)); //aa
expectBytes(toBytes('\x01', false), rng.nextBits(1)); //bb
expectBytes(toBytes('\xcc', false), rng.nextBits(8)); //cc
expectBytes(toBytes('\xdd\x02', false), rng.nextBits(10)); //ddee
expectBytes(toBytes('\x7f', false), rng.nextBits(7)); //ff
expectBytes(toBytes('\x00\x11\x22\x33\x44\x55\x06', false), rng.nextBits(53));
});
it('int', () => {
expect(rng.nextInt(0xff)).toBe(0x77);
expect(rng.nextInt((1 << 16) - 1)).toBe(0x9988);
expect(rng.nextInt(0xffffffff)).toBe(0xddccbbaa);
expect(rng.nextInt()).toBe(0x0433221100ffee);
expect(rng.nextInt(0x0f)).toBe(0x05); //55
expect(rng.nextInt(0x12)).toBe(0x06); //66
expect(rng.nextInt(99)).toBe(0x08); //77(119 -> 7bit) -> 88(136 -> 8bit -> 8)
expect(rng.nextInt(0x99)).toBe(0x99); //99
expect(rng.nextInt(0xaa)).toBe(0xaa); //aa (fit Max)
});
it('float', () => {
const floatMax = 2 ** 53;
const fa = rng.nextFloat1();
expect(fa).toBe(0x1100ffeeddccbb / floatMax);
expect(0.5313720384 > fa).toBe(true);
expect(0.5313720383 < fa).toBe(true);
const fb = rng.nextFloat1();
expect(fb).toBe(0x08776655443322 / floatMax);
});
});
describe('RandUtilDummy', () => {
it('shuffle', () => {
const rng = new DummyBlockRNG([fillBlock('', '\x17\x16\x15\x14\x13\x12\x11\x10')]);
const randUtil = new RandUtil(rng);
/**
* 7, [7,1,2,3,4,5,6,0]
* 6, [7,0,2,3,4,5,6,1]
* 5, [7,0,1,3,4,5,6,2]
* 4, [7,0,1,2,4,5,6,3]
* 3, [7,0,1,2,3,5,6,4]
* 2, [7,0,1,2,3,4,6,5]
* 1, [7,0,1,2,3,4,5,6]
*/
expect(randUtil.shuffle(range(8))).toEqual([7, 0, 1, 2, 3, 4, 5, 6]);
/**
* 0, [0,1,2,3,4,5,6,7,8,9]
* 7, [0,8,2,3,4,5,6,7,1,9]
* 6, [0,8,1,3,4,5,6,7,2,9]
* 5, [0,8,1,2,4,5,6,7,3,9]
* 4, [0,8,1,2,3,5,6,7,4,9]
* 3, [0,8,1,2,3,4,6,7,5,9]
* 2, [0,8,1,2,3,4,5,7,6,9]
* 1, [0,8,1,2,3,4,5,6,7,9]
* 0, [0,8,1,2,3,4,5,6,7,9]
*/
expect(randUtil.shuffle(range(10))).toEqual([0, 8, 1, 2, 3, 4, 5, 6, 7, 9]);
});
const rng = new DummyBlockRNG([fillBlock('', '\x17\x16\x15\x14\x13\x12\x11\x10')]);
const randUtil = new RandUtil(rng);
it('choice', () => {
//0x17(7), 0x16(6)
expect(randUtil.choice([0, 1, 2, 3, 4, 5])).toBe(5);
//0x15(5), Set 순서 유지
expect(randUtil.choice(new Set([5, 3, 1, 2, 8, 0]))).toBe(8);
//0x14(4), 정렬 순서상 숫자(소-대) > 문자열(삽입순) > 심볼 순서
expect(randUtil.choice({ c: 'c', a: 'a', b: 'b', 4: 'x', 2: 't', 3: 'q' })).toBe('c');
});
it('choiceUsingWeight', () => {
//0.6275740099377194 * 38.1 = 23.91
expect(
randUtil.choiceUsingWeight({
a: 0.1,
b: 10,
tt: 2,
x: -1,
c: 20,
d: 0,
e: 6,
})
).toBe('c');
//0.658946544056166
expect(randUtil.choiceUsingWeightPair([['xx', 10]])).toBe('xx');
//0.6903152783785083 * 27.3 = 18.84560709973328
expect(
randUtil.choiceUsingWeightPair([
['e', 10],
['d', 4],
['c', 0.1],
['baba', 0.2],
['q', 9],
['xt', 4],
])
).toBe('q');
});
});
describe('RNGexpectedError', () => {
const rng = new LiteHashDRBG(fixedKey);
it('nextBits0', () => {
expect(() => rng.nextBits(0)).toThrow();
});
it('nextBits-1', () => {
expect(() => rng.nextBits(-1)).toThrow();
});
it('nextBytes0', () => {
expect(() => rng.nextBytes(0)).toThrow();
});
it('nextBytes-1', () => {
expect(() => rng.nextBytes(-1)).toThrow();
});
const randUtil = new RandUtil(rng);
it('utilEmptyChoice', () => {
expect(() => randUtil.choice([])).toThrow();
});
it('utilEmptyChoiceUsingWeight', () => {
expect(() => randUtil.choiceUsingWeight({})).toThrow();
});
it('utilEmptyChoiceUsingWeightPair', () => {
expect(() => randUtil.choiceUsingWeightPair([])).toThrow();
});
});
describe('RNGAcceptable', () => {
const rng = new LiteHashDRBG(fixedKey);
it('RNG', () => {
rng.nextInt(0);
rng.nextInt(2 ** 53 - 1);
rng.nextBytes(65);
rng.nextBits(512);
});
const randUtil = new RandUtil(rng);
it('RandUtil', () => {
randUtil.choice([0, 0, 0]);
randUtil.choiceUsingWeight({
0: 0,
1: -1,
});
randUtil.choiceUsingWeightPair([
[0, 0],
[1, 0],
[2, -2],
]);
randUtil.nextBool(1.1);
randUtil.nextBool(-0.1);
randUtil.shuffle([]);
randUtil.shuffle([1]);
randUtil.nextRange(0, 0);
randUtil.nextRangeInt(0, 0);
randUtil.nextRange(1, -1);
randUtil.nextRangeInt(1, -1);
});
it('RNGLong', () => {
const longKey = fixedKey;
for (let idx = 0; idx < 8; idx += 1) {
longKey.concat(longKey);
}
const rngLong = new LiteHashDRBG(longKey);
for (let idx = 0; idx < 10; idx += 1) {
rngLong.nextBytes(16);
}
});
});
/* Python TestVector
import hashlib
import struct
fixedKey = 'HelloWorld'.encode('utf-8')
def hash(key, idx):
idxV = struct.pack("<I", idx)
return hashlib.sha512(key + idxV).digest()
for idx in range(5):
print(hash(fixedKey, idx).hex())
*/
describe('RNG', () => {
//JS - PHP 일치 확인 정도로.
const testVector = Buffer.from(
[
'24d9ccd648556255fd0ee9f5b29918de90617341958b3b354d572167e4dee02b757816a2bbe0b502c52413ffd384381a9d7b4e193df6f4345d6a95e111d661c4',
'2e9264512f6f4b080cf1376b74fab6878ecf4a6e185942d2e5b22cf923885b9952d40601a414225d6901417fd4ce9368ac77e4a63d3fc9b58ab952bb8c33f165',
'8e2ebf5af6283a1b18f4c044c86c20d02be3890613c4cc8b7c6b7b35581263b972a82630df69a9289988422d7c3a9be5edf78d5de16fabd01e5dd4e458068d8a',
'398596047ba547bfe371ec863a3e019ab0dbc4bb3b27e9077685aae4283ff6bbccfd981d92f9358f7efffbb72a940414802d98466d132e2ad0a16a12946d5f47',
'b3606fe9b18c4aa7315e78bb9e47cb51cc4e203fcc2e631f0405c1b872c8e1cb5b6415ea74bbb77fffaaadb002b47cb4f4628dc0709634365b187667f5c708cb',
].join(''),
'hex'
);
it('bytes', () => {
const rng = new LiteHashDRBG(fixedKey);
let offset = 0;
expectBytes(rng.nextBytes(10), testVector.slice(offset, offset + 10));
offset += 10;
expectBytes(rng.nextBytes(32), testVector.slice(offset, offset + 32));
offset += 32;
expectBytes(rng.nextBytes(1), testVector.slice(offset, offset + 1));
offset += 1;
expectBytes(rng.nextBytes(64), testVector.slice(offset, offset + 64));
offset += 64;
expectBytes(rng.nextBytes(5), testVector.slice(offset, offset + 5));
offset += 5;
const lastA = rng.nextBytes(16, 18);
const lastB = new Uint8Array(18);
lastB.set(testVector.slice(offset, offset + 16));
expectBytes(lastA, lastB);
});
it('bits', () => {
const rng = new LiteHashDRBG(fixedKey);
let offset = 0;
const testBits = [10, 4, 15, 32, 7, 99, 512, 1, 2, 3];
for (const bits of testBits) {
const bytes = Math.ceil(bits / 8);
const A = rng.nextBits(bits);
const B = new Uint8Array(testVector.slice(offset, offset + bytes));
offset += bytes;
if (bits % 8 !== 0) {
const bitMask = 0xff >> (8 - (bits % 8));
B[bytes - 1] &= bitMask;
}
expectBytes(A, B);
}
});
it('float', () => {
const rng = new LiteHashDRBG(fixedKey);
const rng2 = new DummyBlockRNG([
new Uint8Array(testVector.slice(bufferByteSize * 0, bufferByteSize * 1)),
new Uint8Array(testVector.slice(bufferByteSize * 1, bufferByteSize * 2)),
new Uint8Array(testVector.slice(bufferByteSize * 2, bufferByteSize * 3)),
new Uint8Array(testVector.slice(bufferByteSize * 3, bufferByteSize * 4)),
new Uint8Array(testVector.slice(bufferByteSize * 4, bufferByteSize * 5)),
]);
for (let i = 0; i < 18; i++) {
expect(rng.nextFloat1()).toBe(rng2.nextFloat1());
}
});
});
//# sourceMappingURL=rng.test.js.map
File diff suppressed because one or more lines are too long
+39 -33
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
bufferByteSize, bufferByteSize,
ConstantRNG,
convertBytesLikeToArrayBuffer, convertBytesLikeToArrayBuffer,
convertBytesLikeToUint8Array as toBytes, convertBytesLikeToUint8Array as toBytes,
LiteHashDRBG, LiteHashDRBG,
@@ -238,46 +239,51 @@ describe('RNGexpectedError', () => {
}); });
}); });
describe('RNGAcceptable', () => { describe('RNG boundary behavior', () => {
const rng = new LiteHashDRBG(fixedKey); it('returns values at supported zero/max/large-buffer boundaries', () => {
it('RNG', () => { const rng = new LiteHashDRBG(fixedKey);
rng.nextInt(0);
rng.nextInt(2 ** 53 - 1); expect(rng.nextInt(0)).toBe(0);
rng.nextBytes(65); expect(rng.nextInt(2 ** 53 - 1)).toBe(656_721_283_176_740);
rng.nextBits(512); expect(rng.nextBytes(65)).toHaveLength(65);
expect(rng.nextBits(512)).toHaveLength(64);
}); });
const randUtil = new RandUtil(rng); it('defines deterministic behavior for degenerate utility inputs', () => {
it('RandUtil', () => { const minRand = new RandUtil(new ConstantRNG(0));
randUtil.choice([0, 0, 0]); const maxRand = new RandUtil(new ConstantRNG(1));
randUtil.choiceUsingWeight({
0: 0, expect(minRand.choice([0, 0, 0])).toBe(0);
1: -1, expect(minRand.choiceUsingWeight({ 0: 0, 1: -1 })).toBe('0');
}); expect(
randUtil.choiceUsingWeightPair([ minRand.choiceUsingWeightPair([
[0, 0], [0, 0],
[1, 0], [1, 0],
[2, -2], [2, -2],
]); ])
randUtil.nextBool(1.1); ).toBe(0);
randUtil.nextBool(-0.1); expect(minRand.nextBool(1.1)).toBe(true);
randUtil.shuffle([]); expect(minRand.nextBool(-0.1)).toBe(false);
randUtil.shuffle([1]); expect(minRand.shuffle([])).toEqual([]);
randUtil.nextRange(0, 0); expect(minRand.shuffle([1])).toEqual([1]);
randUtil.nextRangeInt(0, 0); expect(minRand.nextRange(0, 0)).toBe(0);
randUtil.nextRange(1, -1); expect(minRand.nextRangeInt(0, 0)).toBe(0);
randUtil.nextRangeInt(1, -1); expect(minRand.nextRange(1, -1)).toBe(1);
expect(minRand.nextRangeInt(1, -1)).toBe(1);
expect(maxRand.nextRange(1, -1)).toBe(-1);
expect(maxRand.nextRangeInt(1, -1)).toBe(-1);
}); });
it('RNGLong', () => { it('hashes the complete expanded long seed', () => {
const longKey = fixedKey; let longKey = fixedKey;
for (let idx = 0; idx < 8; idx += 1) { for (let idx = 0; idx < 8; idx += 1) {
longKey.concat(longKey); longKey += longKey;
} }
const rngLong = new LiteHashDRBG(longKey); const rngLong = new LiteHashDRBG(longKey);
for (let idx = 0; idx < 10; idx += 1) { expect(Buffer.from(rngLong.nextBytes(32)).toString('hex')).toBe(
rngLong.nextBytes(16); '7e44b8180c0d254759a70aee891a5f6ba45c8016e02cb8ee51f0b22e600a4dd1'
} );
}); });
}); });
-2
View File
@@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=test-rngs.test.d.ts.map
@@ -1 +0,0 @@
{"version":3,"file":"test-rngs.test.d.ts","sourceRoot":"","sources":["test-rngs.test.ts"],"names":[],"mappings":""}
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ConstantRNG, MidpointRNG, SequenceRNG, SineRNG } from '../src/index.js';
const toArray = (bytes) => Array.from(bytes);
describe('TestRNG:Constant', () => {
it('returns fixed 0', () => {
const rng = new ConstantRNG(0);
expect(rng.nextFloat1()).toBe(0);
expect(rng.nextInt(10)).toBe(0);
expect(toArray(rng.nextBytes(3))).toEqual([0, 0, 0]);
expect(toArray(rng.nextBits(3))).toEqual([0]);
});
it('returns fixed 1', () => {
const rng = new ConstantRNG(1);
expect(rng.nextFloat1()).toBe(1);
expect(rng.nextInt(10)).toBe(10);
expect(toArray(rng.nextBytes(2))).toEqual([255, 255]);
expect(toArray(rng.nextBits(3))).toEqual([7]);
});
});
describe('TestRNG:Midpoint', () => {
it('returns midpoint for int/float', () => {
const rng = new MidpointRNG();
expect(rng.nextFloat1()).toBe(0.5);
expect(rng.nextInt(9)).toBe(4);
expect(rng.nextInt(10)).toBe(5);
});
it('alternates bits', () => {
const rng = new MidpointRNG();
expect(toArray(rng.nextBits(4))).toEqual([10]);
expect(toArray(rng.nextBits(4))).toEqual([10]);
});
});
describe('TestRNG:Sine', () => {
it('follows sine wave with period/amplitude', () => {
const rng = new SineRNG(4, 0.5, 0);
expect(rng.nextFloat1()).toBeCloseTo(0.5, 8);
expect(rng.nextFloat1()).toBeCloseTo(1, 8);
expect(rng.nextFloat1()).toBeCloseTo(0.5, 8);
expect(rng.nextFloat1()).toBeCloseTo(0, 8);
});
it('maps float to int range', () => {
const rng = new SineRNG(4, 0.5, 0);
expect(rng.nextInt(9)).toBe(5);
expect(rng.nextInt(9)).toBe(9);
});
});
describe('TestRNG:Sequence', () => {
it('cycles fixed sequence', () => {
const rng = new SequenceRNG([0, 0.25, 0.5, 0.75, 1]);
expect(rng.nextFloat1()).toBe(0);
expect(rng.nextFloat1()).toBe(0.25);
expect(rng.nextFloat1()).toBe(0.5);
expect(rng.nextFloat1()).toBe(0.75);
expect(rng.nextFloat1()).toBe(1);
expect(rng.nextFloat1()).toBe(0);
});
it('converts sequence to bytes and ints', () => {
const rng = new SequenceRNG([0, 0.5, 1]);
expect(toArray(rng.nextBytes(3))).toEqual([0, 128, 255]);
expect(rng.nextInt(8)).toBe(0);
expect(rng.nextInt(8)).toBe(4);
expect(rng.nextInt(8)).toBe(8);
});
});
//# sourceMappingURL=test-rngs.test.js.map
File diff suppressed because one or more lines are too long
@@ -225,7 +225,7 @@ describe('Nation Actions', () => {
}); });
describe('che_필사즉생 (Last Stand)', () => { describe('che_필사즉생 (Last Stand)', () => {
it('increases experience and sets strategic limit', () => { it('applies the legacy three-turn gains and global delay', () => {
const nation = buildNation(1); const nation = buildNation(1);
const general = buildGeneral(1, 1, 1); const general = buildGeneral(1, 1, 1);
const definition = new LastStandAction([]); const definition = new LastStandAction([]);
@@ -240,8 +240,11 @@ describe('Nation Actions', () => {
definition.resolve(context as any, {}); definition.resolve(context as any, {});
expect(general.experience).toBeGreaterThan(100); expect(general.experience).toBe(115);
expect(nation.meta.strategic_cmd_limit).toBeGreaterThan(0); expect(general.dedication).toBe(115);
expect(general.train).toBe(100);
expect(general.atmos).toBe(100);
expect(nation.meta.strategic_cmd_limit).toBe(9);
}); });
}); });
+2 -2
View File
@@ -9,7 +9,7 @@ import { parseScenarioDefaults, parseScenarioDefinition } from '../src/scenario/
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..', '..', '..'); const repoRoot = path.resolve(__dirname, '..', '..', '..');
const scenarioRoot = path.join(repoRoot, 'legacy', 'hwe', 'scenario'); const scenarioRoot = path.join(repoRoot, 'resources', 'scenario');
const readJson = async (filePath: string): Promise<unknown> => { const readJson = async (filePath: string): Promise<unknown> => {
const raw = await fs.readFile(filePath, 'utf8'); const raw = await fs.readFile(filePath, 'utf8');
@@ -17,7 +17,7 @@ const readJson = async (filePath: string): Promise<unknown> => {
}; };
describe('scenario parser', () => { describe('scenario parser', () => {
it('reads defaults from legacy default.json', async () => { it('reads defaults from the tracked legacy-format default.json', async () => {
const defaultsRaw = await readJson(path.join(scenarioRoot, 'default.json')); const defaultsRaw = await readJson(path.join(scenarioRoot, 'default.json'));
const defaults = parseScenarioDefaults(defaultsRaw); const defaults = parseScenarioDefaults(defaultsRaw);
+112 -60
View File
@@ -7,10 +7,11 @@ import { commandSpec as declareWarSpec } from '../../src/actions/turn/nation/che
import { commandSpec as deploySpec } from '../../src/actions/turn/general/che_출병.js'; import { commandSpec as deploySpec } from '../../src/actions/turn/general/che_출병.js';
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js'; import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
import { processDiplomacyMonth, DIPLOMACY_STATE, type DiplomacyEntry } from '../../src/diplomacy/index.js'; import { processDiplomacyMonth, DIPLOMACY_STATE, type DiplomacyEntry } from '../../src/diplomacy/index.js';
import { buildWarConfig, buildWarAftermathConfig } from '../../src/actions/turn/actionContextHelpers.js'; import { evaluateConstraints } from '../../src/constraints/evaluate.js';
import type { ConstraintContext, RequirementKey, StateView } from '../../src/constraints/types.js';
describe('Diplomacy Scenario', () => { describe('Diplomacy Scenario', () => {
it('should handle War Declaration and prevent/allow deployment accordingly', async () => { it('changes deployment from denied to allowed when a declaration becomes war', async () => {
// 1. Setup World // 1. Setup World
const NATION_A_ID = 1; const NATION_A_ID = 1;
const NATION_B_ID = 2; // Target nation const NATION_B_ID = 2; // Target nation
@@ -134,8 +135,49 @@ describe('Diplomacy Scenario', () => {
unitSet: { unitSet: {
id: 'default', id: 'default',
name: 'default', name: 'default',
crewTypes: [{ id: 1, name: 'Infantry', armType: 1, cost: 10, requirements: [] }], defaultCrewTypeId: 1,
} as any, armTypes: { 0: '성벽', 1: '보병' },
crewTypes: [
{
id: 0,
name: '성벽',
armType: 0,
attack: 0,
defence: 100,
speed: 1,
avoid: 0,
magicCoef: 0,
cost: 0,
rice: 0,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
{
id: 1,
name: '보병',
armType: 1,
attack: 100,
defence: 100,
speed: 5,
avoid: 0,
magicCoef: 0,
cost: 10,
rice: 1,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
},
nations: [mockNationA, mockNationB], nations: [mockNationA, mockNationB],
cities: [mockCityA, mockCityB], cities: [mockCityA, mockCityB],
generals: [mockGeneralA], generals: [mockGeneralA],
@@ -191,55 +233,71 @@ describe('Diplomacy Scenario', () => {
expect(diplomacyAB).toBeDefined(); expect(diplomacyAB).toBeDefined();
expect(diplomacyAB?.state).toBe(DIPLOMACY_STATE.DECLARATION); expect(diplomacyAB?.state).toBe(DIPLOMACY_STATE.DECLARATION);
// Pre-calculate context data needed for Dispatch const dispatchArgs = { destCityId: 2 };
const warConfig = buildWarConfig(snapshot.scenarioConfig, snapshot.unitSet!);
const aftermathConfig = buildWarAftermathConfig(snapshot.scenarioConfig, warConfig.castleCrewTypeId);
const warTime = {
year: 200,
month: 1,
season: 0,
turn: 1,
};
const seedBase = 'test_seed';
const buildDispatchContext = (destCityId: number) => {
const destCity = world.getCity(destCityId)!;
const destNation = world.getNation(destCity.nationId);
warTime.year = runner.currentDate.getFullYear();
warTime.month = runner.currentDate.getMonth() + 1;
warTime.season = Math.floor(runner.currentDate.getMonth() / 3);
const buildConstraintView = (): StateView => {
const get = (requirement: RequirementKey): unknown | null => {
switch (requirement.kind) {
case 'general':
case 'destGeneral':
return world.getGeneral(requirement.id) ?? null;
case 'generalList':
return world.getAllGenerals();
case 'city':
case 'destCity':
return world.getCity(requirement.id) ?? null;
case 'nation':
case 'destNation':
return world.getNation(requirement.id) ?? null;
case 'nationList':
return world.getAllNations();
case 'diplomacy':
return world.getDiplomacy(requirement.srcNationId, requirement.destNationId) ?? null;
case 'diplomacyList':
return world.snapshot.diplomacy;
case 'arg':
return dispatchArgs[requirement.key as keyof typeof dispatchArgs] ?? null;
case 'env':
if (requirement.key === 'map') return world.snapshot.map;
if (requirement.key === 'cities') return world.getAllCities();
if (requirement.key === 'nations') return world.getAllNations();
if (requirement.key === 'relYear') return 200;
if (requirement.key === 'openingPartYear') return 200;
return null;
}
};
return { return {
destCity, has: (requirement) => get(requirement) !== null,
destNation, get,
warConfig,
aftermathConfig,
time: { ...warTime },
seedBase,
map: world.snapshot.map,
unitSet: world.snapshot.unitSet,
cities: world.getAllCities(),
nations: world.getAllNations(),
generals: world.getAllGenerals(),
diplomacy: world.snapshot.diplomacy,
}; };
}; };
const buildDispatchConstraintContext = (): ConstraintContext => ({
// 3. Try to Deploy actorId: mockGeneralA.id,
const deployDef = deploySpec.createDefinition(systemEnv); cityId: mockGeneralA.cityId,
await runner.runTurn([ nationId: mockGeneralA.nationId,
{ destCityId: dispatchArgs.destCityId,
generalId: 1, destNationId: NATION_B_ID,
commandKey: 'che_출병', args: dispatchArgs,
resolver: deployDef, env: {
args: { destCityId: 2 }, relYear: 200,
context: buildDispatchContext(2), openingPartYear: 200,
}, },
]); mode: 'full',
});
expect(world.snapshot.troops.length).toBe(0); // 3. A declaration is not yet a war, so deployment must be rejected.
const deployDef = deploySpec.createDefinition(systemEnv);
const declarationResult = evaluateConstraints(
deployDef.buildConstraints(buildDispatchConstraintContext(), dispatchArgs),
buildDispatchConstraintContext(),
buildConstraintView()
);
expect(declarationResult).toMatchObject({
kind: 'deny',
constraintName: 'hasRouteWithEnemy',
reason: '교전중인 국가가 아닙니다.',
});
expect(world.getGeneral(1)?.experience).toBe(100);
// 4. Simulate State Transition // 4. Simulate State Transition
for (let i = 0; i < 24; i++) { for (let i = 0; i < 24; i++) {
@@ -267,18 +325,12 @@ describe('Diplomacy Scenario', () => {
const diplomacyAB_After = world.getDiplomacy(NATION_A_ID, NATION_B_ID); const diplomacyAB_After = world.getDiplomacy(NATION_A_ID, NATION_B_ID);
expect(diplomacyAB_After?.state).toBe(DIPLOMACY_STATE.WAR); expect(diplomacyAB_After?.state).toBe(DIPLOMACY_STATE.WAR);
// 5. Try to Deploy (Should Succeed) // 5. The same command becomes valid after the declaration reaches WAR.
await runner.runTurn([ const warResult = evaluateConstraints(
{ deployDef.buildConstraints(buildDispatchConstraintContext(), dispatchArgs),
generalId: 1, buildDispatchConstraintContext(),
commandKey: 'che_출병', buildConstraintView()
resolver: deployDef, );
args: { destCityId: 2 }, expect(warResult).toEqual({ kind: 'allow' });
context: buildDispatchContext(2),
},
]);
const generalAfter = world.getGeneral(1)!;
console.log(`General Exp: ${mockGeneralA.experience} -> ${generalAfter.experience}`);
}); });
}); });
@@ -141,8 +141,7 @@ describe('Domestic Affairs Scenario', () => {
// 4. Verify // 4. Verify
const updatedCity = world.getCity(1)!; const updatedCity = world.getCity(1)!;
console.log(`Agriculture: 500 -> ${updatedCity.agriculture}`); expect(updatedCity.agriculture).toBe(600);
expect(updatedCity.agriculture).toBeGreaterThan(500);
}); });
it('should not increase agriculture when city is already maxed', async () => { it('should not increase agriculture when city is already maxed', async () => {
@@ -224,10 +224,18 @@ describe('che_NPC능동', () => {
).rejects.toThrow('NPC가 아닙니다.'); ).rejects.toThrow('NPC가 아닙니다.');
}); });
it('should fail if args are invalid', async () => { it('rejects structurally invalid command arguments', async () => {
// Arg validation happens in parseArgs or resolver check. const definition = (
// Simulating invalid args via direct usage of resolver might bypass parseArgs if using `resolver` object directly with raw args? await import('../../../src/actions/turn/general/che_NPC능동.js')
// No, verify it returns null or throws. ).commandSpec.createDefinition({} as TurnCommandEnv);
// Here we just skip for brevity or setup minimal test.
expect(definition.parseArgs(null)).toBeNull();
expect(definition.parseArgs({ optionText: '순간이동' })).toBeNull();
expect(definition.parseArgs({ optionText: '순간이동', destCityId: '101' })).toBeNull();
expect(definition.parseArgs({ optionText: '알수없음', destCityId: 101 })).toBeNull();
expect(definition.parseArgs({ optionText: '순간이동', destCityId: 101 })).toEqual({
optionText: '순간이동',
destCityId: 101,
});
}); });
}); });
+2 -5
View File
@@ -144,7 +144,6 @@ describe('Troop Management Scenario', () => {
]); ]);
const generalAfterDraft = world.getGeneral(1)!; const generalAfterDraft = world.getGeneral(1)!;
console.log(`Drafted: ${generalAfterDraft.crew}`);
expect(generalAfterDraft.crew).toBe(1000); expect(generalAfterDraft.crew).toBe(1000);
// 3. Train // 3. Train
@@ -159,8 +158,7 @@ describe('Troop Management Scenario', () => {
]); ]);
const generalAfterTrain = world.getGeneral(1)!; const generalAfterTrain = world.getGeneral(1)!;
console.log(`Train: 10 -> ${generalAfterTrain.train}`); expect(generalAfterTrain.train).toBe(75);
expect(generalAfterTrain.train).toBeGreaterThan(10);
// 4. Boost Morale // 4. Boost Morale
const atmosDef = atmosSpec.createDefinition(systemEnv); const atmosDef = atmosSpec.createDefinition(systemEnv);
@@ -174,7 +172,6 @@ describe('Troop Management Scenario', () => {
]); ]);
const generalAfterAtmos = world.getGeneral(1)!; const generalAfterAtmos = world.getGeneral(1)!;
console.log(`Atmos: 10 -> ${generalAfterAtmos.atmos}`); expect(generalAfterAtmos.atmos).toBe(75);
expect(generalAfterAtmos.atmos).toBeGreaterThan(10);
}); });
}); });
+20 -13
View File
@@ -1,4 +1,5 @@
import { enablePatches } from 'immer'; import { enablePatches } from 'immer';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { City, General, Nation, GeneralId, NationId, CityId } from '../src/domain/entities.js'; import type { City, General, Nation, GeneralId, NationId, CityId } from '../src/domain/entities.js';
import type { WorldSnapshot } from '../src/world/types.js'; import type { WorldSnapshot } from '../src/world/types.js';
import { import {
@@ -176,10 +177,12 @@ export interface TestCommand {
export class TestGameRunner { export class TestGameRunner {
world: InMemoryWorld; world: InMemoryWorld;
currentDate: Date; currentDate: Date;
private readonly seedBase: string;
constructor(world: InMemoryWorld, startYear: number, startMonth: number) { constructor(world: InMemoryWorld, startYear: number, startMonth: number, seedBase = 'test-game-runner') {
this.world = world; this.world = world;
this.currentDate = new Date(startYear, startMonth - 1); this.currentDate = new Date(startYear, startMonth - 1);
this.seedBase = seedBase;
} }
nextGeneralId = 1; nextGeneralId = 1;
@@ -190,29 +193,33 @@ export class TestGameRunner {
entries: [{ startMinute: 0, tickMinutes: 60 }], entries: [{ startMinute: 0, tickMinutes: 60 }],
}; };
for (const cmd of commands) { for (const [commandIndex, cmd] of commands.entries()) {
const general = this.world.getGeneral(cmd.generalId); const general = this.world.getGeneral(cmd.generalId);
if (!general) continue; if (!general) {
throw new Error(`General ${cmd.generalId} does not exist`);
}
const city = this.world.getCity(general.cityId); const city = this.world.getCity(general.cityId);
if (!city) throw new Error(`General ${general.id} is in non-existent city ${general.cityId}`); if (!city) throw new Error(`General ${general.id} is in non-existent city ${general.cityId}`);
const nation = general.nationId ? this.world.getNation(general.nationId) : null; const nation = general.nationId ? this.world.getNation(general.nationId) : null;
const rng = new RandUtil(
LiteHashDRBG.build(
[
this.seedBase,
this.currentDate.toISOString(),
String(commandIndex),
String(general.id),
cmd.commandKey,
].join('|')
)
);
const inputContext = { const inputContext = {
general, general,
city, city,
nation: nation || null, nation: nation || null,
rng: { rng,
real: () => Math.random(),
int: (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min,
nextInt: (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min,
next: () => Math.random(),
nextBool: () => Math.random() < 0.5,
nextRange: (min: number, max: number) => Math.random() * (max - min) + min,
nextRangeInt: (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min,
nextFloat1: () => Math.random(),
} as any,
year: this.currentDate.getFullYear(), year: this.currentDate.getFullYear(),
month: this.currentDate.getMonth() + 1, month: this.currentDate.getMonth() + 1,