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:
2025-12-30 08:19:10 +00:00
parent 519a0a76cb
commit c5cba31418
16 changed files with 8697 additions and 0 deletions
+2
View File
@@ -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>;
};
+118
View File
@@ -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);
};
+50
View File
@@ -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: '',
});
+276
View File
@@ -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(