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
+1
View File
@@ -3,6 +3,7 @@ export type { RandomGenerator } from '@sammo-ts/common';
export * from './actions/index.js';
export * from './constraints/index.js';
export * from './logging/index.js';
export * from './messages/index.js';
export * from './ports/world.js';
export * from './ports/worldSnapshot.js';
export * from './scenario/index.js';
+1
View File
@@ -0,0 +1 @@
export * from './message.js';
+168
View File
@@ -0,0 +1,168 @@
export type MessageType = 'public' | 'private' | 'national' | 'diplomacy';
export const MESSAGE_MAILBOX_PUBLIC = 9999;
export const MESSAGE_MAILBOX_NATIONAL_BASE = 9000;
export interface MessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
export type MessageOption = Record<string, unknown>;
export interface MessageDraft {
msgType: MessageType;
src: MessageTarget;
dest: MessageTarget;
text: string;
time: Date;
validUntil: Date;
option?: MessageOption | null;
}
export interface MessagePayload {
src: MessageTarget;
dest: MessageTarget;
text: string;
option?: MessageOption | null;
}
export interface MessageRecordDraft {
mailbox: number;
msgType: MessageType;
srcId: number;
destId: number;
time: Date;
validUntil: Date;
payload: MessagePayload;
}
export interface MessageStore {
insertMessage(draft: MessageRecordDraft): Promise<number>;
}
export const isValidMailbox = (mailbox: number): boolean =>
mailbox > 0 && mailbox <= MESSAGE_MAILBOX_PUBLIC;
export const resolveReceiverMailbox = (draft: MessageDraft): number => {
switch (draft.msgType) {
case 'public':
return MESSAGE_MAILBOX_PUBLIC;
case 'national':
case 'diplomacy':
return MESSAGE_MAILBOX_NATIONAL_BASE + draft.dest.nationId;
case 'private':
return draft.dest.generalId;
}
};
export const resolveSenderMailbox = (draft: MessageDraft): number | null => {
switch (draft.msgType) {
case 'public':
return null;
case 'private':
return draft.src.generalId !== draft.dest.generalId
? draft.src.generalId
: null;
case 'national':
return draft.src.nationId !== draft.dest.nationId
? MESSAGE_MAILBOX_NATIONAL_BASE + draft.src.nationId
: null;
case 'diplomacy':
return MESSAGE_MAILBOX_NATIONAL_BASE + draft.src.nationId;
}
};
const buildPayload = (
draft: MessageDraft,
optionOverride?: MessageOption | null
): MessagePayload => ({
src: draft.src,
dest: draft.dest,
text: draft.text,
option: optionOverride ?? draft.option ?? {},
});
const buildRecord = (
draft: MessageDraft,
mailbox: number,
optionOverride?: MessageOption | null
): MessageRecordDraft => {
const payload = buildPayload(draft, optionOverride);
let srcId = draft.src.generalId;
let destId = draft.dest.generalId;
if (draft.msgType === 'public') {
destId = MESSAGE_MAILBOX_PUBLIC;
} else if (draft.msgType === 'national' || draft.msgType === 'diplomacy') {
srcId = MESSAGE_MAILBOX_NATIONAL_BASE + draft.src.nationId;
destId = MESSAGE_MAILBOX_NATIONAL_BASE + draft.dest.nationId;
}
return {
mailbox,
msgType: draft.msgType,
srcId,
destId,
time: draft.time,
validUntil: draft.validUntil,
payload,
};
};
const buildSenderOption = (
draft: MessageDraft,
receiverId: number
): MessageOption => {
const option = {
...(draft.option ?? {}),
receiverMessageID: receiverId,
};
if (draft.msgType === 'diplomacy' && 'action' in option) {
const { action: _action, ...rest } = option;
return rest;
}
return option;
};
// 메시지 전달 규칙(수신/송신 복사본)을 그대로 유지한다.
export const sendMessage = async (
store: MessageStore,
draft: MessageDraft,
options: { sendDestOnly?: boolean } = {}
): Promise<{ receiverId: number; senderId?: number }> => {
const receiverMailbox = resolveReceiverMailbox(draft);
if (!isValidMailbox(receiverMailbox)) {
throw new Error(`Invalid receiver mailbox: ${receiverMailbox}`);
}
const receiverRecord = buildRecord(draft, receiverMailbox);
const receiverId = await store.insertMessage(receiverRecord);
if (!receiverId) {
throw new Error('Failed to send receiver message.');
}
if (options.sendDestOnly) {
return { receiverId };
}
const senderMailbox = resolveSenderMailbox(draft);
if (!senderMailbox || senderMailbox === receiverMailbox) {
return { receiverId };
}
const senderRecord = buildRecord(
draft,
senderMailbox,
buildSenderOption(draft, receiverId)
);
const senderId = await store.insertMessage(senderRecord);
return senderId ? { receiverId, senderId } : { receiverId };
};
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
sendMessage,
type MessageDraft,
type MessageRecordDraft,
type MessageStore,
type MessageTarget,
} from '../src/messages/message.js';
const buildTarget = (overrides: Partial<MessageTarget> = {}): MessageTarget => ({
generalId: 1,
generalName: '테스트',
nationId: 1,
nationName: '나라',
color: '#000000',
icon: '',
...overrides,
});
class InMemoryMessageStore implements MessageStore {
private nextId = 1;
public readonly records: Array<{ id: number; draft: MessageRecordDraft }> = [];
async insertMessage(draft: MessageRecordDraft): Promise<number> {
const id = this.nextId++;
this.records.push({ id, draft });
return id;
}
}
const buildDraft = (overrides: Partial<MessageDraft> = {}): MessageDraft => ({
msgType: 'private',
src: buildTarget({ generalId: 10, nationId: 1 }),
dest: buildTarget({ generalId: 20, nationId: 2 }),
text: '안녕',
time: new Date('2025-01-01T00:00:00Z'),
validUntil: new Date('9999-12-31T00:00:00Z'),
option: {},
...overrides,
});
describe('sendMessage', () => {
it('sends a private message to receiver and sender', async () => {
const store = new InMemoryMessageStore();
const draft = buildDraft();
const result = await sendMessage(store, draft);
expect(result.receiverId).toBe(1);
expect(result.senderId).toBe(2);
expect(store.records).toHaveLength(2);
expect(store.records[0].draft.mailbox).toBe(draft.dest.generalId);
expect(store.records[1].draft.mailbox).toBe(draft.src.generalId);
expect(store.records[0].draft.payload.option).not.toHaveProperty(
'receiverMessageID'
);
expect(store.records[1].draft.payload.option).toMatchObject({
receiverMessageID: 1,
});
});
it('sends only one national message when nations match', async () => {
const store = new InMemoryMessageStore();
const draft = buildDraft({
msgType: 'national',
dest: buildTarget({ generalId: 0, nationId: 1 }),
});
const result = await sendMessage(store, draft);
expect(result.senderId).toBeUndefined();
expect(store.records).toHaveLength(1);
expect(store.records[0].draft.mailbox).toBe(
MESSAGE_MAILBOX_NATIONAL_BASE + 1
);
});
it('keeps public messages in the shared mailbox only', async () => {
const store = new InMemoryMessageStore();
const draft = buildDraft({
msgType: 'public',
dest: buildTarget({ generalId: 0, nationId: 0 }),
});
const result = await sendMessage(store, draft);
expect(result.senderId).toBeUndefined();
expect(store.records).toHaveLength(1);
expect(store.records[0].draft.mailbox).toBe(MESSAGE_MAILBOX_PUBLIC);
});
it('removes diplomacy action from sender copy', async () => {
const store = new InMemoryMessageStore();
const draft = buildDraft({
msgType: 'diplomacy',
option: { action: 'test', payload: 1 },
dest: buildTarget({ generalId: 0, nationId: 2 }),
});
await sendMessage(store, draft);
const senderPayload = store.records[1].draft.payload.option ?? {};
expect(senderPayload).not.toHaveProperty('action');
expect(senderPayload).toMatchObject({ payload: 1 });
});
});