feat: add siegetank unit set and implement messaging system
- Created a new unit set for "siegetank" with various crew types and their attributes. - Implemented a messaging system with types, payloads, and storage functionality. - Added tests for the messaging system to ensure correct behavior for different message types (private, national, public, diplomacy).
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import type { TurnDaemonTransport } from './daemon/transport.js';
|
||||
|
||||
@@ -100,6 +101,7 @@ export interface NationRow {
|
||||
}
|
||||
|
||||
export interface DatabaseClient {
|
||||
$queryRaw<T = unknown>(query: Prisma.Sql): Promise<T>;
|
||||
worldState: {
|
||||
findFirst(args?: unknown): Promise<WorldStateRow | null>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
export interface MessageView {
|
||||
id: number;
|
||||
msgType: MessageType;
|
||||
src: MessagePayload['src'];
|
||||
dest: MessagePayload['dest'] | null;
|
||||
text: string;
|
||||
option?: MessagePayload['option'] | null;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: number;
|
||||
mailbox: number;
|
||||
type: MessageType;
|
||||
src: number;
|
||||
dest: number;
|
||||
time: Date;
|
||||
valid_until: Date;
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
const parsePayload = (value: unknown): MessagePayload => {
|
||||
if (typeof value === 'string') {
|
||||
return JSON.parse(value) as MessagePayload;
|
||||
}
|
||||
return value as MessagePayload;
|
||||
};
|
||||
|
||||
const formatMessageTime = (value: Date): string => {
|
||||
const pad = (input: number) => input.toString().padStart(2, '0');
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(
|
||||
value.getDate()
|
||||
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||
};
|
||||
|
||||
const toMessageView = (row: MessageRow): MessageView => {
|
||||
const payload = parsePayload(row.message);
|
||||
return {
|
||||
id: row.id,
|
||||
msgType: row.type,
|
||||
src: payload.src,
|
||||
dest: row.type === 'public' ? null : payload.dest,
|
||||
text: payload.text,
|
||||
option: payload.option ?? null,
|
||||
time: formatMessageTime(new Date(row.time)),
|
||||
};
|
||||
};
|
||||
|
||||
export const insertMessage = async (
|
||||
db: DatabaseClient,
|
||||
draft: MessageRecordDraft
|
||||
): Promise<number> => {
|
||||
const rows = await db.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${draft.validUntil},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error('Failed to insert message row.');
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
export const fetchMessagesFromMailbox = async (params: {
|
||||
db: DatabaseClient;
|
||||
mailbox: number;
|
||||
msgType: MessageType;
|
||||
limit: number;
|
||||
fromSeq: number;
|
||||
}): Promise<MessageView[]> => {
|
||||
const fromSeq = Math.max(params.fromSeq, 0);
|
||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND valid_until > NOW()
|
||||
AND id >= ${fromSeq}
|
||||
ORDER BY id DESC
|
||||
LIMIT ${params.limit}
|
||||
`;
|
||||
|
||||
return rows.map(toMessageView);
|
||||
};
|
||||
|
||||
export const fetchOldMessagesFromMailbox = async (params: {
|
||||
db: DatabaseClient;
|
||||
mailbox: number;
|
||||
msgType: MessageType;
|
||||
toSeq: number;
|
||||
limit: number;
|
||||
}): Promise<MessageView[]> => {
|
||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND valid_until > NOW()
|
||||
AND id < ${params.toSeq}
|
||||
ORDER BY id DESC
|
||||
LIMIT ${params.limit}
|
||||
`;
|
||||
|
||||
return rows.map(toMessageView);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { MessageTarget } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient, GeneralRow } from '../context.js';
|
||||
|
||||
const DEFAULT_NATION = {
|
||||
name: '재야',
|
||||
color: '#000000',
|
||||
};
|
||||
|
||||
export const resolveNationInfo = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number
|
||||
): Promise<{ name: string; color: string }> => {
|
||||
if (nationId <= 0) {
|
||||
return DEFAULT_NATION;
|
||||
}
|
||||
const nation = await db.nation.findUnique({ where: { id: nationId } });
|
||||
if (!nation) {
|
||||
return DEFAULT_NATION;
|
||||
}
|
||||
return { name: nation.name, color: nation.color };
|
||||
};
|
||||
|
||||
export const buildTargetFromGeneral = async (
|
||||
db: DatabaseClient,
|
||||
general: GeneralRow
|
||||
): Promise<MessageTarget> => {
|
||||
const nation = await resolveNationInfo(db, general.nationId);
|
||||
return {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: '',
|
||||
};
|
||||
};
|
||||
|
||||
export const buildNationTarget = (
|
||||
nationId: number,
|
||||
nationName: string,
|
||||
color: string
|
||||
): MessageTarget => ({
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId,
|
||||
nationName,
|
||||
color,
|
||||
icon: '',
|
||||
});
|
||||
@@ -12,8 +12,24 @@ import {
|
||||
shiftGeneralTurns,
|
||||
shiftNationTurns,
|
||||
} from './turns/reservedTurns.js';
|
||||
import {
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||
MESSAGE_MAILBOX_PUBLIC,
|
||||
sendMessage,
|
||||
type MessageDraft,
|
||||
type MessageRecordDraft,
|
||||
type MessageType,
|
||||
} from '@sammo-ts/logic';
|
||||
import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from './messages/targets.js';
|
||||
import {
|
||||
fetchMessagesFromMailbox,
|
||||
fetchOldMessagesFromMailbox,
|
||||
insertMessage,
|
||||
type MessageView,
|
||||
} from './messages/store.js';
|
||||
|
||||
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||
|
||||
const zTurnRunBudget = z.object({
|
||||
budgetMs: z.number().int().positive(),
|
||||
@@ -244,6 +260,266 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
messages: router({
|
||||
getRecent: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
sequence: z.number().int().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const sequence = input.sequence ?? -1;
|
||||
const nationId = general.nationId;
|
||||
const mailboxes = {
|
||||
private: general.id,
|
||||
public: MESSAGE_MAILBOX_PUBLIC,
|
||||
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||
} satisfies Record<MessageType, number>;
|
||||
|
||||
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages] =
|
||||
await Promise.all([
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.private,
|
||||
msgType: 'private',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.public,
|
||||
msgType: 'public',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.national,
|
||||
msgType: 'national',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.diplomacy,
|
||||
msgType: 'diplomacy',
|
||||
limit: 15,
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
]);
|
||||
|
||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||
private: privateMessages,
|
||||
public: publicMessages,
|
||||
national: nationalMessages,
|
||||
diplomacy: diplomacyMessages,
|
||||
};
|
||||
|
||||
let nextSequence = sequence;
|
||||
let minSequence = sequence;
|
||||
let lastType: MessageType | null = null;
|
||||
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
|
||||
for (const message of messages) {
|
||||
if (message.id > nextSequence) {
|
||||
nextSequence = message.id;
|
||||
}
|
||||
if (message.id <= minSequence) {
|
||||
minSequence = message.id;
|
||||
lastType = type;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateSequence('private', privateMessages);
|
||||
updateSequence('public', publicMessages);
|
||||
updateSequence('national', nationalMessages);
|
||||
updateSequence('diplomacy', diplomacyMessages);
|
||||
|
||||
if (lastType === 'private' && messageBuckets.private.length > 0) {
|
||||
messageBuckets.private.pop();
|
||||
} else if (
|
||||
lastType === 'public' &&
|
||||
messageBuckets.public.length > 0
|
||||
) {
|
||||
messageBuckets.public.pop();
|
||||
} else if (
|
||||
lastType === 'national' &&
|
||||
messageBuckets.national.length > 0
|
||||
) {
|
||||
messageBuckets.national.pop();
|
||||
} else if (
|
||||
lastType === 'diplomacy' &&
|
||||
messageBuckets.diplomacy.length > 0
|
||||
) {
|
||||
messageBuckets.diplomacy.pop();
|
||||
}
|
||||
|
||||
return {
|
||||
result: true,
|
||||
...messageBuckets,
|
||||
sequence: nextSequence,
|
||||
nationId: nationId,
|
||||
generalName: general.name,
|
||||
latestRead: {
|
||||
diplomacy: 0,
|
||||
private: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
getOld: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
to: z.number().int().positive(),
|
||||
type: zMessageType,
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const nationId = general.nationId;
|
||||
const mailboxes = {
|
||||
private: general.id,
|
||||
public: MESSAGE_MAILBOX_PUBLIC,
|
||||
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||
} satisfies Record<MessageType, number>;
|
||||
|
||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||
private: [],
|
||||
public: [],
|
||||
national: [],
|
||||
diplomacy: [],
|
||||
};
|
||||
|
||||
const messages = await fetchOldMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes[input.type],
|
||||
msgType: input.type,
|
||||
toSeq: input.to,
|
||||
limit: 15,
|
||||
});
|
||||
messageBuckets[input.type] = messages;
|
||||
|
||||
return {
|
||||
result: true,
|
||||
keepRecent: true,
|
||||
sequence: 0,
|
||||
nationId,
|
||||
generalName: general.name,
|
||||
...messageBuckets,
|
||||
};
|
||||
}),
|
||||
send: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
mailbox: z.number().int(),
|
||||
text: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
||||
const now = new Date();
|
||||
const validUntil = new Date('9999-12-31T00:00:00Z');
|
||||
|
||||
let msgType: MessageType;
|
||||
let dest = src;
|
||||
|
||||
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
||||
msgType = 'public';
|
||||
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
||||
const destNationId =
|
||||
input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
||||
if (destNationId <= 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid nation mailbox.',
|
||||
});
|
||||
}
|
||||
const nationInfo = await resolveNationInfo(
|
||||
ctx.db,
|
||||
destNationId
|
||||
);
|
||||
dest = buildNationTarget(
|
||||
destNationId,
|
||||
nationInfo.name,
|
||||
nationInfo.color
|
||||
);
|
||||
msgType =
|
||||
destNationId === general.nationId
|
||||
? 'national'
|
||||
: 'diplomacy';
|
||||
} else if (input.mailbox > 0) {
|
||||
const destGeneral = await ctx.db.general.findUnique({
|
||||
where: { id: input.mailbox },
|
||||
});
|
||||
if (!destGeneral) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Destination general not found.',
|
||||
});
|
||||
}
|
||||
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
|
||||
msgType = 'private';
|
||||
} else {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid mailbox.',
|
||||
});
|
||||
}
|
||||
|
||||
const draft: MessageDraft = {
|
||||
msgType,
|
||||
src,
|
||||
dest,
|
||||
text: input.text,
|
||||
time: now,
|
||||
validUntil,
|
||||
option: {},
|
||||
};
|
||||
|
||||
const result = await sendMessage(
|
||||
{
|
||||
insertMessage: (draft: MessageRecordDraft) =>
|
||||
insertMessage(ctx.db, draft),
|
||||
},
|
||||
draft
|
||||
);
|
||||
|
||||
return { msgType, msgId: result.receiverId };
|
||||
}),
|
||||
}),
|
||||
turnDaemon: router({
|
||||
run: procedure
|
||||
.input(
|
||||
|
||||
Reference in New Issue
Block a user