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:
2026-07-25 08:50:01 +00:00
25 changed files with 2244 additions and 48 deletions
@@ -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,
+12
View File
@@ -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;
+1
View File
@@ -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) =>
+1
View File
@@ -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,