merge: add secure troop management
# Conflicts: # app/game-engine/src/turn/types.ts # app/game-engine/src/turn/worldLoader.ts
This commit is contained in:
@@ -50,11 +50,31 @@ const zTroopJoin = z.object({
|
||||
troopId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zTroopCreate = z.object({
|
||||
type: z.literal('troopCreate'),
|
||||
generalId: zFiniteNumber,
|
||||
troopName: z.string(),
|
||||
});
|
||||
|
||||
const zTroopExit = z.object({
|
||||
type: z.literal('troopExit'),
|
||||
generalId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zTroopKick = z.object({
|
||||
type: z.literal('troopKick'),
|
||||
generalId: zFiniteNumber,
|
||||
troopId: zFiniteNumber,
|
||||
targetGeneralId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zTroopRename = z.object({
|
||||
type: z.literal('troopRename'),
|
||||
generalId: zFiniteNumber,
|
||||
troopId: zFiniteNumber,
|
||||
troopName: z.string(),
|
||||
});
|
||||
|
||||
const zDieOnPrestart = z.object({
|
||||
type: z.literal('dieOnPrestart'),
|
||||
generalId: zFiniteNumber,
|
||||
@@ -262,6 +282,14 @@ const normalizeTroopJoin: CommandNormalizer<'troopJoin'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeTroopCreate: CommandNormalizer<'troopCreate'> = (envelope) => {
|
||||
const command = parseWith(zTroopCreate, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeTroopExit: CommandNormalizer<'troopExit'> = (envelope) => {
|
||||
const command = parseWith(zTroopExit, envelope.command);
|
||||
if (!command) {
|
||||
@@ -270,6 +298,22 @@ const normalizeTroopExit: CommandNormalizer<'troopExit'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeTroopKick: CommandNormalizer<'troopKick'> = (envelope) => {
|
||||
const command = parseWith(zTroopKick, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeTroopRename: CommandNormalizer<'troopRename'> = (envelope) => {
|
||||
const command = parseWith(zTroopRename, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeDieOnPrestart: CommandNormalizer<'dieOnPrestart'> = (envelope) => {
|
||||
const command = parseWith(zDieOnPrestart, envelope.command);
|
||||
if (!command) {
|
||||
@@ -445,8 +489,11 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => {
|
||||
const normalizers: CommandNormalizerMap = {
|
||||
auctionFinalize: normalizeAuctionFinalize,
|
||||
auctionBid: normalizeAuctionBid,
|
||||
troopCreate: normalizeTroopCreate,
|
||||
troopJoin: normalizeTroopJoin,
|
||||
troopExit: normalizeTroopExit,
|
||||
troopKick: normalizeTroopKick,
|
||||
troopRename: normalizeTroopRename,
|
||||
dieOnPrestart: normalizeDieOnPrestart,
|
||||
buildNationCandidate: normalizeBuildNationCandidate,
|
||||
instantRetreat: normalizeInstantRetreat,
|
||||
|
||||
@@ -472,6 +472,18 @@ export class InMemoryTurnWorld {
|
||||
return next;
|
||||
}
|
||||
|
||||
createTroop(troop: Troop): Troop | null {
|
||||
if (this.troops.has(troop.id)) {
|
||||
return null;
|
||||
}
|
||||
const next = { ...troop };
|
||||
this.troops.set(troop.id, next);
|
||||
this.dirtyTroopIds.add(troop.id);
|
||||
this.createdTroopIds.add(troop.id);
|
||||
this.deletedTroopIds.delete(troop.id);
|
||||
return next;
|
||||
}
|
||||
|
||||
removeTroop(id: number): boolean {
|
||||
if (!this.troops.has(id)) {
|
||||
return false;
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface TurnGeneral extends General {
|
||||
turnTime: Date;
|
||||
recentWarTime?: Date | null;
|
||||
lastTurn?: GeneralLastTurn;
|
||||
penalty?: unknown;
|
||||
}
|
||||
|
||||
export interface TurnDiplomacy {
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
buildVoteUniqueSeed,
|
||||
countOccupiedUniqueItems,
|
||||
createItemModuleRegistry,
|
||||
isValidTroopNameWidth,
|
||||
loadItemModules,
|
||||
normalizeTroopName,
|
||||
resolveTroopSecretPermission,
|
||||
resolveUniqueConfig,
|
||||
rollUniqueLottery,
|
||||
type ItemModule,
|
||||
@@ -441,6 +444,73 @@ async function handleTroopJoin(
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTroopCreate(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'troopCreate' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return {
|
||||
type: 'troopCreate',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '장수 정보를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const troopName = normalizeTroopName(command.troopName);
|
||||
if (!troopName) {
|
||||
return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '부대 이름이 없습니다.' };
|
||||
}
|
||||
if (!isValidTroopNameWidth(troopName)) {
|
||||
return {
|
||||
type: 'troopCreate',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.',
|
||||
};
|
||||
}
|
||||
if (general.troopId !== 0 || world.getTroopById(general.id)) {
|
||||
return {
|
||||
type: 'troopCreate',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '이미 부대에 소속되어 있습니다.',
|
||||
};
|
||||
}
|
||||
if (general.nationId <= 0 || !world.getNationById(general.nationId)) {
|
||||
return {
|
||||
type: 'troopCreate',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '국가에 소속되어 있지 않습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const troop = world.createTroop({
|
||||
id: general.id,
|
||||
nationId: general.nationId,
|
||||
name: troopName,
|
||||
});
|
||||
if (!troop) {
|
||||
return {
|
||||
type: 'troopCreate',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '부대가 생성되지 않았습니다. 버그일 수 있습니다.',
|
||||
};
|
||||
}
|
||||
world.updateGeneral(general.id, { troopId: general.id });
|
||||
return {
|
||||
type: 'troopCreate',
|
||||
ok: true,
|
||||
generalId: general.id,
|
||||
troopId: troop.id,
|
||||
troopName: troop.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTroopExit(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'troopExit' }>
|
||||
@@ -490,6 +560,103 @@ async function handleTroopExit(
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTroopKick(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'troopKick' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const fail = (reason: string): TurnDaemonCommandResult => ({
|
||||
type: 'troopKick',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
troopId: command.troopId,
|
||||
targetGeneralId: command.targetGeneralId,
|
||||
reason,
|
||||
});
|
||||
const { world } = ctx;
|
||||
const actor = world.getGeneralById(command.generalId);
|
||||
if (!actor) {
|
||||
return fail('장수 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
const troop = world.getTroopById(command.troopId);
|
||||
if (
|
||||
command.generalId !== command.troopId ||
|
||||
actor.troopId !== actor.id ||
|
||||
!troop ||
|
||||
troop.id !== actor.id ||
|
||||
troop.nationId !== actor.nationId
|
||||
) {
|
||||
return fail('권한이 부족합니다.');
|
||||
}
|
||||
|
||||
const target = world.getGeneralById(command.targetGeneralId);
|
||||
if (!target) {
|
||||
return fail('장수 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
if (target.troopId === 0) {
|
||||
return fail('부대에 소속되어 있지 않습니다.');
|
||||
}
|
||||
if (target.troopId !== command.troopId) {
|
||||
return fail('다른 부대에 소속되어 있습니다.');
|
||||
}
|
||||
if (target.id === command.troopId) {
|
||||
return fail('부대장을 추방할 수 없습니다.');
|
||||
}
|
||||
|
||||
world.updateGeneral(target.id, { troopId: 0 });
|
||||
return {
|
||||
type: 'troopKick',
|
||||
ok: true,
|
||||
generalId: actor.id,
|
||||
troopId: troop.id,
|
||||
targetGeneralId: target.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTroopRename(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'troopRename' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const fail = (reason: string): TurnDaemonCommandResult => ({
|
||||
type: 'troopRename',
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
troopId: command.troopId,
|
||||
reason,
|
||||
});
|
||||
const { world } = ctx;
|
||||
const actor = world.getGeneralById(command.generalId);
|
||||
if (!actor) {
|
||||
return fail('장수 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const nation = world.getNationById(actor.nationId);
|
||||
const permission = resolveTroopSecretPermission(actor, nation?.meta ?? {}, false);
|
||||
if (actor.id !== command.troopId && permission < 4) {
|
||||
return fail('권한이 부족합니다.');
|
||||
}
|
||||
|
||||
const troopName = normalizeTroopName(command.troopName);
|
||||
if (!troopName) {
|
||||
return fail('부대 이름이 없습니다.');
|
||||
}
|
||||
if (!isValidTroopNameWidth(troopName)) {
|
||||
return fail('부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
||||
}
|
||||
|
||||
const troop = world.getTroopById(command.troopId);
|
||||
if (!troop || actor.nationId <= 0 || troop.nationId !== actor.nationId) {
|
||||
return fail('부대가 없습니다.');
|
||||
}
|
||||
world.updateTroop(troop.id, { name: troopName });
|
||||
return {
|
||||
type: 'troopRename',
|
||||
ok: true,
|
||||
generalId: actor.id,
|
||||
troopId: troop.id,
|
||||
troopName,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDieOnPrestart(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
|
||||
@@ -1154,8 +1321,13 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
>;
|
||||
|
||||
const handlers: HandlerMap = {
|
||||
troopCreate: (command) =>
|
||||
handleTroopCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'troopCreate' }>),
|
||||
troopJoin: (command) => handleTroopJoin(ctx, command as Extract<TurnDaemonCommand, { type: 'troopJoin' }>),
|
||||
troopExit: (command) => handleTroopExit(ctx, command as Extract<TurnDaemonCommand, { type: 'troopExit' }>),
|
||||
troopKick: (command) => handleTroopKick(ctx, command as Extract<TurnDaemonCommand, { type: 'troopKick' }>),
|
||||
troopRename: (command) =>
|
||||
handleTroopRename(ctx, command as Extract<TurnDaemonCommand, { type: 'troopRename' }>),
|
||||
dieOnPrestart: (command) =>
|
||||
handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
|
||||
buildNationCandidate: (command) =>
|
||||
|
||||
@@ -212,6 +212,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => {
|
||||
},
|
||||
itemInventory,
|
||||
lastTurn: normalizeGeneralLastTurn(row.lastTurn),
|
||||
penalty: row.penalty,
|
||||
// meta는 상단에서 보장 처리됨.
|
||||
turnTime: row.turnTime,
|
||||
recentWarTime: row.recentWarTime ?? null,
|
||||
|
||||
Reference in New Issue
Block a user