Files
core2026/app/game-api/test/battleSimRouter.test.ts
T

478 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { BattleSimJobPayload, BattleSimResultPayload } from '../src/battleSim/types.js';
import type { BattleSimTransport } from '../src/battleSim/transport.js';
import type { DatabaseClient, GameApiContext, GameProfile, WorldStateRow } from '../src/context.js';
import type { RedisConnector } from '@sammo-ts/infra';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { appRouter } from '../src/router.js';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
class QueuedBattleSimTransport implements BattleSimTransport {
public simulateCalls = 0;
public lastPayload: BattleSimJobPayload | null = null;
public lastRequesterUserId: string | null = null;
private readonly owners = new Map<string, string>();
private readonly results = new Map<string, BattleSimResultPayload>();
async simulate(payload: BattleSimJobPayload, requesterUserId: string) {
this.simulateCalls += 1;
this.lastPayload = payload;
this.lastRequesterUserId = requesterUserId;
const jobId = `job-${this.simulateCalls}`;
this.owners.set(jobId, requesterUserId);
return { status: 'queued', jobId } as const;
}
async getSimulationResult(jobId: string, requesterUserId: string) {
if (this.owners.get(jobId) !== requesterUserId) {
return null;
}
return this.results.get(jobId) ?? null;
}
pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) {
if (this.owners.get(jobId) !== requesterUserId) {
throw new Error('requester mismatch');
}
this.results.set(jobId, payload);
}
}
const buildBattleRequest = () => ({
action: 'battle' as const,
repeatCnt: 1,
year: 200,
month: 1,
seed: 'test-seed',
attackerGeneral: {
no: 1,
name: 'Attacker',
nation: 1,
turntime: '2026-01-01 00:00:00',
personal: null,
special2: null,
crew: 1000,
crewtype: 100,
atmos: 100,
train: 100,
intel: 70,
intel_exp: 0,
book: null,
strength: 70,
strength_exp: 0,
weapon: null,
injury: 0,
leadership: 70,
leadership_exp: 0,
horse: null,
item: null,
explevel: 0,
experience: 100,
dedication: 100,
officer_level: 3,
officer_city: 1,
gold: 1000,
rice: 1000,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
defence_train: 0,
recent_war: null,
warnum: 0,
killnum: 0,
killcrew: 0,
},
attackerCity: {
city: 1,
nation: 1,
supply: 1,
name: 'AttackerCity',
pop: 10000,
agri: 1000,
comm: 1000,
secu: 1000,
def: 100,
wall: 100,
trust: 100,
level: 2,
pop_max: 10000,
agri_max: 1000,
comm_max: 1000,
secu_max: 1000,
def_max: 200,
wall_max: 200,
dead: 0,
state: 0,
conflict: '{}',
},
attackerNation: {
type: 'test',
tech: 1000,
level: 1,
capital: 1,
nation: 1,
name: 'AttackerNation',
gold: 1000,
rice: 1000,
gennum: 1,
},
defenderGenerals: [
{
no: 2,
name: 'Defender',
nation: 2,
turntime: '2026-01-01 00:00:00',
personal: null,
special2: null,
crew: 1000,
crewtype: 100,
atmos: 100,
train: 100,
intel: 60,
intel_exp: 0,
book: null,
strength: 60,
strength_exp: 0,
weapon: null,
injury: 0,
leadership: 60,
leadership_exp: 0,
horse: null,
item: null,
explevel: 0,
experience: 100,
dedication: 100,
officer_level: 3,
officer_city: 2,
gold: 1000,
rice: 1000,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
defence_train: 0,
recent_war: null,
warnum: 0,
killnum: 0,
killcrew: 0,
},
],
defenderCity: {
city: 2,
nation: 2,
supply: 1,
name: 'DefenderCity',
pop: 10000,
agri: 1000,
comm: 1000,
secu: 1000,
def: 100,
wall: 100,
trust: 100,
level: 2,
pop_max: 10000,
agri_max: 1000,
comm_max: 1000,
secu_max: 1000,
def_max: 200,
wall_max: 200,
dead: 0,
state: 0,
conflict: '{}',
},
defenderNation: {
type: 'test',
tech: 1000,
level: 1,
capital: 2,
nation: 2,
name: 'DefenderNation',
gold: 1000,
rice: 1000,
gennum: 1,
},
});
const buildContext = (options: {
state: WorldStateRow;
battleSim: BattleSimTransport;
userId?: string | null;
db?: Partial<DatabaseClient>;
}): GameApiContext => {
const db = options.db ?? {
worldState: {
findFirst: async () => options.state,
},
};
const accessTokenStore = new RedisAccessTokenStore(
{
get: async () => null,
set: async () => null,
},
profile.name
);
const auth: GameSessionTokenPayload | null =
options.userId === null
? null
: {
version: 1,
profile: profile.name,
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
sessionId: 'session-1',
user: {
id: options.userId ?? 'user-1',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: options.battleSim,
profile,
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis: {} as unknown as RedisConnector['client'],
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('battle router orchestration', () => {
it('returns queued then completed results via transport', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
const response = await caller.battle.simulate(buildBattleRequest());
expect(response.status).toBe('queued');
expect(battleSim.simulateCalls).toBe(1);
expect(battleSim.lastRequesterUserId).toBe('user-1');
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
expect(queued.status).toBe('queued');
battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 });
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
expect(completed.status).toBe('completed');
expect(completed.payload?.result).toBe(true);
});
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
let transactionCalls = 0;
const db = {
worldState: { findFirst: async () => state },
$transaction: async () => {
transactionCalls += 1;
throw new Error('simulation must not create an input event transaction');
},
} as unknown as DatabaseClient;
const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db }));
await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
const noGeneralUser = appRouter.createCaller(
buildContext({ state, battleSim, userId: 'user-without-general', db })
);
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
status: 'queued',
});
expect(transactionCalls).toBe(0);
expect(battleSim.lastRequesterUserId).toBe('user-without-general');
});
it('does not expose queued results across authenticated users', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' }));
const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' }));
const response = await owner.battle.simulate(buildBattleRequest());
battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 });
await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({
status: 'completed',
payload: { avgWar: 7 },
});
await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({
status: 'queued',
jobId: response.jobId,
});
});
});
describe('battle simulator general import permissions', () => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const buildGeneral = (overrides: Record<string, unknown>) => ({
id: 1,
userId: 'same-nation-user',
name: '관전자',
npcState: 0,
nationId: 1,
leadership: 70,
strength: 71,
intel: 72,
officerLevel: 1,
injury: 0,
rice: 9000,
crew: 5000,
crewTypeId: 100,
atmos: 100,
train: 100,
experience: 400,
horseCode: null,
weaponCode: null,
bookCode: null,
itemCode: null,
personalCode: null,
special2Code: null,
meta: {},
...overrides,
});
const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 });
const ally = buildGeneral({
id: 2,
userId: 'ally-user',
name: '아군 장수',
nationId: 1,
officerLevel: 4,
rice: 4321,
crew: 3210,
train: 97,
atmos: 96,
horseCode: 'che_적토마',
weaponCode: 'che_의천검',
bookCode: 'che_손자병법',
itemCode: 'che_옥새',
meta: {
dex1: 10000,
rank_warnum: 33,
rank_killnum: 22,
rank_killcrew: 1111,
},
});
const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 });
const generals = [actor, ally, foreignActor];
const db = {
worldState: { findFirst: async () => state },
general: {
findFirst: async ({ where }: { where: { userId: string } }) =>
generals.find((general) => general.userId === where.userId) ?? null,
findUnique: async ({ where }: { where: { id: number } }) =>
generals.find((general) => general.id === where.id) ?? null,
},
} as unknown as DatabaseClient;
it('returns full ally details to the same nation but redacts them for another nation', async () => {
const battleSim = new QueuedBattleSimTransport();
const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db }));
const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db }));
const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id });
expect(visible.general).toMatchObject({
name: '아군 장수',
officer_level: 4,
horse: 'che_적토마',
crew: 3210,
rice: 4321,
train: 97,
atmos: 96,
warnum: 33,
killnum: 22,
killcrew: 1111,
});
const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id });
expect(redacted.general).toMatchObject({
name: '아군 장수',
officer_level: 1,
horse: null,
weapon: null,
book: null,
item: null,
crew: 0,
rice: 10000,
dex1: 0,
warnum: 0,
killnum: 0,
killcrew: 0,
});
});
it('requires a game general only for server-side general import', async () => {
const caller = appRouter.createCaller(
buildContext({
state,
battleSim: new QueuedBattleSimTransport(),
userId: 'user-without-general',
db,
})
);
await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'General not found',
});
});
});