fix(scenario): open nation betting in scenario 29

This commit is contained in:
2026-08-15 04:27:30 +00:00
parent e08789379b
commit f8b28a055c
6 changed files with 310 additions and 0 deletions
@@ -19,4 +19,20 @@ describe('tracked scenario resources', () => {
const scenarios = await Promise.all(scenarioIds.map((scenarioId) => loadScenarioDefinitionById(scenarioId)));
expect(scenarios.every((scenario) => scenario.title.length > 0)).toBe(true);
});
it('opens nation betting in the first playable year of every scenario 29 variant', async () => {
const scenarioIds = [2900, 2901, 2903, 2904];
const scenarios = await Promise.all(scenarioIds.map((scenarioId) => loadScenarioDefinitionById(scenarioId)));
for (const scenario of scenarios) {
expect(scenario.startYear).toBe(2025);
expect(scenario.events).toContainEqual([
'month',
999,
['Date', '==', 2026, 1],
['OpenNationBetting', 1, 2000],
['DeleteEvent'],
]);
}
});
});
+3
View File
@@ -471,5 +471,8 @@
[0, "Faker", "롤시나리오/Faker.jpg", 0, null, 120, 120, 120, 0, 1996, 2200, "출세", "무쌍", "모든 길은 저를 통합니다."],
[0, "내셔 남작", "롤시나리오/내셔 남작.png", 1, "업", 110, 110, 110, 12, 2009, 2200, null, null],
[0, "장로 드래곤", "롤시나리오/장로 드래곤.png", 2, "건업", 100, 100, 100, 12, 2009, 2200, null, null]
],
"events": [
["month", 999, ["Date", "==", 2026, 1], ["OpenNationBetting", 1, 2000], ["DeleteEvent"]]
]
}
+3
View File
@@ -483,5 +483,8 @@
[0, "Faker", "롤시나리오/Faker.jpg", 0, null, 120, 120, 120, 0, 1996, 2200, "출세", "무쌍", "모든 길은 저를 통합니다."],
[0, "내셔 남작", "롤시나리오/내셔 남작.png", 1, "업", 110, 110, 110, 12, 2009, 2200, null, null],
[0, "장로 드래곤", "롤시나리오/장로 드래곤.png", 2, "건업", 100, 100, 100, 12, 2009, 2200, null, null]
],
"events": [
["month", 999, ["Date", "==", 2026, 1], ["OpenNationBetting", 1, 2000], ["DeleteEvent"]]
]
}
+3
View File
@@ -475,5 +475,8 @@
[0, "Faker", "롤시나리오/Faker.jpg", 0, null, 120, 120, 120, 0, 1996, 2200, "출세", "무쌍", "모든 길은 저를 통합니다."],
[0, "내셔 남작", "롤시나리오/내셔 남작.png", 1, "업", 110, 110, 110, 12, 2009, 2200, null, null],
[0, "장로 드래곤", "롤시나리오/장로 드래곤.png", 2, "건업", 100, 100, 100, 12, 2009, 2200, null, null]
],
"events": [
["month", 999, ["Date", "==", 2026, 1], ["OpenNationBetting", 1, 2000], ["DeleteEvent"]]
]
}
+3
View File
@@ -487,5 +487,8 @@
[0, "Faker", "롤시나리오/Faker.jpg", 0, null, 120, 120, 120, 0, 1996, 2200, "출세", "무쌍", "모든 길은 저를 통합니다."],
[0, "내셔 남작", "롤시나리오/내셔 남작.png", 1, "업", 110, 110, 110, 12, 2009, 2200, null, null],
[0, "장로 드래곤", "롤시나리오/장로 드래곤.png", 2, "건업", 100, 100, 100, 12, 2009, 2200, null, null]
],
"events": [
["month", 999, ["Date", "==", 2026, 1], ["OpenNationBetting", 1, 2000], ["DeleteEvent"]]
]
}
@@ -0,0 +1,282 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { RedisAccessTokenStore } from '@sammo-ts/game-api/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '@sammo-ts/game-api/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '@sammo-ts/game-api/battleSim/inMemoryTransport.js';
import type { GameApiContext } from '@sammo-ts/game-api/context.js';
import { InMemoryTurnDaemonTransport } from '@sammo-ts/game-api/daemon/inMemoryTransport.js';
import { appRouter } from '@sammo-ts/game-api/router.js';
import { createTurnDaemonRuntime, seedScenarioToDatabase } from '@sammo-ts/game-engine';
import { createFinishNationBettingHandler } from '@sammo-ts/game-engine/turn/monthlyNationBettingAction.js';
import { createMonthlyEventHandler } from '@sammo-ts/game-engine/turn/monthlyEventHandler.js';
import {
createGamePostgresConnector,
type RedisConnector,
} from '@sammo-ts/infra';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const scenarioId = 2900;
const userIds = ['scenario29-betting-winner', 'scenario29-betting-loser'] as const;
const openedMessage = '새로운 천통국 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.';
type TurnDaemonRuntime = Awaited<ReturnType<typeof createTurnDaemonRuntime>>;
const buildAuth = (index: 0 | 1): GameSessionTokenPayload => ({
version: 1,
profile: `che:${scenarioId}`,
issuedAt: '2026-08-15T00:00:00.000Z',
expiresAt: '2026-08-16T00:00:00.000Z',
sessionId: `scenario29-betting-session-${index + 1}`,
user: {
id: userIds[index],
username: `scenario29-bettor-${index + 1}`,
displayName: `시나리오29 베팅유저 ${index + 1}`,
roles: ['user'],
},
sanctions: {},
});
const readMessageText = (value: unknown): string | null => {
if (!value || typeof value !== 'object' || Array.isArray(value) || !('text' in value)) {
return null;
}
return typeof value.text === 'string' ? value.text : null;
};
integration('scenario 29 nation betting lifecycle', () => {
let runtime: TurnDaemonRuntime | null = null;
let closeDb: (() => Promise<void>) | null = null;
afterEach(async () => {
await runtime?.close();
runtime = null;
await closeDb?.();
closeDb = null;
});
it('starts scenario 2900, announces and accepts nation betting, then settles the winning user', async () => {
let generalIds: [number, number] | null = null;
await seedScenarioToDatabase({
scenarioId,
databaseUrl: databaseUrl!,
now: new Date('2026-08-15T00:00:00.000Z'),
gameClockMode: 'manual',
installOptions: {
turnTermMinutes: 120,
sync: false,
tournamentTrig: false,
},
onBeforeCommit: async (prisma) => {
const generals = await prisma.general.findMany({
where: { nationId: { gt: 0 } },
orderBy: { id: 'asc' },
take: 2,
select: { id: true },
});
if (generals.length !== 2 || !generals[0] || !generals[1]) {
throw new Error('Scenario 2900 must seed at least two bettable generals.');
}
generalIds = [generals[0].id, generals[1].id];
await Promise.all(
generalIds.map((generalId, index) =>
prisma.general.update({
where: { id: generalId },
data: { userId: userIds[index], npcState: 0 },
})
)
);
await prisma.inheritancePoint.createMany({
data: userIds.map((userId) => ({ userId, key: 'previous', value: 1_000 })),
});
},
});
if (!generalIds) {
throw new Error('Scenario 2900 bettor fixtures were not created.');
}
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
const db = connector.prisma;
closeDb = () => connector.disconnect();
runtime = await createTurnDaemonRuntime({
profile: 'che',
databaseUrl: databaseUrl!,
gameClockMode: 'manual',
enableLeaseHeartbeat: false,
leaseDurationMs: 300_000,
exclusiveFastForward: true,
});
const buildContext = (index: 0 | 1): GameApiContext => {
const redisClient = {
get: async () => null,
set: async () => null,
};
return {
requestId: `scenario29-betting-${index + 1}`,
db,
redis: redisClient as unknown as RedisConnector['client'],
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile: { id: 'che', scenario: String(scenarioId), name: `che:${scenarioId}` },
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
auth: buildAuth(index),
accessTokenStore: new RedisAccessTokenStore(redisClient, `che:${scenarioId}`),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'scenario29-betting-test-secret',
};
};
expect(runtime.world.getState()).toMatchObject({ currentYear: 2025, currentMonth: 1 });
expect(runtime.world.listEvents('month').map((event) => event.action)).toContainEqual([
['OpenNationBetting', 1, 2000],
['DeleteEvent'],
]);
for (let month = 0; month < 12; month += 1) {
const state = runtime.world.getState();
const turnTime = new Date(state.lastTurnTime.getTime() + state.tickSeconds * 1_000);
await runtime.world.advanceMonth(turnTime);
await runtime.hooks?.flushChanges?.({
lastTurnTime: runtime.world.getState().lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 1,
durationMs: 0,
partial: false,
});
}
expect(runtime.world.getState()).toMatchObject({ currentYear: 2026, currentMonth: 1 });
const betting = await db.nationBetting.findFirstOrThrow({
where: { name: '천통국 예상', finished: false },
orderBy: { id: 'desc' },
});
expect(betting).toMatchObject({ selectCount: 1, openYearMonth: 2026 * 12, closeYearMonth: 2028 * 12 });
expect(
await db.nationBet.findFirstOrThrow({
where: { bettingId: betting.id, generalId: 0 },
select: { selectionKey: true, amount: true },
})
).toEqual({ selectionKey: '[-1]', amount: 2000 });
const historyLogs = await db.logEntry.findMany({
where: { text: { contains: '천하통일 후보를 점치는' } },
orderBy: { id: 'asc' },
select: { text: true },
});
expect(historyLogs.map((entry) => entry.text)).toContain(
'<C>●</>2026년 1월:<B><b>【내기】</b></>천하통일 후보를 점치는 <C>내기</>가 진행중입니다! 호사가의 참여를 기다립니다!'
);
const messages = await db.message.findMany({
where: { mailbox: { in: generalIds } },
orderBy: { id: 'asc' },
select: { mailbox: true, message: true },
});
expect(
messages
.filter((message) => readMessageText(message.message) === openedMessage)
.map((message) => message.mailbox)
.sort((left, right) => left - right)
).toEqual([...generalIds].sort((left, right) => left - right));
const candidates = Array.isArray(betting.candidates) ? betting.candidates : [];
const candidateNationIds = candidates.flatMap((candidate) => {
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate) || !('aux' in candidate)) {
return [];
}
const aux = candidate.aux;
if (!aux || typeof aux !== 'object' || Array.isArray(aux) || !('nation' in aux)) {
return [];
}
return typeof aux.nation === 'number' ? [aux.nation] : [];
});
expect(candidateNationIds.length).toBeGreaterThan(1);
const winnerNationId = candidateNationIds[0]!;
const loserSelection = 1;
await expect(
appRouter.createCaller(buildContext(0)).betting.bet({
bettingId: betting.id,
bettingType: [0],
amount: 100,
})
).resolves.toEqual({ result: true });
await expect(
appRouter.createCaller(buildContext(1)).betting.bet({
bettingId: betting.id,
bettingType: [loserSelection],
amount: 100,
})
).resolves.toEqual({ result: true });
expect(
await Promise.all(
userIds.map(async (userId) =>
(
await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId, key: 'previous' } },
})
).value
)
)
).toEqual([900, 900]);
for (const nation of runtime.world.listNations()) {
if (nation.id > 0 && nation.id !== winnerNationId) {
runtime.world.removeNation(nation.id);
}
}
const finishHandler = createFinishNationBettingHandler({ getWorld: () => runtime?.world ?? null });
const eventHandler = createMonthlyEventHandler({
getWorld: () => runtime?.world ?? null,
startYear: 2025,
actions: new Map([['FinishNationBetting', finishHandler]]),
});
const finishState = runtime.world.getState();
await eventHandler.dispatchTarget('destroy_nation', {
previousYear: finishState.currentYear,
previousMonth: finishState.currentMonth,
currentYear: finishState.currentYear,
currentMonth: finishState.currentMonth,
turnTime: new Date(finishState.lastTurnTime.getTime() + finishState.tickSeconds * 1_000),
});
await runtime.hooks?.flushChanges?.({
lastTurnTime: runtime.world.getState().lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
expect(await db.nationBetting.findUniqueOrThrow({ where: { id: betting.id } })).toMatchObject({
finished: true,
winner: [0],
});
expect(
await Promise.all(
userIds.map(async (userId) =>
(
await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId, key: 'previous' } },
})
).value
)
)
).toEqual([3_100, 900]);
expect(
await db.rankData.findUniqueOrThrow({
where: { generalId_type: { generalId: generalIds[0], type: 'inherit_earned_act' } },
select: { value: true },
})
).toEqual({ value: 2_200 });
expect(
await db.logEntry.findFirst({
where: { text: { contains: '천통국 예상 내기의 결과가 나왔습니다' } },
select: { text: true },
})
).not.toBeNull();
}, 120_000);
});