Implement nation betting lifecycle
This commit is contained in:
@@ -23,6 +23,7 @@ import { yearbookRouter } from './router/yearbook/index.js';
|
||||
import { rankingRouter } from './router/ranking/index.js';
|
||||
import { dynastyRouter } from './router/dynasty/index.js';
|
||||
import { voteRouter } from './router/vote/index.js';
|
||||
import { bettingRouter } from './router/betting/index.js';
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
@@ -48,6 +49,7 @@ export const appRouter = router({
|
||||
ranking: rankingRouter,
|
||||
dynasty: dynastyRouter,
|
||||
vote: voteRouter,
|
||||
betting: bettingRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { appendInheritanceLog, readInheritancePoint, setInheritancePoint } from '../../services/inheritance.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
|
||||
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||
|
||||
const purifySelection = (selection: readonly number[]): number[] =>
|
||||
[...new Set(selection)].sort((left, right) => left - right);
|
||||
|
||||
const requireUserId = (auth: { user: { id: string } } | null): string => {
|
||||
const userId = auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
|
||||
const world = await db.worldState.findFirst({
|
||||
select: { currentYear: true, currentMonth: true },
|
||||
});
|
||||
if (!world) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' });
|
||||
}
|
||||
return world;
|
||||
};
|
||||
|
||||
export const bettingRouter = router({
|
||||
getList: authedProcedure
|
||||
.input(z.object({ req: z.enum(['bettingNation', 'tournament']).optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
requireUserId(ctx.auth);
|
||||
await getMyGeneral(ctx);
|
||||
const [world, rows] = await Promise.all([
|
||||
loadWorldDate(ctx.db),
|
||||
ctx.db.nationBetting.findMany({
|
||||
where: input?.req ? { type: input.req } : undefined,
|
||||
orderBy: { id: 'asc' },
|
||||
include: { bets: { select: { amount: true } } },
|
||||
}),
|
||||
]);
|
||||
const bettingList = Object.fromEntries(
|
||||
rows.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
finished: row.finished,
|
||||
selectCnt: row.selectCount,
|
||||
isExclusive: row.isExclusive,
|
||||
reqInheritancePoint: row.requiresInheritancePoint,
|
||||
openYearMonth: row.openYearMonth,
|
||||
closeYearMonth: row.closeYearMonth,
|
||||
winner: row.winner,
|
||||
totalAmount: row.bets.reduce((sum, bet) => sum + bet.amount, 0),
|
||||
},
|
||||
])
|
||||
);
|
||||
return {
|
||||
result: true,
|
||||
bettingList,
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
};
|
||||
}),
|
||||
|
||||
getDetail: authedProcedure
|
||||
.input(z.object({ bettingId: z.number().int().positive() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = requireUserId(ctx.auth);
|
||||
const general = await getMyGeneral(ctx);
|
||||
const [world, betting, remainPoint] = await Promise.all([
|
||||
loadWorldDate(ctx.db),
|
||||
ctx.db.nationBetting.findUnique({
|
||||
where: { id: input.bettingId },
|
||||
include: { bets: { orderBy: { id: 'asc' } } },
|
||||
}),
|
||||
ctx.db.inheritancePoint.findUnique({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
select: { value: true },
|
||||
}),
|
||||
]);
|
||||
if (!betting) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '해당 베팅이 없습니다' });
|
||||
}
|
||||
const group = (bets: typeof betting.bets) => {
|
||||
const totals = new Map<string, number>();
|
||||
for (const bet of bets) {
|
||||
totals.set(bet.selectionKey, (totals.get(bet.selectionKey) ?? 0) + bet.amount);
|
||||
}
|
||||
return Array.from(totals, ([selection, amount]) => [selection, amount] as const);
|
||||
};
|
||||
return {
|
||||
result: true,
|
||||
bettingInfo: {
|
||||
id: betting.id,
|
||||
type: betting.type,
|
||||
name: betting.name,
|
||||
finished: betting.finished,
|
||||
selectCnt: betting.selectCount,
|
||||
isExclusive: betting.isExclusive,
|
||||
reqInheritancePoint: betting.requiresInheritancePoint,
|
||||
openYearMonth: betting.openYearMonth,
|
||||
closeYearMonth: betting.closeYearMonth,
|
||||
candidates: betting.candidates,
|
||||
winner: betting.winner,
|
||||
},
|
||||
bettingDetail: group(betting.bets),
|
||||
myBetting: group(betting.bets.filter((bet) => bet.userId === userId)),
|
||||
remainPoint: betting.requiresInheritancePoint ? (remainPoint?.value ?? 0) : general.gold,
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
};
|
||||
}),
|
||||
|
||||
bet: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
bettingId: z.number().int().positive(),
|
||||
bettingType: z.array(z.number().int().nonnegative()),
|
||||
amount: z.number().int().min(10),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = requireUserId(ctx.auth);
|
||||
const general = await getMyGeneral(ctx);
|
||||
await ctx.db.$queryRaw`
|
||||
SELECT id
|
||||
FROM nation_betting
|
||||
WHERE id = ${input.bettingId}
|
||||
FOR UPDATE
|
||||
`;
|
||||
const betting = await ctx.db.nationBetting.findUnique({ where: { id: input.bettingId } });
|
||||
if (!betting) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `해당 베팅이 없습니다: ${input.bettingId}` });
|
||||
}
|
||||
if (betting.finished) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
|
||||
}
|
||||
const world = await loadWorldDate(ctx.db);
|
||||
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
|
||||
if (betting.closeYearMonth <= yearMonth) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
|
||||
}
|
||||
if (betting.openYearMonth > yearMonth) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '아직 시작되지 않은 베팅입니다' });
|
||||
}
|
||||
|
||||
const selection = purifySelection(input.bettingType);
|
||||
if (selection.length !== betting.selectCount || input.bettingType.length !== betting.selectCount) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '필요한 선택 수를 채우지 못했습니다.' });
|
||||
}
|
||||
const candidates = Array.isArray(betting.candidates) ? betting.candidates : [];
|
||||
if (selection.some((index) => index >= candidates.length)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바른 후보가 아닙니다.' });
|
||||
}
|
||||
const selectionKey = JSON.stringify(selection);
|
||||
const totals = await ctx.db.$queryRaw<Array<{ total: number | null }>>(
|
||||
GamePrisma.sql`
|
||||
SELECT SUM(amount)::float8 AS total
|
||||
FROM nation_bet
|
||||
WHERE betting_id = ${input.bettingId}
|
||||
AND user_id = ${userId}
|
||||
`
|
||||
);
|
||||
const previousBetAmount = totals[0]?.total ?? 0;
|
||||
if (previousBetAmount + input.amount > 1_000) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${1_000 - previousBetAmount}${betting.requiresInheritancePoint ? '유산포인트' : '금'}까지만 베팅 가능합니다.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (betting.requiresInheritancePoint) {
|
||||
const remainingPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
||||
if (remainingPoint < input.amount) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산포인트가 충분하지 않습니다.' });
|
||||
}
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', remainingPoint - input.amount);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
world.currentYear,
|
||||
world.currentMonth,
|
||||
`${input.amount} 포인트를 베팅에 사용`
|
||||
);
|
||||
await ctx.db.rankData.upsert({
|
||||
where: {
|
||||
generalId_type: {
|
||||
generalId: general.id,
|
||||
type: 'inherit_spent_dyn',
|
||||
},
|
||||
},
|
||||
update: { value: { increment: input.amount } },
|
||||
create: {
|
||||
generalId: general.id,
|
||||
nationId: general.nationId,
|
||||
type: 'inherit_spent_dyn',
|
||||
value: input.amount,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_IMPLEMENTED',
|
||||
message: 'Nation betting currently requires inheritance points.',
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.nationBet.upsert({
|
||||
where: {
|
||||
bettingId_userId_selectionKey: {
|
||||
bettingId: input.bettingId,
|
||||
userId,
|
||||
selectionKey,
|
||||
},
|
||||
},
|
||||
update: { amount: { increment: input.amount }, generalId: general.id, selection },
|
||||
create: {
|
||||
bettingId: input.bettingId,
|
||||
generalId: general.id,
|
||||
userId,
|
||||
selection,
|
||||
selectionKey,
|
||||
amount: input.amount,
|
||||
},
|
||||
});
|
||||
return { result: true };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
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';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const bettingId = 990_071;
|
||||
const concurrentBettingId = 990_072;
|
||||
const generalId = 9_971;
|
||||
const nationId = 990_071;
|
||||
const userId = 'nation-betting-router-user';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:2',
|
||||
issuedAt: '2026-07-25T00:00:00.000Z',
|
||||
expiresAt: '2026-07-26T00:00:00.000Z',
|
||||
sessionId: 'nation-betting-router-session',
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'bettor',
|
||||
displayName: 'Bettor',
|
||||
roles: ['user'],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
integration('nation betting router', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let worldStateId: number;
|
||||
|
||||
const buildContext = (requestId: string): GameApiContext => {
|
||||
const redisClient = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
};
|
||||
return {
|
||||
requestId,
|
||||
db,
|
||||
redis: redisClient as unknown as RedisConnector['client'],
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: { id: 'che', scenario: '2', name: 'che:2' },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
auth,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.nation.deleteMany({ where: { id: nationId } });
|
||||
|
||||
await db.nation.create({
|
||||
data: {
|
||||
id: nationId,
|
||||
name: '베팅국',
|
||||
color: '#123456',
|
||||
level: 2,
|
||||
},
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '베팅장수',
|
||||
nationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
const world = await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'nation-betting-router',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
worldStateId = world.id;
|
||||
await db.nationBetting.create({
|
||||
data: {
|
||||
id: bettingId,
|
||||
name: '천통국 예상',
|
||||
selectCount: 1,
|
||||
requiresInheritancePoint: true,
|
||||
openYearMonth: 2_400,
|
||||
closeYearMonth: 2_424,
|
||||
candidates: [
|
||||
{
|
||||
title: '베팅국',
|
||||
info: '국력: 100<br>장수 수: 1<br>도시 수: 1',
|
||||
isHtml: true,
|
||||
aux: { nation: nationId },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.nationBetting.create({
|
||||
data: {
|
||||
id: concurrentBettingId,
|
||||
name: '동시 베팅',
|
||||
selectCount: 1,
|
||||
requiresInheritancePoint: true,
|
||||
openYearMonth: 2_400,
|
||||
closeYearMonth: 2_424,
|
||||
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
||||
},
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId, key: 'previous', value: 1_000 },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.nation.deleteMany({ where: { id: nationId } });
|
||||
await db.worldState.delete({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('returns legacy-shaped data and atomically deducts and accumulates a bet', async () => {
|
||||
const detailBefore = await appRouter
|
||||
.createCaller(buildContext('nation-betting-detail-before'))
|
||||
.betting.getDetail({ bettingId });
|
||||
expect(detailBefore).toMatchObject({
|
||||
result: true,
|
||||
bettingInfo: { id: bettingId, selectCnt: 1, reqInheritancePoint: true },
|
||||
remainPoint: 1_000,
|
||||
year: 200,
|
||||
month: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-duplicate')).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0, 0],
|
||||
amount: 100,
|
||||
})
|
||||
).rejects.toMatchObject({ message: '필요한 선택 수를 채우지 못했습니다.' });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-first')).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 100,
|
||||
})
|
||||
).resolves.toEqual({ result: true });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-second')).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 50,
|
||||
})
|
||||
).resolves.toEqual({ result: true });
|
||||
|
||||
expect(await db.nationBet.findMany({ where: { bettingId } })).toEqual([
|
||||
expect.objectContaining({
|
||||
generalId,
|
||||
userId,
|
||||
selection: [0],
|
||||
selectionKey: '[0]',
|
||||
amount: 150,
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 850 });
|
||||
expect(
|
||||
await db.rankData.findUniqueOrThrow({
|
||||
where: { generalId_type: { generalId, type: 'inherit_spent_dyn' } },
|
||||
})
|
||||
).toMatchObject({ nationId, value: 150 });
|
||||
expect(await db.inheritanceLog.count({ where: { userId } })).toBe(2);
|
||||
|
||||
const detailAfter = await appRouter
|
||||
.createCaller(buildContext('nation-betting-detail-after'))
|
||||
.betting.getDetail({ bettingId });
|
||||
expect(detailAfter).toMatchObject({
|
||||
bettingDetail: [['[0]', 150]],
|
||||
myBetting: [['[0]', 150]],
|
||||
remainPoint: 850,
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes concurrent bets so the cumulative 1,000 point limit cannot be overspent', async () => {
|
||||
const results = await Promise.allSettled([
|
||||
appRouter.createCaller(buildContext('nation-betting-concurrent-a')).betting.bet({
|
||||
bettingId: concurrentBettingId,
|
||||
bettingType: [0],
|
||||
amount: 600,
|
||||
}),
|
||||
appRouter.createCaller(buildContext('nation-betting-concurrent-b')).betting.bet({
|
||||
bettingId: concurrentBettingId,
|
||||
bettingType: [0],
|
||||
amount: 600,
|
||||
}),
|
||||
]);
|
||||
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
|
||||
expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }))
|
||||
.toMatchObject({ _sum: { amount: 600 } });
|
||||
expect(
|
||||
await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 250 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user