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:
@@ -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';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './message.js';
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user