- Added `adjustGeneralResources` command to handle resource adjustments for generals. - Introduced `patchGeneral` command for updating general attributes. - Created `auctionBid` command to facilitate bidding in auctions with validation. - Refactored database interactions to use transactions for auction bids and resource adjustments. - Enhanced error handling for auction and resource operations. - Updated command handling in the turn daemon to support new commands. - Introduced `AuctionBidder` interface and implementation for managing auction bids. - Improved overall structure and readability of auction-related code.
490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
import type {
|
|
TurnDaemonCommand,
|
|
TurnDaemonCommandResponder,
|
|
TurnDaemonControlQueue,
|
|
TurnDaemonStatus,
|
|
TurnDaemonCommandResult,
|
|
} from './types.js';
|
|
|
|
export interface TurnDaemonStreamKeys {
|
|
commandStream: string;
|
|
eventStream: string;
|
|
}
|
|
|
|
export const buildTurnDaemonStreamKeys = (profileName: string): TurnDaemonStreamKeys => ({
|
|
commandStream: `sammo:${profileName}:turn-daemon:commands`,
|
|
eventStream: `sammo:${profileName}:turn-daemon:events`,
|
|
});
|
|
|
|
interface RedisStreamClient {
|
|
xAdd(stream: string, id: string, message: Record<string, string>): Promise<string>;
|
|
xRead(streams: { key: string; id: string }, options?: { BLOCK?: number; COUNT?: number }): Promise<unknown>;
|
|
}
|
|
|
|
type RedisStreamReadResponse = Array<{
|
|
name: string;
|
|
messages: Array<{ id: string; message: Record<string, string> }>;
|
|
}>;
|
|
|
|
type TurnDaemonEvent =
|
|
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
|
| { type: 'commandResult'; result: TurnDaemonCommandResult };
|
|
|
|
type TurnDaemonCommandEnvelope = {
|
|
requestId: string;
|
|
sentAt: string;
|
|
command: TurnDaemonCommand;
|
|
};
|
|
|
|
type TurnDaemonEventEnvelope = {
|
|
requestId?: string;
|
|
sentAt: string;
|
|
event: TurnDaemonEvent;
|
|
};
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
|
|
const parseCommandEnvelope = (raw: string): TurnDaemonCommandEnvelope | null => {
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<TurnDaemonCommandEnvelope>;
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
return null;
|
|
}
|
|
if (!parsed.command || typeof parsed.command !== 'object') {
|
|
return null;
|
|
}
|
|
if (typeof parsed.requestId !== 'string') {
|
|
return null;
|
|
}
|
|
if (typeof parsed.sentAt !== 'string') {
|
|
return null;
|
|
}
|
|
return parsed as TurnDaemonCommandEnvelope;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonCommand | null => {
|
|
const command = envelope.command as TurnDaemonCommand & {
|
|
requestId?: string;
|
|
};
|
|
switch (command.type) {
|
|
case 'auctionFinalize': {
|
|
if (typeof command.auctionId !== 'number') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'auctionFinalize',
|
|
requestId: envelope.requestId,
|
|
auctionId: command.auctionId,
|
|
};
|
|
}
|
|
case 'troopJoin': {
|
|
if (typeof command.generalId !== 'number' || typeof command.troopId !== 'number') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'troopJoin',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
troopId: command.troopId,
|
|
};
|
|
}
|
|
case 'troopExit': {
|
|
if (typeof command.generalId !== 'number') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'troopExit',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
};
|
|
}
|
|
case 'dieOnPrestart': {
|
|
if (typeof command.generalId !== 'number') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'dieOnPrestart',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
};
|
|
}
|
|
case 'buildNationCandidate': {
|
|
if (typeof command.generalId !== 'number') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'buildNationCandidate',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
};
|
|
}
|
|
case 'instantRetreat': {
|
|
if (typeof command.generalId !== 'number') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'instantRetreat',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
};
|
|
}
|
|
case 'vacation': {
|
|
if (typeof command.generalId !== 'number') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'vacation',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
};
|
|
}
|
|
case 'setMySetting': {
|
|
if (typeof command.generalId !== 'number' || !command.settings || typeof command.settings !== 'object') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'setMySetting',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
settings: command.settings,
|
|
};
|
|
}
|
|
case 'dropItem': {
|
|
if (typeof command.generalId !== 'number' || typeof command.itemType !== 'string') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'dropItem',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
itemType: command.itemType,
|
|
};
|
|
}
|
|
case 'changePermission': {
|
|
if (
|
|
typeof command.generalId !== 'number' ||
|
|
typeof command.isAmbassador !== 'boolean' ||
|
|
!Array.isArray(command.targetGeneralIds)
|
|
) {
|
|
return null;
|
|
}
|
|
const targetGeneralIds = command.targetGeneralIds.filter((id) => typeof id === 'number');
|
|
if (targetGeneralIds.length === 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'changePermission',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
isAmbassador: command.isAmbassador,
|
|
targetGeneralIds,
|
|
};
|
|
}
|
|
case 'kick': {
|
|
if (typeof command.generalId !== 'number' || typeof command.destGeneralId !== 'number') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'kick',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
destGeneralId: command.destGeneralId,
|
|
};
|
|
}
|
|
case 'appoint': {
|
|
if (
|
|
typeof command.generalId !== 'number' ||
|
|
typeof command.destGeneralId !== 'number' ||
|
|
typeof command.destCityId !== 'number' ||
|
|
typeof command.officerLevel !== 'number'
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'appoint',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
destGeneralId: command.destGeneralId,
|
|
destCityId: command.destCityId,
|
|
officerLevel: command.officerLevel,
|
|
};
|
|
}
|
|
case 'tournamentRefund': {
|
|
if (!Array.isArray(command.refunds)) {
|
|
return null;
|
|
}
|
|
const refunds = command.refunds
|
|
.filter((entry) =>
|
|
entry && typeof entry.generalId === 'number' && typeof entry.amount === 'number'
|
|
)
|
|
.map((entry) => ({
|
|
generalId: entry.generalId,
|
|
amount: entry.amount,
|
|
}));
|
|
if (refunds.length === 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'tournamentRefund',
|
|
requestId: envelope.requestId,
|
|
bettingId: typeof command.bettingId === 'number' ? command.bettingId : undefined,
|
|
reason: typeof command.reason === 'string' ? command.reason : undefined,
|
|
refunds,
|
|
};
|
|
}
|
|
case 'tournamentBettingPayout': {
|
|
if (!Array.isArray(command.payouts)) {
|
|
return null;
|
|
}
|
|
const payouts = command.payouts
|
|
.filter((entry) =>
|
|
entry && typeof entry.generalId === 'number' && typeof entry.amount === 'number'
|
|
)
|
|
.map((entry) => ({
|
|
generalId: entry.generalId,
|
|
amount: entry.amount,
|
|
}));
|
|
if (payouts.length === 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'tournamentBettingPayout',
|
|
requestId: envelope.requestId,
|
|
bettingId: typeof command.bettingId === 'number' ? command.bettingId : undefined,
|
|
reason: typeof command.reason === 'string' ? command.reason : undefined,
|
|
payouts,
|
|
};
|
|
}
|
|
case 'tournamentReward': {
|
|
if (typeof command.winnerId !== 'number' || typeof command.runnerUpId !== 'number') {
|
|
return null;
|
|
}
|
|
if (!Array.isArray(command.top16) || !Array.isArray(command.top8) || !Array.isArray(command.top4)) {
|
|
return null;
|
|
}
|
|
const normalizeIds = (list: unknown[]): number[] =>
|
|
list.filter((entry): entry is number => typeof entry === 'number' && Number.isFinite(entry));
|
|
const top16 = normalizeIds(command.top16);
|
|
const top8 = normalizeIds(command.top8);
|
|
const top4 = normalizeIds(command.top4);
|
|
if (top16.length === 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'tournamentReward',
|
|
requestId: envelope.requestId,
|
|
tournamentType: typeof command.tournamentType === 'number' ? command.tournamentType : 0,
|
|
winnerId: command.winnerId,
|
|
runnerUpId: command.runnerUpId,
|
|
top16,
|
|
top8,
|
|
top4,
|
|
};
|
|
}
|
|
case 'setNationMeta': {
|
|
if (typeof command.nationId !== 'number' || !command.updates || typeof command.updates !== 'object') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'setNationMeta',
|
|
requestId: envelope.requestId,
|
|
nationId: command.nationId,
|
|
updates: command.updates as Record<string, unknown>,
|
|
expectedUpdatedAt: typeof command.expectedUpdatedAt === 'string' ? command.expectedUpdatedAt : undefined,
|
|
};
|
|
}
|
|
case 'adjustGeneralResources': {
|
|
if (!Array.isArray(command.adjustments)) {
|
|
return null;
|
|
}
|
|
const adjustments = command.adjustments
|
|
.filter((entry) => entry && typeof entry.generalId === 'number')
|
|
.map((entry) => ({
|
|
generalId: entry.generalId,
|
|
goldDelta: typeof entry.goldDelta === 'number' ? entry.goldDelta : undefined,
|
|
riceDelta: typeof entry.riceDelta === 'number' ? entry.riceDelta : undefined,
|
|
}))
|
|
.filter((entry) => entry.goldDelta !== undefined || entry.riceDelta !== undefined);
|
|
if (adjustments.length === 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'adjustGeneralResources',
|
|
requestId: envelope.requestId,
|
|
reason: typeof command.reason === 'string' ? command.reason : undefined,
|
|
adjustments,
|
|
};
|
|
}
|
|
case 'patchGeneral': {
|
|
if (typeof command.generalId !== 'number' || !command.patch || typeof command.patch !== 'object') {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'patchGeneral',
|
|
requestId: envelope.requestId,
|
|
generalId: command.generalId,
|
|
patch: {
|
|
meta: isRecord(command.patch.meta) ? command.patch.meta : undefined,
|
|
turnTime: typeof command.patch.turnTime === 'string' ? command.patch.turnTime : undefined,
|
|
stats: isRecord(command.patch.stats)
|
|
? {
|
|
leadership:
|
|
typeof command.patch.stats.leadership === 'number'
|
|
? command.patch.stats.leadership
|
|
: undefined,
|
|
strength:
|
|
typeof command.patch.stats.strength === 'number'
|
|
? command.patch.stats.strength
|
|
: undefined,
|
|
intelligence:
|
|
typeof command.patch.stats.intelligence === 'number'
|
|
? command.patch.stats.intelligence
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
specialWar: typeof command.patch.specialWar === 'string' ? command.patch.specialWar : undefined,
|
|
},
|
|
};
|
|
}
|
|
case 'auctionBid': {
|
|
if (
|
|
typeof command.auctionId !== 'number' ||
|
|
typeof command.generalId !== 'number' ||
|
|
typeof command.amount !== 'number'
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
type: 'auctionBid',
|
|
requestId: envelope.requestId,
|
|
auctionId: command.auctionId,
|
|
generalId: command.generalId,
|
|
amount: command.amount,
|
|
tryExtendCloseDate:
|
|
typeof command.tryExtendCloseDate === 'boolean' ? command.tryExtendCloseDate : undefined,
|
|
};
|
|
}
|
|
case 'getStatus': {
|
|
const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId;
|
|
return { type: 'getStatus', requestId };
|
|
}
|
|
case 'run':
|
|
case 'pause':
|
|
case 'resume':
|
|
case 'shutdown':
|
|
return command;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
|
|
private readonly client: RedisStreamClient;
|
|
private readonly keys: TurnDaemonStreamKeys;
|
|
private readonly localQueue: TurnDaemonCommand[] = [];
|
|
private lastId: string;
|
|
|
|
constructor(
|
|
client: RedisStreamClient,
|
|
options: {
|
|
keys: TurnDaemonStreamKeys;
|
|
startId?: string;
|
|
}
|
|
) {
|
|
this.client = client;
|
|
this.keys = options.keys;
|
|
this.lastId = options.startId ?? '$';
|
|
}
|
|
|
|
enqueue(command: TurnDaemonCommand): void {
|
|
this.localQueue.push(command);
|
|
}
|
|
|
|
async drain(): Promise<TurnDaemonCommand[]> {
|
|
const drained = this.localQueue.splice(0, this.localQueue.length);
|
|
const remote = await this.readRemoteCommands(1);
|
|
return drained.concat(remote);
|
|
}
|
|
|
|
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
|
if (this.localQueue.length > 0) {
|
|
return this.localQueue.shift() ?? null;
|
|
}
|
|
|
|
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now());
|
|
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000);
|
|
if (deadlineMs !== null && blockMs === 0) {
|
|
return null;
|
|
}
|
|
|
|
const remote = await this.readRemoteCommands(cappedBlockMs);
|
|
if (remote.length === 0) {
|
|
return null;
|
|
}
|
|
const [first, ...rest] = remote;
|
|
if (rest.length > 0) {
|
|
this.localQueue.push(...rest);
|
|
}
|
|
return first ?? null;
|
|
}
|
|
|
|
getDepth(): number {
|
|
return this.localQueue.length;
|
|
}
|
|
|
|
async publishStatus(requestId: string, status: TurnDaemonStatus): Promise<void> {
|
|
await this.publishEvent({ type: 'status', requestId, status }, requestId);
|
|
}
|
|
|
|
async publishCommandResult(requestId: string, result: TurnDaemonCommandResult): Promise<void> {
|
|
await this.publishEvent({ type: 'commandResult', result }, requestId);
|
|
}
|
|
|
|
private async publishEvent(event: TurnDaemonEvent, requestId?: string): Promise<void> {
|
|
const envelope: TurnDaemonEventEnvelope = {
|
|
requestId,
|
|
sentAt: new Date().toISOString(),
|
|
event,
|
|
};
|
|
await this.client.xAdd(this.keys.eventStream, '*', {
|
|
payload: JSON.stringify(envelope),
|
|
});
|
|
}
|
|
|
|
private async readRemoteCommands(blockMs: number): Promise<TurnDaemonCommand[]> {
|
|
const response = (await this.client.xRead(
|
|
{ key: this.keys.commandStream, id: this.lastId },
|
|
{ BLOCK: blockMs, COUNT: 100 }
|
|
)) as RedisStreamReadResponse | null;
|
|
|
|
if (!response) {
|
|
return [];
|
|
}
|
|
|
|
const commands: TurnDaemonCommand[] = [];
|
|
for (const stream of response) {
|
|
for (const message of stream.messages) {
|
|
this.lastId = message.id;
|
|
const payload = message.message.payload;
|
|
if (!payload) {
|
|
continue;
|
|
}
|
|
const envelope = parseCommandEnvelope(payload);
|
|
if (!envelope) {
|
|
continue;
|
|
}
|
|
const command = normalizeCommand(envelope);
|
|
if (!command) {
|
|
continue;
|
|
}
|
|
commands.push(command);
|
|
}
|
|
}
|
|
return commands;
|
|
}
|
|
}
|