test: audit and strengthen legacy test suite
This commit is contained in:
@@ -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', () => {
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|||||||
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
export {};
|
|
||||||
//# sourceMappingURL=clock.test.d.ts.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"clock.test.d.ts","sourceRoot":"","sources":["clock.test.ts"],"names":[],"mappings":""}
|
|
||||||
@@ -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
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
export {};
|
|
||||||
//# sourceMappingURL=rng.test.d.ts.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"rng.test.d.ts","sourceRoot":"","sources":["rng.test.ts"],"names":[],"mappings":""}
|
|
||||||
@@ -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
@@ -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
@@ -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":""}
|
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user