feat: add secure troop management

This commit is contained in:
2026-07-25 08:44:44 +00:00
parent 790f4915ab
commit ae4cc31a16
25 changed files with 2232 additions and 48 deletions
@@ -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) =>