Merge branch 'main' into feature/frontend-legacy-parity

This commit is contained in:
2026-07-25 11:45:07 +00:00
64 changed files with 5226 additions and 811 deletions
+35
View File
@@ -23,6 +23,14 @@ interface MessageRow {
message: unknown;
}
export interface StoredMessage {
id: number;
mailbox: number;
msgType: MessageType;
time: Date;
payload: MessagePayload;
}
const parsePayload = (value: unknown): MessagePayload => {
if (typeof value === 'string') {
return JSON.parse(value) as MessagePayload;
@@ -113,3 +121,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
return rows.map(toMessageView);
};
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id} AND valid_until > NOW()
LIMIT 1
`;
const row = rows[0];
if (!row) return null;
return {
id: row.id,
mailbox: row.mailbox,
msgType: row.type,
time: new Date(row.time),
payload: parsePayload(row.message),
};
};
export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Promise<void> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return;
await db.message.updateMany({
where: { id: { in: uniqueIds } },
data: { validUntil: new Date() },
});
};
+145 -32
View File
@@ -14,11 +14,14 @@ import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from '..
import {
fetchMessagesFromMailbox,
fetchOldMessagesFromMailbox,
fetchMessageById,
invalidateMessages,
insertMessage,
type MessageView,
} from '../../messages/store.js';
import { publishRealtimeEvent } from '../../realtime/publisher.js';
import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -42,36 +45,39 @@ export const messagesRouter = router({
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages] = await Promise.all([
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
]);
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState] = await Promise.all(
[
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
]
);
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
@@ -117,11 +123,118 @@ export const messagesRouter = router({
nationId: nationId,
generalName: general.name,
latestRead: {
diplomacy: 0,
private: 0,
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
},
};
}),
getContacts: authedProcedure
.input(z.object({ generalId: z.number().int().positive() }))
.query(async ({ ctx, input }) => {
await getOwnedGeneral(ctx, input.generalId);
const [nations, generals] = await Promise.all([
ctx.db.nation.findMany({
select: { id: true, name: true, color: true, meta: true },
orderBy: { id: 'asc' },
}),
ctx.db.general.findMany({
where: { npcState: { lt: 2 } },
select: {
id: true,
name: true,
nationId: true,
officerLevel: true,
npcState: true,
meta: true,
penalty: true,
},
orderBy: { id: 'asc' },
}),
]);
const nationMeta = new Map(nations.map((nation) => [nation.id, nation.meta]));
const grouped = new Map<number, Array<[number, string, number]>>();
for (const general of generals) {
let flags = 0;
if (general.officerLevel === 12) flags |= 1;
if (general.npcState === 1) flags |= 2;
if (resolveNationPermission(general, nationMeta.get(general.nationId) ?? {}, false) === 4) flags |= 4;
const list = grouped.get(general.nationId) ?? [];
list.push([general.id, general.name, flags]);
grouped.set(general.nationId, list);
}
const nationList = [
{ id: 0, name: '재야', color: '#000000', meta: {} },
...nations.filter((nation) => nation.id !== 0),
];
return {
nation: nationList.map((nation) => ({
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
name: nation.name,
color: nation.color,
general: grouped.get(nation.id) ?? [],
})),
};
}),
readLatest: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
type: z.enum(['private', 'diplomacy']),
messageId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const privateValue = input.type === 'private' ? input.messageId : 0;
const diplomacyValue = input.type === 'diplomacy' ? input.messageId : 0;
await ctx.db.$executeRaw`
INSERT INTO message_read_state (
general_id,
latest_private_message,
latest_diplomacy_message,
updated_at
)
VALUES (${general.id}, ${privateValue}, ${diplomacyValue}, NOW())
ON CONFLICT (general_id) DO UPDATE SET
latest_private_message = GREATEST(
message_read_state.latest_private_message,
EXCLUDED.latest_private_message
),
latest_diplomacy_message = GREATEST(
message_read_state.latest_diplomacy_message,
EXCLUDED.latest_diplomacy_message
),
updated_at = NOW()
`;
return { ok: true };
}),
delete: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
messageId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const message = await fetchMessageById(ctx.db, input.messageId);
if (!message) {
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
}
if (message.payload.src.generalId !== general.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
}
if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
}
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMessageId = message.payload.option?.receiverMessageID;
const ids = [message.id, ...(typeof receiverMessageId === 'number' ? [receiverMessageId] : [])];
await invalidateMessages(ctx.db, ids);
return { ok: true, deletedIds: ids };
}),
getOld: authedProcedure
.input(
z.object({
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { appRouter } from '../src/router.js';
import type { GameApiContext, GeneralRow } from '../src/context.js';
const general = {
id: 7,
userId: 'user-7',
name: '보낸이',
nationId: 1,
officerLevel: 5,
npcState: 0,
meta: {},
penalty: {},
} as GeneralRow;
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2027-01-01T00:00:00.000Z',
sessionId: 'session-7',
user: {
id: 'user-7',
username: 'tester',
displayName: 'Tester',
roles: ['user'],
},
sanctions: {},
};
const buildContext = (overrides: Record<string, unknown> = {}) => {
const executeRaw = vi.fn(async () => 1);
const updateMany = vi.fn(async () => ({ count: 1 }));
const db = {
general: {
findUnique: vi.fn(async () => general),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => null),
},
messageReadState: {
findUnique: vi.fn(async () => ({
generalId: general.id,
latestPrivateMessage: 11,
latestDiplomacyMessage: 13,
updatedAt: new Date(),
})),
},
message: { updateMany },
$queryRaw: vi.fn(async () => []),
$executeRaw: executeRaw,
...overrides,
};
const context = {
db,
auth,
profile: { id: 'che', scenario: 'default', name: 'che:default' },
redis: {},
turnDaemon: {},
battleSim: {},
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: {},
flushStore: {},
gameTokenSecret: 'test-secret',
} as unknown as GameApiContext;
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany };
};
describe('messages router missing-flow compatibility', () => {
it('returns persisted latest-read positions with recent messages', async () => {
const { caller } = buildContext();
const result = await caller.messages.getRecent({ generalId: general.id });
expect(result.latestRead).toEqual({ private: 11, diplomacy: 13 });
});
it('persists latest-read updates through the monotonic upsert', async () => {
const { caller, executeRaw } = buildContext();
await caller.messages.readLatest({
generalId: general.id,
type: 'private',
messageId: 17,
});
expect(executeRaw).toHaveBeenCalledOnce();
});
it('invalidates a recent owned message and its receiver copy', async () => {
const queryRaw = vi.fn(async () => [
{
id: 21,
mailbox: general.id,
type: 'private',
src: general.id,
dest: 8,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 8,
generalName: '받는이',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '삭제할 메시지',
option: { receiverMessageID: 22 },
},
},
]);
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [21, 22] } },
data: { validUntil: expect.any(Date) },
});
});
it('rejects deleting another general message', async () => {
const queryRaw = vi.fn(async () => [
{
id: 23,
mailbox: general.id,
type: 'private',
src: 99,
dest: general.id,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: { generalId: 99, generalName: '타인', nationId: 1, nationName: '위', color: '#fff', icon: '' },
dest: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
text: '타인 메시지',
option: {},
},
},
]);
const { caller } = buildContext({ $queryRaw: queryRaw });
await expect(caller.messages.delete({ generalId: general.id, messageId: 23 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
});
+43 -16
View File
@@ -36,7 +36,7 @@ import { WorldStateView } from './worldStateView.js';
import type { GeneralAIOptions, GeneralAiDebugState } from './types.js';
const ACTION_REST = '휴식';
const lastAttackableByNation = new Map<number, number>();
const lastAttackableByWorld = new WeakMap<object, Map<number, number>>();
const t무장 = 1;
const t지장 = 2;
@@ -129,11 +129,12 @@ export class GeneralAI {
private readonly reservedTurnProvider: AiReservedTurnProvider;
constructor(options: GeneralAIOptions) {
this.general = options.general;
this.general = { ...options.general, meta: { ...options.general.meta } };
this.city = options.city;
this.nation =
const nation =
options.nation ??
(options.general.nationId > 0 ? options.worldRef?.getNationById(options.general.nationId) ?? null : null);
(options.general.nationId > 0 ? (options.worldRef?.getNationById(options.general.nationId) ?? null) : null);
this.nation = nation ? { ...nation, meta: { ...nation.meta } } : nation;
this.world = options.world;
this.worldRef = options.worldRef;
this.map = options.map;
@@ -255,25 +256,39 @@ export class GeneralAI {
return null;
}
const npcMessage = asRecord(this.general.meta).npcmsg;
if (npcMessage && this.rng.nextBool((this.aiConst.npcMessageFreqByDay * this.turnTermMinutes) / (60 * 24))) {
// 메시지 영속화는 turn handler가 담당한다. 여기서는 레거시와 같은 RNG 소비를 보존한다.
}
if (this.general.npcState >= 2) {
this.general.meta = { ...this.general.meta, defence_train: 80 };
}
if (this.general.officerLevel === 12 && this.generalPolicy.can('선양')) {
const abdication = generalActionHandlers['선양']?.(this);
if (abdication) {
return abdication;
}
}
if (this.general.npcState === 5) {
if (this.general.nationId === 0) {
this.general.meta = { ...this.general.meta, killturn: 1 };
return { action: reservedTurn.action, args: reservedTurn.args, reason: '사망' };
}
const result = generalActionHandlers['집합']?.(this);
return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop');
}
if (reservedTurn.action !== ACTION_REST) {
const reservedCandidate = this.buildGeneralCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
if (reservedCandidate) {
return reservedCandidate;
}
return { action: reservedTurn.action, args: reservedTurn.args, reason: 'do예약턴' };
}
if (
readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold
) {
const heal = this.buildGeneralCandidate('che_요양', {}, 'heal');
if (heal) {
return heal;
}
return { action: 'che_요양', args: {}, reason: 'do요양' };
}
if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) {
@@ -293,7 +308,7 @@ export class GeneralAI {
}
if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) {
return this.buildGeneralCandidate(ACTION_REST, {}, 'neutral_user');
return { action: reservedTurn.action, args: reservedTurn.args, reason: '재야유저' };
}
if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) {
@@ -312,6 +327,12 @@ export class GeneralAI {
if (move) {
return move;
}
if (relYearMonth > 1) {
const disband = generalActionHandlers['해산']?.(this);
if (disband) {
return disband;
}
}
}
for (const actionName of this.generalPolicy.priority) {
@@ -757,11 +778,17 @@ export class GeneralAI {
const declareTerms = warTargets.filter((entry) => entry.state === 1).map((entry) => entry.term);
const minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null;
let lastAttackable = lastAttackableByNation.get(nationId) ??
readMetaNumber(asRecord(this.nation.meta), 'last_attackable', 0);
let worldLastAttackable = lastAttackableByWorld.get(this.world.meta);
if (!worldLastAttackable) {
worldLastAttackable = new Map();
lastAttackableByWorld.set(this.world.meta, worldLastAttackable);
}
let lastAttackable =
worldLastAttackable.get(nationId) ?? readMetaNumber(asRecord(this.nation.meta), 'last_attackable', 0);
const markAttackable = () => {
lastAttackable = yearMonth;
lastAttackableByNation.set(nationId, yearMonth);
worldLastAttackable.set(nationId, yearMonth);
this.nation!.meta = { ...this.nation!.meta, last_attackable: yearMonth };
};
if (minWarTerm === null) {
@@ -1,7 +1,20 @@
import type { GeneralAI } from '../core.js';
import { valueFit } from '../../aiUtils.js';
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
import { pickWeightedCandidate, resolveCityTrust, t무장, t지장, t통솔장 } from './helpers.js';
const isTechLimited = (ai: GeneralAI, tech: number): boolean => {
const relativeYear = Math.max(0, ai.world.currentYear - ai.startYear);
const levelIncreaseYears = ai.commandEnv.techLevelIncYear ?? 5;
const initialAllowedLevel = ai.commandEnv.initialAllowedTechLevel ?? 1;
const relativeMaxLevel = valueFit(
Math.floor(relativeYear / levelIncreaseYears) + initialAllowedLevel,
1,
ai.commandEnv.maxTechLevel
);
const currentLevel = valueFit(Math.floor(tech / 1000), 0, ai.commandEnv.maxTechLevel);
return currentLevel >= relativeMaxLevel;
};
export const do일반내정 = (ai: GeneralAI) => {
const city = ai.city;
const nation = ai.nation;
@@ -14,6 +27,7 @@ export const do일반내정 = (ai: GeneralAI) => {
}
const develRate = ai.calcCityDevelRate(city);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -64,7 +78,13 @@ export const do일반내정 = (ai: GeneralAI) => {
}
if (ai.genType & t지장) {
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), ai.general.stats.intelligence]);
if (!isTechLimited(ai, tech)) {
const nextTech = (tech % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000)
? ai.general.stats.intelligence / (nextTech / 2000)
: ai.general.stats.intelligence;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), weight]);
}
if (develRate.agri[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'),
@@ -119,6 +139,7 @@ export const do전쟁내정 = (ai: GeneralAI) => {
return null;
}
const develRate = ai.calcCityDevelRate(city);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -130,10 +151,9 @@ export const do전쟁내정 = (ai: GeneralAI) => {
]);
}
if (develRate.pop[0] < 0.8) {
const weight =
city.frontState > 0
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
const weight = [1, 3].includes(city.frontState)
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]);
}
}
@@ -160,27 +180,31 @@ export const do전쟁내정 = (ai: GeneralAI) => {
}
if (ai.genType & t지장) {
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), ai.general.stats.intelligence]);
if (!isTechLimited(ai, tech)) {
const nextTech = (tech % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000)
? ai.general.stats.intelligence / (nextTech / 3000)
: ai.general.stats.intelligence;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), weight]);
}
if (develRate.agri[0] < 0.5) {
const weight =
city.frontState > 0
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.agri[0], 0.001, 1)
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.agri[0], 0.001, 1);
const weight = [1, 3].includes(city.frontState)
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.agri[0], 0.001, 1)
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.agri[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]);
}
if (develRate.comm[0] < 0.5) {
const weight =
city.frontState > 0
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.comm[0], 0.001, 1)
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.comm[0], 0.001, 1);
const weight = [1, 3].includes(city.frontState)
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.comm[0], 0.001, 1)
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.comm[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]);
}
}
@@ -5,7 +5,7 @@ import { do징병 } from './recruitActions.js';
import { do전투준비, do소집해제, do출병 } from './warActions.js';
import { do후방워프, do전방워프, do내정워프, do귀환, do집합 } from './warpActions.js';
import { doNPC헌납, doNPC사망대비 } from './npcActions.js';
import { do국가선택, do중립, do거병, do건국, do방랑군이동 } from './politicsActions.js';
import { do국가선택, do중립, do거병, do건국, do해산, do선양, do방랑군이동 } from './politicsActions.js';
export {
do일반내정,
@@ -27,6 +27,8 @@ export {
do중립,
do거병,
do건국,
do해산,
do선양,
do방랑군이동,
};
@@ -53,5 +55,7 @@ export const generalActionHandlers: Record<
집합: do집합,
거병: do거병,
건국: do건국,
해산: do해산,
선양: do선양,
방랑군이동: do방랑군이동,
};
@@ -18,6 +18,27 @@ export const do국가선택 = (ai: GeneralAI) => {
}
if (ai.rng.nextBool(0.3)) {
const affinity = ai.general.affinity ?? readMetaNumber(asRecord(ai.general.meta), 'affinity', 0);
if (affinity === 999) {
return null;
}
if (ai.world.currentYear < ai.startYear + 3) {
const nations = ai.worldRef.listNations();
const nationCount = nations.length;
const notFullNationCount = nations.filter((nation) => {
const count = ai.worldRef!.listGenerals().filter((general) => general.nationId === nation.id).length;
return count < ai.commandEnv.initialNationGenLimit;
}).length;
if (nationCount === 0 || notFullNationCount === 0) {
return null;
}
const rejectProbability = Math.pow(1 / (nationCount + 1) / Math.pow(notFullNationCount, 3), 1 / 4);
if (ai.rng.nextBool(rejectProbability)) {
return null;
}
} else if (ai.rng.nextBool()) {
return null;
}
return ai.buildGeneralCandidate('che_랜덤임관', {}, '국가선택');
}
@@ -47,13 +68,15 @@ export const do중립 = (ai: GeneralAI) => {
candidates = ['che_물자조달'];
}
for (const key of candidates) {
const cmd = ai.buildGeneralCandidate(key, {}, '중립');
if (cmd) {
return cmd;
}
const picked = ai.buildGeneralCandidate(ai.rng.choice(candidates), {}, '중립');
if (picked) {
return picked;
}
return ai.buildGeneralCandidate(ACTION_REST, {}, '중립');
const supply = ai.buildGeneralCandidate('che_물자조달', {}, '중립');
if (supply) {
return supply;
}
return ai.buildGeneralCandidate('che_견문', {}, '중립') ?? ai.buildGeneralCandidate(ACTION_REST, {}, '중립');
};
export const do거병 = (ai: GeneralAI) => {
@@ -111,13 +134,18 @@ export const do거병 = (ai: GeneralAI) => {
}
const prop = (ai.rng.nextFloat1() * (ai.aiConst.defaultStatNpcMax + ai.aiConst.chiefStatMin)) / 2;
const ratio = (ai.general.stats.leadership + ai.general.stats.strength + ai.general.stats.intelligence) / 3;
const generalMeta = asRecord(ai.general.meta);
const ratio =
(readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership) +
readMetaNumber(generalMeta, 'fullStrength', ai.general.stats.strength) +
readMetaNumber(generalMeta, 'fullIntelligence', ai.general.stats.intelligence)) /
3;
if (prop >= ratio) {
return null;
}
const relYear = Math.max(0, ai.world.currentYear - ai.startYear);
const more = valueFit(3 - relYear, 1, 3);
const initYear = readMetaNumber(asRecord(ai.world.meta), 'initYear', ai.startYear);
const more = valueFit(3 - ai.world.currentYear + initYear, 1, 3);
if (!ai.rng.nextBool(0.0075 * more)) {
return null;
}
@@ -132,10 +160,39 @@ export const do건국 = (ai: GeneralAI) => {
ai.aiConst.availableNationTypes.length > 0
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
: `${prefix}def`;
const colorType = ai.rng.nextRangeInt(0, 34);
const nationName = ai.general.name;
const colorType = ai.rng.nextRangeInt(0, 32);
const nationName = `${Array.from(ai.general.name).slice(1).join('')}`;
return ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
const result = ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
if (result) {
const nextMeta = { ...ai.general.meta };
delete nextMeta.movingTargetCityID;
ai.general.meta = nextMeta;
}
return result;
};
export const do해산 = (ai: GeneralAI) => {
const result = ai.buildGeneralCandidate('che_해산', {}, '해산');
if (result) {
const nextMeta = { ...ai.general.meta };
delete nextMeta.movingTargetCityID;
ai.general.meta = nextMeta;
}
return result;
};
export const do선양 = (ai: GeneralAI) => {
if (!ai.worldRef) {
return null;
}
const candidates = ai.worldRef
.listGenerals()
.filter((general) => general.nationId === ai.general.nationId && general.npcState !== 5);
if (candidates.length === 0) {
return null;
}
return ai.buildGeneralCandidate('che_선양', { destGeneralID: ai.rng.choice(candidates).id }, '선양');
};
export const do방랑군이동 = (ai: GeneralAI) => {
@@ -143,37 +200,76 @@ export const do방랑군이동 = (ai: GeneralAI) => {
if (!city || !ai.map || !ai.worldRef) {
return null;
}
const lordCities = ai.worldRef
.listGenerals()
.filter((general) => general.officerLevel === 12 && general.nationId === 0)
.map((general) => general.cityId);
if (lordCities.filter((cityId) => cityId === city.id).length <= 1 && [5, 6].includes(city.level)) {
return null;
}
const occupied = new Set(
ai.worldRef
.listCities()
.filter((c) => c.nationId !== 0)
.map((c) => c.id)
.filter((candidate) => candidate.nationId !== 0)
.map((candidate) => candidate.id)
);
for (const general of ai.worldRef.listGenerals()) {
if (general.officerLevel === 12 && general.nationId === 0) {
occupied.add(general.cityId);
}
for (const cityId of lordCities) {
occupied.add(cityId);
}
const nearby = searchDistance(ai.map, city.id, 4);
const candidates: Array<[number, number]> = [];
for (const [cityIdRaw, dist] of Object.entries(nearby)) {
const cityId = Number(cityIdRaw);
if (!Number.isFinite(cityId) || occupied.has(cityId)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
if (!target || target.level < 5 || target.level > 6) {
continue;
}
candidates.push([cityId, 1 / Math.pow(2, dist)]);
let movingTargetCityId = readMetaNumber(asRecord(ai.general.meta), 'movingTargetCityID', 0) || null;
if (movingTargetCityId === city.id || (movingTargetCityId !== null && occupied.has(movingTargetCityId))) {
movingTargetCityId = null;
}
if (candidates.length === 0) {
return null;
if (movingTargetCityId === null) {
const nearby = searchDistance(ai.map, city.id, 4);
const candidates: Array<[number, number]> = [];
for (const [cityIdRaw, dist] of Object.entries(nearby)) {
const cityId = Number(cityIdRaw);
if (!Number.isFinite(cityId) || occupied.has(cityId)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
if (!target || target.level < 5 || target.level > 6) {
continue;
}
candidates.push([cityId, 1 / Math.pow(2, dist)]);
}
if (candidates.length === 0) {
return null;
}
movingTargetCityId = ai.rng.choiceUsingWeightPair(candidates);
ai.general.meta = { ...ai.general.meta, movingTargetCityID: movingTargetCityId };
}
const destCityId = ai.rng.choiceUsingWeightPair(candidates);
if (destCityId === city.id) {
if (movingTargetCityId === city.id) {
return ai.buildGeneralCandidate('che_인재탐색', {}, '방랑군이동');
}
return ai.buildGeneralCandidate('che_이동', { destCityId }, '방랑군이동');
const distanceMap = searchDistance(ai.map, movingTargetCityId, 99);
const targetDistance = distanceMap[city.id];
if (targetDistance === undefined) {
return null;
}
const neighbors = ai.map.cities.find((candidate) => candidate.id === city.id)?.connections ?? [];
const nextCandidates: Array<[number, number]> = [];
for (const nextCityId of neighbors) {
const nextCity = ai.worldRef.getCityById(nextCityId);
if (nextCity && [5, 6].includes(nextCity.level) && !occupied.has(nextCityId)) {
nextCandidates.push([nextCityId, 10]);
}
if (distanceMap[nextCityId] !== undefined && distanceMap[nextCityId] + 1 === targetDistance) {
nextCandidates.push([nextCityId, 1]);
}
}
if (nextCandidates.length === 0) {
return null;
}
return ai.buildGeneralCandidate(
'che_이동',
{ destCityId: ai.rng.choiceUsingWeightPair(nextCandidates) },
'방랑군이동'
);
};
@@ -9,7 +9,7 @@ import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic';
import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
import { t통솔장 } from './helpers.js';
import { t무장, t지장, t통솔장 } from './helpers.js';
export const buildRecruitArmTypeWeights = (general: General, armTypes: WarArmTypes): Array<[number, number]> => {
const meta = asRecord(general.meta);
@@ -45,7 +45,7 @@ export const do징병 = (ai: GeneralAI) => {
if (!city || !nation || !ai.unitSet || !ai.map) {
return null;
}
if ([0, 1].includes(ai.dipState) && ai.general.npcState < 2) {
if ([0, 1].includes(ai.dipState)) {
return null;
}
if (!(ai.genType & t통솔장)) {
@@ -55,9 +55,10 @@ export const do징병 = (ai: GeneralAI) => {
return null;
}
const generalMeta = asRecord(ai.general.meta);
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
if (!ai.generalPolicy.can('한계징병')) {
const remainPop =
city.population - ai.nationPolicy.minNpcRecruitCityPopulation - ai.general.stats.leadership * 100;
const remainPop = city.population - ai.nationPolicy.minNpcRecruitCityPopulation - fullLeadership * 100;
if (remainPop <= 0) {
return null;
}
@@ -71,9 +72,16 @@ export const do징병 = (ai: GeneralAI) => {
}
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const crewAmountBase = ai.general.stats.leadership * 100;
const crewAmountBase = fullLeadership * 100;
const warConfig = buildWarConfig(ai.scenarioConfig, ai.unitSet);
const forcedArmType = readMetaNumber(asRecord(ai.general.meta), 'armType', 0);
let forcedArmType = readMetaNumber(asRecord(ai.general.meta), 'armType', 0);
if (
(forcedArmType === warConfig.armTypes.wizard && !(ai.genType & t지장)) ||
([warConfig.armTypes.footman, warConfig.armTypes.archer, warConfig.armTypes.cavalry].includes(forcedArmType) &&
!(ai.genType & t무장))
) {
forcedArmType = 0;
}
const armType =
forcedArmType > 0
? forcedArmType
@@ -123,25 +131,31 @@ export const do징병 = (ai: GeneralAI) => {
let crewAmount = crewAmountBase;
const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
const riceCost = crewAmount / 100;
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
let riceCost = (picked.rice * getTechCost(tech) * expectedCrewLoss) / 100;
if (ai.general.gold <= 0 || ai.general.rice <= 0) {
const remainingGold = ai.general.gold - fullLeadership * 3;
const remainingRice = ai.general.rice - fullLeadership * 4;
if (remainingGold <= 0 || remainingRice <= 0) {
return null;
}
if (ai.generalPolicy.can('모병') && ai.general.gold >= goldCost * 6) {
if (ai.generalPolicy.can('모병') && remainingGold >= goldCost * 6) {
const hire = ai.buildGeneralCandidate('che_모병', { crewType: crewTypeId, amount: crewAmount }, '징병');
if (hire) {
return hire;
}
}
if (ai.general.gold < goldCost && ai.general.gold * 2 >= goldCost) {
if (remainingGold < goldCost && remainingGold * 2 >= goldCost) {
crewAmount *= 0.5;
riceCost *= 0.5;
crewAmount = roundTo(crewAmount - 49, -2);
}
if (!ai.generalPolicy.can('한계징병') && ai.general.rice * 1.1 <= riceCost) {
if (!ai.generalPolicy.can('한계징병') && remainingRice * 1.1 <= riceCost) {
return null;
}
@@ -3,7 +3,7 @@ import { valueFit } from '../../aiUtils.js';
import { pickWeightedCandidate } from './helpers.js';
export const do전투준비 = (ai: GeneralAI) => {
if ([0, 1].includes(ai.dipState) && ai.general.crew <= 0) {
if ([0, 1].includes(ai.dipState)) {
return null;
}
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -1,4 +1,5 @@
import type { GeneralAI } from '../core.js';
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
import { t통솔장 } from './helpers.js';
export const do후방워프 = (ai: GeneralAI) => {
@@ -9,6 +10,9 @@ export const do후방워프 = (ai: GeneralAI) => {
if ([0, 1].includes(ai.dipState)) {
return null;
}
if (!ai.generalPolicy.can('징병')) {
return null;
}
if (!(ai.genType & t통솔장)) {
return null;
}
@@ -104,6 +108,7 @@ export const do전방워프 = (ai: GeneralAI) => {
}
ai.categorizeNationCities();
ai.categorizeNationGeneral();
const candidateCities: Record<number, number> = {};
for (const frontCity of Object.values(ai.frontCities)) {
if (frontCity.supplyState <= 0) {
@@ -195,4 +200,11 @@ export const do귀환 = (ai: GeneralAI) => {
return ai.buildGeneralCandidate('che_귀환', {}, '귀환');
};
export const do집합 = (ai: GeneralAI) => ai.buildGeneralCandidate('che_집합', {}, '집합');
export const do집합 = (ai: GeneralAI) => {
if (ai.general.npcState === 5) {
const killturn = readRequiredMetaNumber(asRecord(ai.general.meta), 'killturn', `generalId=${ai.general.id}`);
const nextKillturn = ((killturn + ai.rng.nextRangeInt(2, 4)) % 5) + 70;
ai.general.meta = { ...ai.general.meta, killturn: nextKillturn };
}
return ai.buildGeneralCandidate('che_집합', {}, '집합');
};
@@ -14,7 +14,25 @@ export const do천도 = (ai: GeneralAI) => {
return null;
}
const cityIds = nationCities.map((city) => city.id);
const nationCityIds = new Set(nationCities.map((city) => city.id));
const connectedCityIds = new Set<number>([ai.nation.capitalCityId]);
const queue = [ai.nation.capitalCityId];
while (queue.length > 0) {
const cityId = queue.shift()!;
const connections = ai.map.cities.find((city) => city.id === cityId)?.connections ?? [];
for (const nextCityId of connections) {
if (!nationCityIds.has(nextCityId) || connectedCityIds.has(nextCityId)) {
continue;
}
connectedCityIds.add(nextCityId);
queue.push(nextCityId);
}
}
if (connectedCityIds.size <= 1) {
return null;
}
const cityIds = Array.from(connectedCityIds);
const distanceList = searchAllDistanceByCityList(ai.map, cityIds);
const capitalId = ai.nation.capitalCityId;
if (!distanceList[capitalId]) {
@@ -28,7 +46,7 @@ export const do천도 = (ai: GeneralAI) => {
}
const cityScores: Record<number, number> = {};
for (const city of nationCities) {
for (const city of nationCities.filter((candidate) => connectedCityIds.has(candidate.id))) {
const sumDistance = Object.values(distanceList[city.id] ?? {}).reduce((acc, value) => acc + value, 0);
if (sumDistance <= 0) {
continue;
@@ -39,7 +57,7 @@ export const do천도 = (ai: GeneralAI) => {
const sorted = Object.entries(cityScores).sort((a, b) => b[1] - a[1]);
const topLimit = Math.ceil(sorted.length * 0.25);
for (let idx = 0; idx < Math.min(topLimit, sorted.length); idx += 1) {
for (let idx = 0; idx <= Math.min(topLimit, sorted.length - 1); idx += 1) {
if (Number(sorted[idx][0]) === capitalId) {
return null;
}
@@ -62,5 +80,5 @@ export const do천도 = (ai: GeneralAI) => {
}
}
return ai.buildNationCandidate('che_천도', { destCityId: targetCityId }, '천도');
return ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도');
};
@@ -3,6 +3,18 @@ import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../a
import { isNeighbor } from '../../distance.js';
import { resolveNationIncome } from './helpers.js';
const isTechLimited = (ai: GeneralAI, tech: number): boolean => {
const relativeYear = Math.max(0, ai.world.currentYear - ai.startYear);
const levelIncreaseYears = ai.commandEnv.techLevelIncYear ?? 5;
const initialAllowedLevel = ai.commandEnv.initialAllowedTechLevel ?? 1;
const relativeMaxLevel = Math.max(
1,
Math.min(Math.floor(relativeYear / levelIncreaseYears) + initialAllowedLevel, ai.commandEnv.maxTechLevel)
);
const techLevel = Math.max(0, Math.min(Math.floor(tech / 1000), ai.commandEnv.maxTechLevel));
return techLevel >= relativeMaxLevel;
};
export const do불가침제의 = (ai: GeneralAI) => {
if (!ai.nation || ai.general.officerLevel < 12) {
return null;
@@ -66,11 +78,16 @@ export const do불가침제의 = (ai: GeneralAI) => {
}
const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth));
return ai.buildNationCandidate(
const result = ai.buildNationCandidate(
'che_불가침제의',
{ destNationId, year: targetYear, month: targetMonth },
'불가침제의'
);
if (result) {
const nextTry = { ...respAssistTry, [`n${destNationId}`]: [destNationId, yearMonth] };
asRecord(ai.nation.meta).resp_assist_try = nextTry;
}
return result;
};
export const do선전포고 = (ai: GeneralAI) => {
@@ -92,6 +109,10 @@ export const do선전포고 = (ai: GeneralAI) => {
if (!ai.map || !ai.worldRef) {
return null;
}
const currentTech = readMetaNumber(asRecord(ai.nation.meta), 'tech', 0);
if (!isTechLimited(ai, currentTech + 1000)) {
return null;
}
const avgResources = Object.values({
...ai.npcWarGenerals,
@@ -134,9 +155,26 @@ export const do선전포고 = (ai: GeneralAI) => {
return null;
}
const lowTargetNations = new Set(
ai.worldRef
.listDiplomacy()
.filter((entry) => entry.fromNationId !== currentNationId && (entry.state === 0 || entry.state === 1))
.map((entry) => entry.fromNationId)
);
const weight: Record<number, number> = {};
const warWeight: Record<number, number> = {};
for (const nation of neighbors) {
weight[nation.id] = 1 / Math.sqrt(nation.power + 1);
const target = lowTargetNations.has(nation.id) ? warWeight : weight;
target[nation.id] = 1 / Math.sqrt(nation.power + 1);
}
if (Object.keys(weight).length === 0) {
if (Object.keys(warWeight).length === 0 || lowTargetNations.size === 0) {
return null;
}
if (ai.rng.nextBool(1 / lowTargetNations.size)) {
return null;
}
Object.assign(weight, warWeight);
}
const destNationId = Number(ai.rng.choiceUsingWeight(weight));
@@ -3,7 +3,10 @@ import type { City } from '@sammo-ts/logic';
import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber } from '../../aiUtils.js';
export const pickWeightedCandidate = (ai: GeneralAI, list: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]>) => {
export const pickWeightedCandidate = (
ai: GeneralAI,
list: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]>
) => {
const items = list.filter(([item]) => Boolean(item)) as Array<
[ReturnType<GeneralAI['buildNationCandidate']>, number]
>;
@@ -59,23 +62,21 @@ export const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record<num
export const buildAssignmentCandidate = (ai: GeneralAI, destGeneralId: number, destCityId: number, reason: string) =>
ai.buildNationCandidate('che_발령', { destGeneralId, destCityId }, reason);
export const buildSeizureCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason);
export const buildSeizureCandidate = (
ai: GeneralAI,
destGeneralId: number,
amount: number,
isGold: boolean,
reason: string
) => ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason);
export const buildAwardCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
export const resolveAwardAmount = (ai: GeneralAI, current: number, target: number): number | null => {
const diff = target - current;
if (diff <= 0) {
return null;
}
const amount = Math.min(diff, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
return null;
}
return amount;
};
export const buildAwardCandidate = (
ai: GeneralAI,
destGeneralId: number,
amount: number,
isGold: boolean,
reason: string
) => ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
export const resolveNationIncome = (ai: GeneralAI): number => {
const cities = Object.values(ai.supplyCities);
@@ -1,6 +1,34 @@
import type { GeneralAI } from '../core.js';
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate, resolveAwardAmount } from './helpers.js';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import type { TurnGeneral } from '../../../types.js';
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate } from './helpers.js';
type ResourceName = 'gold' | 'rice';
const clampLegacy = (value: number, min: number | null, max: number | null): number => {
if (min !== null && max !== null && max < min) {
return min;
}
return Math.max(min ?? -Infinity, Math.min(max ?? Infinity, value));
};
const getFullLeadership = (general: TurnGeneral): number =>
readMetaNumber(asRecord(general.meta), 'fullLeadership', general.stats.leadership);
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, multiplier: number): number => {
const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0);
return (crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(general) * multiplier;
};
const sortedByResource = (generals: Record<number, TurnGeneral>, resource: ResourceName, descending = false) =>
Object.values(generals).sort((lhs, rhs) =>
descending ? rhs[resource] - lhs[resource] : lhs[resource] - rhs[resource]
);
const canUseGeneral = (general: TurnGeneral): boolean =>
readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`) > 5;
export const do유저장긴급포상 = (ai: GeneralAI) => {
const nation = ai.nation;
@@ -8,24 +36,38 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [
const resourceMap: Array<[ResourceName, number]> = [
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
];
for (const [resKey, required] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.userWarGenerals)) {
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
for (const [resKey, minimum] of resourceMap) {
const generals = sortedByResource(ai.userWarGenerals, resKey);
for (const [index, general] of generals.entries()) {
if (general[resKey] >= minimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'), amount]);
let required = getCrewGoldCost(ai, general, 3 * 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, minimum);
}
const enough = required * 1.1;
if (general[resKey] >= required) {
continue;
}
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, null, enough - general[resKey]);
if (amount < ai.nationPolicy.minimumResourceActionAmount || nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'),
generals.length - index,
]);
}
}
@@ -38,24 +80,56 @@ export const do유저장포상 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [
['gold', ai.nationPolicy.reqHumanWarRecommandGold],
['rice', ai.nationPolicy.reqHumanWarRecommandRice],
const resourceMap: Array<[ResourceName, number, number, number]> = [
[
'gold',
ai.nationPolicy.reqNationGold,
ai.nationPolicy.reqHumanWarRecommandGold,
ai.nationPolicy.reqHumanDevelGold,
],
[
'rice',
ai.nationPolicy.reqNationRice,
ai.nationPolicy.reqHumanWarRecommandRice,
ai.nationPolicy.reqHumanDevelRice,
],
];
for (const [resKey, required] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
if (nation[resKey] < nationMinimum) {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.userWarGenerals)) {
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
const generals = sortedByResource(ai.userGenerals, resKey);
for (const [index, general] of generals.entries()) {
if (general[resKey] >= warMinimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'), amount]);
let enough: number;
if (ai.userWarGenerals[general.id]) {
let required = getCrewGoldCost(ai, general, 6 * 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, warMinimum);
}
enough = required * 1.2;
} else {
enough = civilMinimum * 1.2;
}
if (general[resKey] >= enough) {
continue;
}
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, nation[resKey] - nationMinimum, enough - general[resKey]);
if (amount < ai.nationPolicy.minimumResourceActionAmount || nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'),
generals.length - index,
]);
}
}
@@ -68,28 +142,41 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [
['gold', ai.nationPolicy.reqNpcWarGold / 2],
['rice', ai.nationPolicy.reqNpcWarRice / 2],
const resourceMap: Array<[ResourceName, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
];
for (const [resKey, required] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
for (const [resKey, nationMinimum, minimum] of resourceMap) {
if (nation[resKey] < nationMinimum) {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.npcWarGenerals)) {
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`);
if (killturn <= 5) {
const generals = sortedByResource(ai.npcWarGenerals, resKey);
for (const [index, general] of generals.entries()) {
if (general[resKey] >= minimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
let required = getCrewGoldCost(ai, general, 1.5);
if (ai.world.currentYear > ai.startYear + 5) {
required = Math.max(required, minimum);
}
const enough = required * 1.2;
if (general[resKey] >= required) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'), amount]);
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, nation[resKey] - nationMinimum * 0.9, enough - general[resKey]);
if (amount < ai.nationPolicy.minimumResourceActionAmount || nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'),
generals.length - index,
]);
}
}
@@ -102,39 +189,60 @@ export const doNPC포상 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number, number]> = [
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
];
for (const [resKey, warReq, devReq] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
if (nation[resKey] < nationMinimum) {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
const warGenerals = sortedByResource(ai.npcWarGenerals, resKey);
const civilGenerals = sortedByResource(ai.npcCivilGenerals, resKey);
const weightBase = Math.max(warGenerals.length, civilGenerals.length);
for (const [index, general] of warGenerals.entries()) {
if (general[resKey] >= warMinimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
let required = getCrewGoldCost(ai, general, 3 * 1.1);
if (ai.world.currentYear > ai.startYear + 5) {
required = Math.max(required, warMinimum);
}
const enough = required * 1.5;
if (general[resKey] >= required) {
continue;
}
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, nation[resKey] - nationMinimum, enough - general[resKey]);
if (nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
weightBase - index,
]);
}
for (const general of Object.values(ai.npcWarGenerals)) {
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`);
if (killturn <= 5) {
for (const [index, general] of civilGenerals.entries()) {
if (general[resKey] >= civilMinimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], warReq);
if (!amount) {
let amount = civilMinimum * 1.5 - general[resKey];
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
}
for (const general of Object.values(ai.npcCivilGenerals)) {
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`);
if (killturn <= 5) {
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], devReq);
if (!amount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
weightBase - index,
]);
}
}
@@ -147,41 +255,46 @@ export const doNPC몰수 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number, number]> = [
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
];
for (const [resKey, warReq, devReq] of resourceMap) {
const nationLimit = resKey === 'gold' ? ai.nationPolicy.reqNationGold : ai.nationPolicy.reqNationRice;
const nationEnough = nation[resKey] >= nationLimit;
for (const general of Object.values(ai.npcCivilGenerals)) {
if (general[resKey] <= devReq * 1.5) {
continue;
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
for (const general of sortedByResource(ai.npcCivilGenerals, resKey, true)) {
if (general[resKey] <= civilMinimum * 1.5) {
break;
}
const amount = Math.min(general[resKey] - devReq * 1.2, ai.maxResourceActionAmount);
const amount = clampLegacy(general[resKey] - civilMinimum * 1.2, 100, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue;
break;
}
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
}
if (!nationEnough) {
for (const general of Object.values(ai.npcWarGenerals)) {
const minRes = nation[resKey] < nationLimit * 0.5 ? warReq * 2 : warReq;
if (general[resKey] <= minRes) {
continue;
}
const amount = Math.min(general[resKey] - minRes, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue;
}
candidates.push([
buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'),
amount,
]);
const nationDelta = nationMinimum * 1.5 - nation[resKey];
if (nationDelta < 0) {
continue;
}
const takeSmallAmount = nation[resKey] >= nationMinimum;
for (const general of sortedByResource(ai.npcWarGenerals, resKey, true)) {
if (general[resKey] <= warMinimum * (takeSmallAmount ? 2 : 1)) {
break;
}
let amount: number;
if (takeSmallAmount) {
const maxAmount = general[resKey] - warMinimum;
const minAmount = general[resKey] - warMinimum * 2;
amount = clampLegacy(Math.sqrt(minAmount * nationDelta), 0, maxAmount);
} else {
const maxAmount = general[resKey] - warMinimum;
amount = clampLegacy(Math.sqrt(maxAmount * nationDelta), 0, maxAmount);
}
if (amount < 100 || amount < ai.nationPolicy.minimumResourceActionAmount) {
break;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
}
}
+2 -2
View File
@@ -280,7 +280,7 @@ export class AutorunNationPolicy {
if (this.reqNpcWarGold === 0 || this.reqNpcWarRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.npcMax : 0;
const baseRice = stat.npcMax;
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.npcMax : 0;
if (this.reqNpcWarGold === 0) {
this.reqNpcWarGold = roundTo(baseGold * 4, -2);
}
@@ -292,7 +292,7 @@ export class AutorunNationPolicy {
if (this.reqHumanWarUrgentGold === 0 || this.reqHumanWarUrgentRice === 0) {
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.max : 0;
const baseRice = stat.max;
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.max : 0;
if (this.reqHumanWarUrgentGold === 0) {
this.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
}
@@ -492,6 +492,7 @@ export const createDatabaseTurnHooks = async (
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId,
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold,
rice: nation.rice,
level: nation.level,
+1 -1
View File
@@ -257,7 +257,7 @@ const mapNationRow = (row: TurnEngineNationRow): Nation => ({
name: row.name,
color: row.color,
capitalCityId: row.capitalCityId,
chiefGeneralId: null,
chiefGeneralId: row.chiefGeneralId,
gold: row.gold,
rice: row.rice,
power: 0,
@@ -0,0 +1,570 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '@sammo-ts/logic';
import type { GeneralAI } from '../src/turn/ai/generalAi.js';
import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general/devActions.js';
import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.js';
import { do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js';
import { do징병 } from '../src/turn/ai/generalAi/general/recruitActions.js';
import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warActions.js';
import { do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
import { doNPC몰수, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js';
type Candidate = {
action: string;
args: Record<string, unknown>;
reason: string;
};
type ScriptedRng = {
bools: boolean[];
choices: unknown[];
weightedPairs: Array<Array<[unknown, number]>>;
nextBool: (probability?: number) => boolean;
nextFloat1: () => number;
nextRangeInt: (min: number, max: number) => number;
choice: <T>(items: T[] | Record<string, T>) => T;
choiceUsingWeight: <T extends string | number>(items: Record<T, number>) => T;
choiceUsingWeightPair: <T>(items: Array<[T, number]>) => T;
};
const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng => {
const scriptedChoices = [...choices];
return {
bools: [...bools],
choices: scriptedChoices,
weightedPairs: [],
nextBool() {
return this.bools.shift() ?? false;
},
nextFloat1() {
return 0;
},
nextRangeInt(min) {
const picked = scriptedChoices.shift();
return typeof picked === 'number' ? picked : min;
},
choice<T>(items: T[] | Record<string, T>): T {
const values = Array.isArray(items) ? items : Object.values(items);
const picked = scriptedChoices.shift();
if (typeof picked === 'number' && Number.isInteger(picked) && picked >= 0 && picked < values.length) {
return values[picked]!;
}
if (picked !== undefined && values.includes(picked as T)) {
return picked as T;
}
return values[0]!;
},
choiceUsingWeight<T extends string | number>(items: Record<T, number>): T {
return this.choice(
Object.keys(items).map((key) => {
const numeric = Number(key);
return (Number.isNaN(numeric) ? key : numeric) as T;
})
);
},
choiceUsingWeightPair<T>(items: Array<[T, number]>): T {
this.weightedPairs.push(items);
const picked = scriptedChoices.shift();
if (typeof picked === 'number' && Number.isInteger(picked) && picked >= 0 && picked < items.length) {
return items[picked]![0];
}
return items[0]![0];
},
};
};
const baseGeneral = (): General => ({
id: 1,
name: '가상장수',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 70, intelligence: 70 },
experience: 0,
dedication: 0,
officerLevel: 1,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
injury: 0,
gold: 10_000,
rice: 10_000,
crew: 0,
crewTypeId: 1,
train: 0,
atmos: 0,
age: 30,
npcState: 2,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 100, fullLeadership: 70 },
});
const baseCity = (): City => ({
id: 1,
name: '가상도시',
nationId: 1,
level: 5,
state: 0,
population: 100_000,
populationMax: 100_000,
agriculture: 10_000,
agricultureMax: 10_000,
commerce: 10_000,
commerceMax: 10_000,
security: 10_000,
securityMax: 10_000,
supplyState: 1,
frontState: 0,
defence: 10_000,
defenceMax: 10_000,
wall: 10_000,
wallMax: 10_000,
meta: { trust: 100, trade: 100 },
});
const baseNation = (): Nation => ({
id: 1,
name: '가상국',
color: '#ffffff',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 100_000,
rice: 100_000,
power: 100,
level: 1,
typeCode: 'che_중립',
meta: { tech: 0 },
});
const makeAi = (
overrides: {
general?: Partial<General>;
city?: Partial<City>;
nation?: Partial<Nation>;
dipState?: number;
attackable?: boolean;
genType?: number;
year?: number;
startYear?: number;
rng?: ScriptedRng;
blockedActions?: string[];
nations?: Nation[];
generals?: General[];
disabledPolicyActions?: string[];
} = {}
): GeneralAI => {
const general = {
...baseGeneral(),
...overrides.general,
meta: { ...baseGeneral().meta, ...overrides.general?.meta },
};
const city = { ...baseCity(), ...overrides.city, meta: { ...baseCity().meta, ...overrides.city?.meta } };
const nation = {
...baseNation(),
...overrides.nation,
meta: { ...baseNation().meta, ...overrides.nation?.meta },
};
const rng = overrides.rng ?? makeRng();
const blocked = new Set(overrides.blockedActions ?? []);
const disabledPolicyActions = new Set(overrides.disabledPolicyActions ?? []);
const nations = overrides.nations ?? [nation];
const generals = overrides.generals ?? [general];
const candidates: Candidate[] = [];
return {
general,
city,
nation,
world: {
id: 1,
currentYear: overrides.year ?? 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0190-01-01T00:00:00Z'),
meta: { seed: 1 },
},
worldRef: {
listNations: () => nations,
listGenerals: () => generals,
listCities: () => [city],
listTroops: () => [],
listDiplomacy: () => [],
getNationById: (id: number) => nations.find((item) => item.id === id) ?? null,
getGeneralById: (id: number) => generals.find((item) => item.id === id) ?? null,
getCityById: (id: number) => (city.id === id ? city : null),
getTroopById: () => null,
getDiplomacyEntry: () => null,
},
map: {
id: 'test',
name: 'test',
cities: [
{
id: 1,
name: '가상도시',
level: 5,
region: 1,
position: { x: 0, y: 0 },
connections: [2],
max: {
population: 100_000,
agriculture: 10_000,
commerce: 10_000,
security: 10_000,
defence: 10_000,
wall: 10_000,
},
initial: {
population: 100_000,
agriculture: 10_000,
commerce: 10_000,
security: 10_000,
defence: 10_000,
wall: 10_000,
},
},
],
defaults: { trust: 100, trade: 100, supplyState: 1, frontState: 0 },
},
unitSet: {
id: 'test',
name: 'test',
defaultCrewTypeId: 1,
crewTypes: [
{
id: 1,
armType: 1,
name: '보병',
attack: 10,
defence: 10,
speed: 10,
avoid: 0,
magicCoef: 0,
cost: 10,
rice: 1,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
},
scenarioConfig: {
stat: { total: 300, min: 1, max: 100, npcTotal: 150, npcMax: 50, npcMin: 1, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'test' },
},
startYear: overrides.startYear ?? 180,
commandEnv: {
baseGold: 1000,
baseRice: 1000,
develCost: 10,
maxResourceActionAmount: 10_000,
minAvailableRecruitPop: 30_000,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
defaultCrewTypeId: 1,
openingPartYear: 3,
initialNationGenLimit: 10,
maxTechLevel: 10,
techLevelIncYear: 5,
initialAllowedTechLevel: 1,
},
aiConst: {
baseGold: 1000,
baseRice: 1000,
minAvailableRecruitPop: 30_000,
maxResourceActionAmount: 10_000,
minNationalGold: 1000,
minNationalRice: 1000,
defaultStatMax: 100,
defaultStatNpcMax: 50,
chiefStatMin: 70,
npcMessageFreqByDay: 0,
availableNationTypes: [],
},
dipState: overrides.dipState ?? 0,
attackable: overrides.attackable ?? false,
genType: overrides.genType ?? 7,
rng,
maxResourceActionAmount: 10_000,
generalPolicy: {
can: (action: string) =>
!disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action),
},
nationPolicy: {
minWarCrew: 1500,
minNpcRecruitCityPopulation: 30_000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
minimumResourceActionAmount: 1000,
reqNationGold: 10_000,
reqNationRice: 12_000,
reqHumanWarRecommandGold: 20_000,
reqHumanWarRecommandRice: 20_000,
reqHumanDevelGold: 10_000,
reqHumanDevelRice: 10_000,
reqNpcWarGold: 10_000,
reqNpcWarRice: 10_000,
reqNpcDevelGold: 5_000,
reqNpcDevelRice: 5_000,
},
calcCityDevelRate: (target: City) => ({
trust: [Number(target.meta.trust ?? 0) / 100, 4],
pop: [target.population / target.populationMax, 4],
agri: [target.agriculture / target.agricultureMax, 2],
comm: [target.commerce / target.commerceMax, 2],
secu: [target.security / target.securityMax, 1],
def: [target.defence / target.defenceMax, 1],
wall: [target.wall / target.wallMax, 1],
}),
buildGeneralCandidate: (action: string, args: Record<string, unknown>, reason: string) => {
if (blocked.has(action)) {
return null;
}
const candidate = { action, args, reason };
candidates.push(candidate);
return candidate;
},
buildNationCandidate: (action: string, args: Record<string, unknown>, reason: string) => {
if (blocked.has(action)) {
return null;
}
const candidate = { action, args, reason };
candidates.push(candidate);
return candidate;
},
} as unknown as GeneralAI;
};
/**
* Expected branches are extracted from ref/sam hwe/sammo/GeneralAI.php
* at ng_compare@fe9ae978. These tests intentionally assert final command
* selection and RNG-sensitive gates, not TypeScript implementation details.
*/
describe('legacy NPC AI final-decision parity', () => {
it.each([
[0, 0],
[0, 2],
[1, 0],
[1, 2],
])('does not recruit during peace/declaration (dip=%i, npc=%i)', (dipState, npcState) => {
const ai = makeAi({ dipState, general: { npcState } });
expect(do징병(ai)).toBeNull();
});
it.each([
[1000, 1000, null],
[1000, 2000, 'che_징병'],
])(
'uses legacy casualty ranks for recruitment rice reserve (kill=%i, death=%i)',
(killCrew, deathCrew, expected) => {
const ai = makeAi({
dipState: 2,
general: {
gold: 10_000,
rice: 350,
meta: {
killturn: 100,
fullLeadership: 70,
rank_killcrew: killCrew,
rank_deathcrew: deathCrew,
},
},
rng: makeRng([], [0, 0]),
});
expect(do징병(ai)?.action ?? null).toBe(expected);
}
);
it.each([
[0, 0],
[0, 2000],
[1, 0],
[1, 2000],
])('does not train during peace/declaration (dip=%i, crew=%i)', (dipState, crew) => {
const ai = makeAi({ dipState, general: { crew, train: 0, atmos: 0 } });
expect(do전투준비(ai)).toBeNull();
});
it.each([
[180, 0, 'che_기술연구'],
[180, 1000, null],
[185, 1000, 'che_기술연구'],
[185, 2000, null],
])('respects the legacy year-based technology ceiling (year=%i, tech=%i)', (year, tech, expected) => {
const ai = makeAi({
year,
genType: 2,
nation: { rice: 100_000, meta: { tech } },
rng: makeRng([], [0]),
});
expect(do일반내정(ai)?.action ?? null).toBe(expected);
});
it('uses the legacy weighted front-state rule for wartime domestic choices', () => {
const rng = makeRng([false], [0]);
const ai = makeAi({
dipState: 4,
genType: 2,
city: {
frontState: 2,
agriculture: 1000,
agricultureMax: 10_000,
commerce: 10_000,
commerceMax: 10_000,
},
nation: { meta: { tech: 1000 } },
year: 185,
rng,
});
expect(do전쟁내정(ai)?.action).toBe('che_기술연구');
const weights = rng.weightedPairs.at(-1)!;
const agriculture = weights.find(([candidate]) => (candidate as Candidate).action === 'che_농지개간')!;
expect(agriculture[1]).toBe(420);
});
it.each([
[1500, 400, null],
[10_000, 1000, 'che_군량매매'],
[1000, 10_000, 'che_군량매매'],
[10_000, 10_000, null],
])('matches legacy gold/rice trade decisions (gold=%i, rice=%i)', (gold, rice, expected) => {
const ai = makeAi({ general: { gold, rice } });
expect(do금쌀구매(ai)?.action ?? null).toBe(expected);
});
it('randomly chooses between supply and search when national resources are sufficient', () => {
const ai = makeAi({ rng: makeRng([], [1]) });
expect(do중립(ai)?.action).toBe('che_인재탐색');
});
it('falls back supply -> inspect when the randomly selected neutral command is invalid', () => {
const ai = makeAi({
rng: makeRng([], [1]),
blockedActions: ['che_인재탐색', 'che_물자조달'],
});
expect(do중립(ai)?.action).toBe('che_견문');
});
it.each([
['affinity sentinel', { affinity: 999 }, 190, [true], null],
['late rejection', {}, 190, [true, true], null],
['late acceptance', {}, 190, [true, false], 'che_랜덤임관'],
['movement', {}, 190, [false, true], 'che_이동'],
['no action', {}, 190, [false, false], null],
])('matches legacy free-general choice: %s', (_name, general, year, bools, expected) => {
const ai = makeAi({
general: { nationId: 0, ...general },
year,
rng: makeRng(bools, [0]),
});
expect(do국가선택(ai)?.action ?? null).toBe(expected);
});
it('rejects early random enlistment when no nation exists', () => {
const ai = makeAi({
general: { nationId: 0 },
year: 181,
nations: [],
rng: makeRng([true]),
});
expect(do국가선택(ai)).toBeNull();
});
it.each([
[false, 4, 100, 100, 2000, null],
[true, 3, 100, 100, 2000, null],
[true, 4, 89, 100, 2000, null],
[true, 4, 100, 89, 2000, null],
[true, 4, 100, 100, 1000, null],
])(
'rejects deployment outside legacy war readiness (attackable=%s dip=%i train=%i atmos=%i crew=%i)',
(attackable, dipState, train, atmos, crew, expected) => {
const ai = makeAi({
attackable,
dipState,
general: { train, atmos, crew },
city: { frontState: 3 },
});
expect(do출병(ai)?.action ?? null).toBe(expected);
}
);
it('updates NPC troop-leader lifespan before selecting assembly', () => {
const ai = makeAi({
general: { npcState: 5, meta: { killturn: 69 } },
rng: makeRng([], [3]),
});
expect(do집합(ai)?.action).toBe('che_집합');
expect(ai.general.meta.killturn).toBe(72);
});
it('does not warp to the rear when recruitment is disabled', () => {
const ai = makeAi({
dipState: 4,
general: { crew: 0 },
city: { population: 10_000 },
disabledPolicyActions: ['징병'],
});
expect(do후방워프(ai)).toBeNull();
});
it('categorizes generals before weighting a front-line warp destination', () => {
const ai = makeAi({
dipState: 4,
attackable: true,
general: { crew: 2000 },
});
let categorizedGenerals = false;
ai.categorizeNationCities = () => {
ai.frontCities = { 1: { ...baseCity(), frontState: 3, important: 1, dev: 1 } };
};
ai.categorizeNationGeneral = () => {
categorizedGenerals = true;
ai.frontCities[1]!.important = 2;
};
expect(do전방워프(ai)?.action).toBe('che_NPC능동');
expect(categorizedGenerals).toBe(true);
});
it('awards a resource-poor civil user general like the legacy nation AI', () => {
const ai = makeAi();
const civilGeneral = {
...baseGeneral(),
id: 2,
npcState: 0,
gold: 0,
rice: 20_000,
meta: { killturn: 100, fullLeadership: 70 },
turnTime: new Date('0190-01-01T00:00:00Z'),
};
ai.userGenerals = { 2: civilGeneral };
ai.userWarGenerals = {};
expect(do유저장포상(ai)?.action).toBe('che_포상');
});
it('seizes a small war-NPC surplus while the treasury is below 1.5x reserve', () => {
const ai = makeAi({ nation: { gold: 12_000, rice: 100_000 } });
const warGeneral = {
...baseGeneral(),
id: 2,
gold: 25_000,
rice: 10_000,
meta: { killturn: 100, fullLeadership: 70 },
turnTime: new Date('0190-01-01T00:00:00Z'),
};
ai.npcCivilGenerals = {};
ai.npcWarGenerals = { 2: warGeneral };
expect(doNPC몰수(ai)?.action).toBe('che_몰수');
});
});
@@ -315,17 +315,6 @@ describe('NPC 대형 시뮬레이션', () => {
}
};
const assertNationRecruitCount = (minRecruit: number) => {
const nations = world.listNations().filter((nation) => nation.level >= 1 && nation.capitalCityId);
const generals = world.listGenerals();
for (const nation of nations) {
const recruited = generals.filter(
(general) => general.nationId === nation.id && general.crew > 0 && general.crewTypeId > 0
);
expect(recruited.length).toBeGreaterThanOrEqual(minRecruit);
}
};
const assertWarReadiness = (minReadyCount: number, minTrain: number, minAtmos: number) => {
const recruited = world
.listGenerals()
@@ -405,7 +394,6 @@ describe('NPC 대형 시뮬레이션', () => {
['180-11', () => assertCityTrust(90)],
['181-01', () => assertNationGeneralCount(10)],
['182-01', () => assertDomesticGrowth()],
['182-10', () => assertNationRecruitCount(5)],
['183-01', () => assertWarReadiness(10, 70, 70)],
['183-02', () => assertDispatchRecorded(183, 1, 1)],
['183-07', () => assertNoNeutralCities()],
@@ -109,11 +109,18 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
const pushNationGenerals = (nationId: number, cityId: number) => {
const leaderId = nextId++;
generals.push(
createNpcGeneral(leaderId, cityId, nationId, 12, {
leadership: 100,
strength: 90,
intelligence: 40,
}, 1)
createNpcGeneral(
leaderId,
cityId,
nationId,
12,
{
leadership: 100,
strength: 90,
intelligence: 40,
},
1
)
);
for (let i = 0; i < 9; i += 1) {
generals.push(
@@ -317,6 +324,8 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(initialLevel);
expect(secondRecruitCost).not.toBeNull();
expect(secondRecruitCost ?? 0).toBeGreaterThan(firstRecruitCost);
// Nation awards can occur in the same tick and make the general's net
// gold delta smaller than the recruitment price. Exact cost scaling is
// covered by the unit-set/action contract tests rather than this smoke.
}, 60000);
});
@@ -113,24 +113,30 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
const generals: TurnGeneral[] = [];
let nextId = 1;
const pushNationGenerals = (nationId: number, cityId: number) => {
generals.push(createNpcGeneral(nextId++, cityId, nationId, 12, {
leadership: 90,
strength: 80,
intelligence: 40,
}));
for (let i = 0; i < 9; i += 1) {
generals.push(createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 12, {
leadership: 90,
strength: 80,
intelligence: 30,
}));
intelligence: 40,
})
);
for (let i = 0; i < 9; i += 1) {
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
strength: 80,
intelligence: 30,
})
);
}
for (let i = 0; i < 10; i += 1) {
generals.push(createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
strength: 30,
intelligence: 80,
}));
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
strength: 30,
intelligence: 80,
})
);
}
};
pushNationGenerals(1, cityA1.id);
@@ -280,9 +286,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
const findDiplomacyEntry = (world: InMemoryTurnWorld | null) => {
const diplomacyEntries = world?.listDiplomacy() ?? [];
return diplomacyEntries.find((entry) =>
(entry.fromNationId === 1 && entry.toNationId === 2) ||
(entry.fromNationId === 2 && entry.toNationId === 1)
return diplomacyEntries.find(
(entry) =>
(entry.fromNationId === 1 && entry.toNationId === 2) ||
(entry.fromNationId === 2 && entry.toNationId === 1)
);
};
@@ -321,15 +328,12 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(declareEntry?.state).toBe(DIPLOMACY_STATE.DECLARATION);
const remainTurns = Math.max(0, (declareEntry?.term ?? 0) - 1);
const preWarTarget = addMonths(
world!.getState().currentYear,
world!.getState().currentMonth,
remainTurns
);
const preWarTarget = addMonths(world!.getState().currentYear, world!.getState().currentMonth, remainTurns);
await runUntil((current) =>
current.currentYear > preWarTarget.year ||
(current.currentYear === preWarTarget.year && current.currentMonth >= preWarTarget.month)
await runUntil(
(current) =>
current.currentYear > preWarTarget.year ||
(current.currentYear === preWarTarget.year && current.currentMonth >= preWarTarget.month)
);
const preWarEntry = findDiplomacyEntry(world);
@@ -348,10 +352,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
debug.dumpWatched('개전 직전 병력 부족');
}
expect(recruited.length).toBeGreaterThanOrEqual(5);
const battleReady = recruited.filter((general) => general.train >= 90 && general.atmos >= 90);
expect(battleReady.length).toBeGreaterThanOrEqual(5);
let frontRecruited = 0;
for (const general of recruited) {
expect(general.train).toBeGreaterThanOrEqual(90);
expect(general.atmos).toBeGreaterThanOrEqual(90);
const city = world.getCityById(general.cityId);
if (city && city.frontState > 0) {
frontRecruited += 1;
@@ -363,9 +367,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(frontRecruited).toBeGreaterThan(0);
const warTarget = addMonths(preWarTarget.year, preWarTarget.month, 1);
await runUntil((current) =>
current.currentYear > warTarget.year ||
(current.currentYear === warTarget.year && current.currentMonth >= warTarget.month)
await runUntil(
(current) =>
current.currentYear > warTarget.year ||
(current.currentYear === warTarget.year && current.currentMonth >= warTarget.month)
);
const warEntry = findDiplomacyEntry(world);
@@ -381,9 +386,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
while (prevNation1Cities > 0 && guard < 120) {
const next = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > next.year ||
(current.currentYear === next.year && current.currentMonth >= next.month)
await runUntil(
(current) =>
current.currentYear > next.year ||
(current.currentYear === next.year && current.currentMonth >= next.month)
);
const nowNation1Cities = countCities(1, world);
@@ -415,9 +421,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(allOwnedByNation2).toBe(true);
const unifyCheckTarget = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > unifyCheckTarget.year ||
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month)
await runUntil(
(current) =>
current.currentYear > unifyCheckTarget.year ||
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month)
);
const worldMeta = world.getState().meta as Record<string, unknown>;
@@ -434,9 +441,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(hasUnificationLog).toBe(true);
const dispatchWindowEnd = addMonths(warTarget.year, warTarget.month, 2);
await runUntil((current) =>
current.currentYear > dispatchWindowEnd.year ||
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month)
await runUntil(
(current) =>
current.currentYear > dispatchWindowEnd.year ||
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month)
);
const dispatchKeys: string[] = [];
+2
View File
@@ -27,6 +27,7 @@
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^7.2.0",
"@sammo-ts/common": "workspace:*",
"@sammo-ts/game-engine": "workspace:*",
@@ -38,6 +39,7 @@
"fastify": "^5.6.2",
"pm2": "^5.4.3",
"redis": "^5.10.0",
"sharp": "^0.34.4",
"zod": "^4.3.5"
}
}
+167
View File
@@ -0,0 +1,167 @@
import { randomBytes } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { TRPCError } from '@trpc/server';
import sharp from 'sharp';
import { z } from 'zod';
import type { GatewayApiContext } from '../context.js';
import { procedure, router } from '../trpc.js';
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
const zSessionToken = z.string().min(1);
const zPassword = z.string().min(6).max(128);
const MAX_ICON_BYTES = 50 * 1024;
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise<UserRecord> => {
const session = await ctx.sessions.getSession(sessionToken);
if (!session) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.' });
}
const user = await ctx.users.findById(session.userId);
if (!user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists.' });
}
return user;
};
const decodeImage = (input: string): Buffer => {
const match = input.match(/^data:[^;]+;base64,(.+)$/);
const encoded = match?.[1] ?? input;
const buffer = Buffer.from(encoded, 'base64');
if (buffer.length === 0 || buffer.length > MAX_ICON_BYTES) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘은 50KB 이하여야 합니다.' });
}
return buffer;
};
const sameUtcDate = (left: Date, right: Date): boolean =>
left.getUTCFullYear() === right.getUTCFullYear() &&
left.getUTCMonth() === right.getUTCMonth() &&
left.getUTCDate() === right.getUTCDate();
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
if (user.iconUpdatedAt && sameUtcDate(new Date(user.iconUpdatedAt), now)) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' });
}
};
const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
const dates = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil];
for (const value of dates) {
if (value && new Date(value) > now) return true;
}
return Object.values(sanctions.serverRestrictions ?? {}).some((restriction) =>
Boolean(restriction.until && new Date(restriction.until) > now)
);
};
const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => {
if (user.imageServer !== 1 || user.picture === 'default.jpg') return null;
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(user.picture)}`;
};
export const accountRouter = router({
get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
return {
id: user.id,
username: user.username,
displayName: user.displayName,
roles: user.roles,
oauthType: user.oauthType,
createdAt: user.createdAt,
iconUrl: buildIconUrl(ctx, user),
thirdPartyUse: user.thirdPartyUse,
deleteAfter: user.deleteAfter ?? null,
};
}),
changePassword: procedure
.input(
z.object({
sessionToken: zSessionToken,
currentPassword: zPassword,
newPassword: zPassword,
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
if (!(await ctx.users.verifyPassword(user, input.currentPassword))) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' });
}
await ctx.users.updatePassword(user.id, input.newPassword);
await ctx.flushPublisher.publishUserFlush(user.id, 'password-changed');
return { ok: true };
}),
scheduleDeletion: procedure
.input(z.object({ sessionToken: zSessionToken, currentPassword: zPassword }))
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
if (!(await ctx.users.verifyPassword(user, input.currentPassword))) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' });
}
if (user.deleteAfter) {
throw new TRPCError({ code: 'CONFLICT', message: '이미 탈퇴 처리되어 있습니다.' });
}
const now = new Date();
if (hasActiveSanction(user.sanctions, now)) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '징계가 남아 있어 탈퇴할 수 없습니다.' });
}
const deleteAfter = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
await ctx.users.scheduleDeletion(user.id, deleteAfter);
await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true });
await ctx.flushPublisher.publishUserFlush(user.id, 'account-deletion-scheduled');
return { ok: true, deleteAfter: deleteAfter.toISOString() };
}),
disallowThirdPartyUse: procedure
.input(z.object({ sessionToken: zSessionToken }))
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
await ctx.users.setThirdPartyUse(user.id, false);
return { ok: true };
}),
changeIcon: procedure
.input(
z.object({
sessionToken: zSessionToken,
imageData: z.string().min(1).max(100_000),
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
assertIconChangeAvailable(user, now);
const buffer = decodeImage(input.imageData);
const metadata = await sharp(buffer, { animated: true }).metadata();
if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
});
}
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
});
}
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
const filename = `${randomBytes(8).toString('hex')}.${extension}`;
await fs.mkdir(ctx.userIconDir, { recursive: true });
await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' });
await ctx.users.updateIcon(user.id, filename, 1, now);
return {
ok: true,
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
};
}),
deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
assertIconChangeAvailable(user, now);
await ctx.users.updateIcon(user.id, 'default.jpg', 0, now);
return { ok: true, iconUrl: null };
}),
});
+254 -3
View File
@@ -4,10 +4,18 @@ import path from 'node:path';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
import {
createGamePostgresConnector,
resolvePostgresConfigFromEnv,
type GatewayPrisma,
} from '@sammo-ts/infra';
import { procedure, router } from './trpc.js';
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
import {
listScenarioPreviews,
resolveGitBranchCommitSha,
resolveGitCommitSha,
} from './scenario/scenarioCatalog.js';
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
import { toPublicUser } from './auth/userRepository.js';
import type { AdminAuthContext } from './adminAuth.js';
@@ -241,6 +249,8 @@ const zInstallAutorun = z.object({
});
const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value);
const isUniqueConstraintError = (error: unknown): boolean =>
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002');
const zInstallOptions = z.object({
scenarioId: z.number().int().min(0),
@@ -260,6 +270,8 @@ const zInstallOptions = z.object({
preopenAt: z.string().datetime().optional(),
gitRef: z.string().min(1).max(128).optional(),
});
const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
@@ -578,6 +590,237 @@ export const adminRouter = router({
return { ok: true };
}),
}),
operations: router({
list: adminProcedure
.input(
z
.object({
profileName: z.string().min(1).optional(),
limit: z.number().int().min(1).max(200).optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
if (input?.profileName) {
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
return ctx.profiles.listOperations({
profileName: input.profileName,
limit: input.limit,
});
}
if (hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES)) {
return ctx.profiles.listOperations({ limit: input?.limit });
}
const profiles = await ctx.profiles.listProfiles();
const allowed = profiles.filter((profile) =>
hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName)
);
const operations = (
await Promise.all(
allowed.map((profile) =>
ctx.profiles.listOperations({
profileName: profile.profileName,
limit: input?.limit,
})
)
)
)
.flat()
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
return operations.slice(0, input?.limit ?? 50);
}),
requestReset: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
sourceMode: zSourceMode,
sourceRef: z.string().min(1).max(128),
install: zOperationInstallOptions,
scheduledAt: z.string().datetime().optional(),
reason: z.string().max(200).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
if (input.scheduledAt && new Date(input.scheduledAt).getTime() <= Date.now()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'scheduledAt must be in the future.',
});
}
const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null;
const openAt = input.install.openAt ? new Date(input.install.openAt) : null;
const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null;
if (preopenAt && !openAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'openAt is required when preopenAt is set.',
});
}
if (preopenAt && openAt && preopenAt.getTime() >= openAt.getTime()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'preopenAt must be earlier than openAt.',
});
}
if (openAt && openAt.getTime() <= (scheduledAt?.getTime() ?? Date.now())) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'openAt must be later than the reset start.',
});
}
if (preopenAt && scheduledAt && preopenAt.getTime() < scheduledAt.getTime()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'preopenAt cannot be earlier than scheduledAt.',
});
}
const autorunUser = input.install.autorunUser;
if (
autorunUser &&
((autorunUser.limitMinutes <= 0 && autorunUser.options.length > 0) ||
(autorunUser.limitMinutes > 0 && autorunUser.options.length === 0))
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'autorunUser minutes and options must be configured together.',
});
}
let sourceRef = input.sourceRef.trim();
try {
const resolved =
input.sourceMode === 'BRANCH'
? await resolveGitBranchCommitSha(sourceRef)
: await resolveGitCommitSha(sourceRef);
if (input.sourceMode === 'COMMIT') {
sourceRef = resolved;
}
const scenarios = await listScenarioPreviews({ gitRef: resolved });
if (!scenarios.some((scenario) => scenario.id === input.install.scenarioId)) {
throw new Error('Scenario not found at source.');
}
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
input.sourceMode === 'BRANCH'
? 'Branch is invalid or does not contain the scenario.'
: 'Commit is invalid or does not contain the scenario.',
});
}
try {
const operation = await ctx.profiles.createOperation({
profileName: input.profileName,
type: 'RESET',
sourceMode: input.sourceMode,
sourceRef,
payload: { install: input.install } as GatewayPrisma.JsonObject,
reason: input.reason,
requestedBy: adminAuth.user.id,
scheduledAt: input.scheduledAt,
});
return operation;
} catch (error) {
if (!isUniqueConstraintError(error)) {
throw error;
}
throw new TRPCError({
code: 'CONFLICT',
message: 'This profile already has a queued or running operation.',
});
}
}),
requestRuntime: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
action: z.enum(['START', 'STOP']),
reason: z.string().max(200).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
try {
const operation = await ctx.profiles.createOperation({
profileName: input.profileName,
type: input.action,
reason: input.reason,
requestedBy: adminAuth.user.id,
});
return operation;
} catch (error) {
if (!isUniqueConstraintError(error)) {
throw error;
}
throw new TRPCError({
code: 'CONFLICT',
message: 'This profile already has a queued or running operation.',
});
}
}),
cancel: adminProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
const previous = await ctx.profiles.getOperation(input.id);
if (!previous) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
}
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
const cancelled = await ctx.profiles.cancelOperation(input.id);
if (!cancelled) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Only queued operations can be cancelled.',
});
}
return { ok: true };
}),
retry: adminProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
const previous = await ctx.profiles.getOperation(input.id);
if (!previous) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
}
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
try {
const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id);
if (!operation) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Only failed or cancelled operations can be retried.',
});
}
return operation;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
if (!isUniqueConstraintError(error)) {
throw error;
}
throw new TRPCError({
code: 'CONFLICT',
message: 'This profile already has a queued or running operation.',
});
}
}),
}),
profiles: router({
list: adminProcedure.query(async ({ ctx }) => {
const profiles = await ctx.profiles.listProfiles();
@@ -599,12 +842,20 @@ export const adminRouter = router({
z
.object({
gitRef: z.string().min(1).max(128).optional(),
sourceMode: zSourceMode.optional(),
})
.optional()
)
.query(async ({ input }) => {
const gitRef = input?.gitRef?.trim();
return listScenarioPreviews({ gitRef: gitRef || null });
if (!gitRef) {
return listScenarioPreviews();
}
const resolved =
input?.sourceMode === 'BRANCH'
? await resolveGitBranchCommitSha(gitRef)
: await resolveGitCommitSha(gitRef);
return listScenarioPreviews({ gitRef: resolved });
}),
upsert: profileAdminProcedure
.input(
@@ -43,6 +43,9 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
oauthId: input.oauth?.id,
email: input.oauth?.email,
oauthInfo: input.oauth?.info,
picture: 'default.jpg',
imageServer: 0,
thirdPartyUse: true,
passwordSalt: salt,
passwordHash: hasher.hash(input.password, salt),
createdAt: new Date().toISOString(),
@@ -97,6 +100,35 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.picture = picture;
user.imageServer = imageServer;
user.iconUpdatedAt = updatedAt.toISOString();
return;
}
}
throw new Error('User not found.');
},
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.thirdPartyUse = allowed;
return;
}
}
throw new Error('User not found.');
},
async scheduleDeletion(userId: string, deleteAfter: Date): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.deleteAfter = deleteAfter.toISOString();
return;
}
}
throw new Error('User not found.');
},
async deleteUser(userId: string): Promise<void> {
for (const [username, user] of usersByName.entries()) {
if (user.id === userId) {
@@ -29,6 +29,11 @@ const mapUser = (row: {
oauthId: string | null;
email: string | null;
oauthInfo: GatewayPrisma.JsonValue;
picture: string;
imageServer: number;
iconUpdatedAt: Date | null;
thirdPartyUse: boolean;
deleteAfter: Date | null;
createdAt: Date;
}): UserRecord => ({
id: row.id,
@@ -40,6 +45,11 @@ const mapUser = (row: {
oauthId: row.oauthId ?? undefined,
email: row.email ?? undefined,
oauthInfo: readObject<UserOAuthInfo>(row.oauthInfo, {}),
picture: row.picture,
imageServer: row.imageServer,
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
thirdPartyUse: row.thirdPartyUse,
deleteAfter: row.deleteAfter?.toISOString(),
passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt,
createdAt: row.createdAt.toISOString(),
@@ -139,6 +149,28 @@ export const createPostgresUserRepository = (
},
});
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: {
picture,
imageServer,
iconUpdatedAt: updatedAt,
},
});
},
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: { thirdPartyUse: allowed },
});
},
async scheduleDeletion(userId: string, deleteAfter: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: { deleteAfter },
});
},
async deleteUser(userId: string): Promise<void> {
await prisma.appUser.delete({
where: { id: userId },
@@ -8,6 +8,11 @@ export interface UserRecord {
oauthId?: string;
email?: string;
oauthInfo?: UserOAuthInfo;
picture: string;
imageServer: number;
iconUpdatedAt?: string;
thirdPartyUse: boolean;
deleteAfter?: string;
passwordHash: string;
passwordSalt: string;
createdAt: string;
@@ -18,6 +23,7 @@ export interface PublicUser {
username: string;
displayName: string;
roles: string[];
picture: string;
createdAt: string;
}
@@ -44,6 +50,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
username: user.username,
displayName: user.displayName,
roles: user.roles,
picture: user.picture,
createdAt: user.createdAt,
});
@@ -70,6 +77,9 @@ export interface UserRepository {
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
deleteUser(userId: string): Promise<void>;
}
+4
View File
@@ -16,6 +16,8 @@ export interface GatewayApiConfig {
kakaoAdminKey?: string;
kakaoRedirectUri: string;
publicBaseUrl: string;
userIconDir: string;
userIconPublicUrl: string;
adminLocalAccountEnabled: boolean;
orchestratorEnabled: boolean;
orchestratorReconcileIntervalMs: number;
@@ -81,6 +83,8 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
kakaoRedirectUri,
publicBaseUrl,
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? `${publicBaseUrl.replace(/\/$/, '')}/user-icons`,
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
orchestratorReconcileIntervalMs: parseNumberWithFallback(
+6
View File
@@ -18,6 +18,8 @@ export interface GatewayApiContext {
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
userIconDir: string;
userIconPublicUrl: string;
adminLocalAccountEnabled: boolean;
profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle;
@@ -36,6 +38,8 @@ export const createGatewayApiContext = (options: {
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
userIconDir?: string;
userIconPublicUrl?: string;
adminLocalAccountEnabled: boolean;
profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle;
@@ -51,6 +55,8 @@ export const createGatewayApiContext = (options: {
kakaoClient: options.kakaoClient,
oauthSessions: options.oauthSessions,
publicBaseUrl: options.publicBaseUrl,
userIconDir: options.userIconDir ?? 'uploads/user-icons',
userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`,
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
profiles: options.profiles,
orchestrator: options.orchestrator,
@@ -7,7 +7,12 @@ import { isRecord } from '@sammo-ts/common';
import type { BuildCommand, BuildRunner } from './buildRunner.js';
import type { ProcessManager } from './processManager.js';
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
import type {
GatewayOperationRecord,
GatewayProfileRecord,
GatewayProfileRepository,
GatewayProfileStatus,
} from './profileRepository.js';
import type { GitWorkspaceManager } from './workspaceManager.js';
import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js';
@@ -46,6 +51,7 @@ export interface GatewayOrchestratorHandle {
reconcileNow(): Promise<void>;
runScheduleNow(): Promise<void>;
runBuildQueueNow(): Promise<void>;
runOperationsNow(): Promise<void>;
cleanupStaleWorkspaces(): Promise<{
removed: string[];
skipped: string[];
@@ -376,6 +382,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private scheduleInFlight = false;
private buildInFlight = false;
private adminActionInFlight = false;
private operationInFlight = false;
private readonly resetInFlight = new Set<string>();
constructor(options: GatewayOrchestratorOptions) {
@@ -393,11 +400,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
start(): void {
void this.reconcileNow();
void this.runOperationsNow();
void this.runAdminActionsNow();
this.reconcileTimer = setInterval(() => void this.reconcileNow(), this.reconcileIntervalMs);
this.scheduleTimer = setInterval(() => void this.runScheduleNow(), this.scheduleIntervalMs);
this.buildTimer = setInterval(() => void this.runBuildQueueNow(), this.buildIntervalMs);
this.adminActionTimer = setInterval(() => void this.runAdminActionsNow(), this.adminActionIntervalMs);
this.adminActionTimer = setInterval(() => {
void this.runOperationsNow();
void this.runAdminActionsNow();
}, this.adminActionIntervalMs);
}
async stop(): Promise<void> {
@@ -548,6 +559,83 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
async runOperationsNow(): Promise<void> {
if (this.operationInFlight || this.buildInFlight) {
return;
}
this.operationInFlight = true;
try {
const operation = await this.repository.claimNextOperation(this.now());
if (!operation) {
return;
}
await this.handleOperation(operation);
} finally {
this.operationInFlight = false;
}
}
private async handleOperation(operation: GatewayOperationRecord): Promise<void> {
const profile = await this.repository.getProfile(operation.profileName);
if (!profile) {
await this.repository.completeOperation(operation.id, 'FAILED', {
error: 'Profile not found.',
});
return;
}
try {
if (operation.type === 'START') {
const updated = await this.repository.updateStatus(profile.profileName, 'RUNNING', {
preopenAt: null,
openAt: null,
scheduledStartAt: null,
});
const started = await this.startProfile(updated ?? profile);
if (!started) {
throw new Error('Failed to start profile processes.');
}
await this.repository.completeOperation(operation.id, 'SUCCEEDED', { error: null });
return;
}
if (operation.type === 'STOP') {
await this.repository.updateStatus(profile.profileName, 'STOPPED');
await this.stopProfile(profile);
await this.repository.completeOperation(operation.id, 'SUCCEEDED', { error: null });
return;
}
if (!operation.sourceMode || !operation.sourceRef) {
throw new Error('Reset source mode and ref are required.');
}
const commitSha = await this.workspaceManager.resolveCommit(operation.sourceMode, operation.sourceRef);
const payload = normalizeMeta(operation.payload);
const install = isRecord(payload.install) ? payload.install : {};
const resetAction: GatewayAdminActionRecord = {
action: operation.scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW',
requestedAt: operation.createdAt,
scheduledAt: operation.scheduledAt ?? null,
reason: operation.reason ?? null,
install,
};
const result = await this.handleResetAction(profile, resetAction, commitSha);
if (result.status === 'REQUESTED') {
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
await this.repository.requeueOperation(operation.id, result.detail, retryAt);
return;
}
if (result.status !== 'APPLIED') {
throw new Error(result.detail ?? 'Reset failed.');
}
await this.repository.completeOperation(operation.id, 'SUCCEEDED', {
resolvedCommitSha: commitSha,
error: null,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
await this.repository.completeOperation(operation.id, 'FAILED', { error: detail });
}
}
private async runAdminActionsNow(): Promise<void> {
if (this.adminActionInFlight) {
return;
@@ -632,7 +720,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async handleResetAction(
profile: GatewayProfileRecord,
action: GatewayAdminActionRecord
action: GatewayAdminActionRecord,
commitShaOverride?: string
): Promise<GatewayAdminActionResult> {
// 리셋 요청을 빌드+재기동 흐름으로 처리한다.
if (this.resetInFlight.has(profile.profileName)) {
@@ -651,7 +740,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
const commitSha = profile.buildCommitSha;
const commitSha = commitShaOverride ?? profile.buildCommitSha;
if (!commitSha) {
return { status: 'FAILED', detail: 'buildCommitSha is missing' };
}
@@ -734,7 +823,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
scheduledStartAt: action.scheduledAt ?? null,
});
const builtProfile = (await this.repository.getProfile(profile.profileName)) ?? activeProfile;
await this.startProfile(builtProfile);
const started = await this.startProfile(builtProfile);
if (!started) {
return { status: 'FAILED', detail: 'reset completed but profile processes failed to start' };
}
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
@@ -852,32 +944,39 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return cutoff;
}
private async startProfile(profile: GatewayProfileRecord): Promise<void> {
private async startProfile(profile: GatewayProfileRecord): Promise<boolean> {
const definitions = buildProcessDefinitions(profile, this.processConfig);
try {
await this.processManager.start(definitions.api);
await this.processManager.start(definitions.daemon);
await this.repository.updateLastError(profile.profileName, null);
return true;
} catch (error) {
await this.repository.updateLastError(
profile.profileName,
error instanceof Error ? error.message : 'Failed to start processes.'
);
return false;
}
}
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
try {
await this.processManager.stop(apiName);
} catch {
await this.processManager.delete(apiName);
const failures: string[] = [];
for (const name of [apiName, daemonName]) {
try {
await this.processManager.stop(name);
} catch {
try {
await this.processManager.delete(name);
} catch (error) {
failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
try {
await this.processManager.stop(daemonName);
} catch {
await this.processManager.delete(daemonName);
if (failures.length > 0) {
throw new Error(`Failed to stop profile processes: ${failures.join('; ')}`);
}
}
@@ -14,6 +14,45 @@ export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number];
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
export const GATEWAY_OPERATION_TYPES = ['RESET', 'START', 'STOP'] as const;
export type GatewayOperationType = (typeof GATEWAY_OPERATION_TYPES)[number];
export const GATEWAY_OPERATION_STATUSES = ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'] as const;
export type GatewayOperationStatus = (typeof GATEWAY_OPERATION_STATUSES)[number];
export const GATEWAY_SOURCE_MODES = ['BRANCH', 'COMMIT'] as const;
export type GatewaySourceMode = (typeof GATEWAY_SOURCE_MODES)[number];
export interface GatewayOperationRecord {
id: string;
profileName: string;
type: GatewayOperationType;
status: GatewayOperationStatus;
sourceMode?: GatewaySourceMode;
sourceRef?: string;
resolvedCommitSha?: string;
payload: GatewayPrisma.JsonObject;
reason?: string;
requestedBy: string;
scheduledAt?: string;
startedAt?: string;
completedAt?: string;
error?: string;
createdAt: string;
updatedAt: string;
}
export interface GatewayOperationCreateInput {
profileName: string;
type: GatewayOperationType;
sourceMode?: GatewaySourceMode;
sourceRef?: string;
payload?: GatewayPrisma.JsonObject;
reason?: string;
requestedBy: string;
scheduledAt?: string;
}
export interface GatewayProfileRecord {
profileName: string;
profile: string;
@@ -82,6 +121,18 @@ export interface GatewayProfileRepository {
updateLastError(profileName: string, lastError: string | null): Promise<void>;
updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void>;
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]>;
getOperation(id: string): Promise<GatewayOperationRecord | null>;
createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord>;
claimNextOperation(now: Date): Promise<GatewayOperationRecord | null>;
completeOperation(
id: string,
status: Extract<GatewayOperationStatus, 'SUCCEEDED' | 'FAILED'>,
fields?: { resolvedCommitSha?: string | null; error?: string | null }
): Promise<GatewayOperationRecord>;
requeueOperation(id: string, detail?: string, retryAt?: string): Promise<GatewayOperationRecord>;
cancelOperation(id: string): Promise<boolean>;
retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null>;
}
const toIso = (value: Date | null): string | undefined => (value ? value.toISOString() : undefined);
@@ -109,6 +160,25 @@ type GatewayProfileRow = {
updatedAt: Date;
};
type GatewayOperationRow = {
id: string;
profileName: string;
type: GatewayOperationType;
status: GatewayOperationStatus;
sourceMode: GatewaySourceMode | null;
sourceRef: string | null;
resolvedCommitSha: string | null;
payload: GatewayPrisma.JsonValue;
reason: string | null;
requestedBy: string;
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
error: string | null;
createdAt: Date;
updatedAt: Date;
};
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
profileName: row.profileName,
profile: row.profile,
@@ -134,6 +204,25 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`;
const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
id: row.id,
profileName: row.profileName,
type: row.type,
status: row.status,
sourceMode: row.sourceMode ?? undefined,
sourceRef: row.sourceRef ?? undefined,
resolvedCommitSha: row.resolvedCommitSha ?? undefined,
payload: (row.payload ?? {}) as GatewayPrisma.JsonObject,
reason: row.reason ?? undefined,
requestedBy: row.requestedBy,
scheduledAt: toIso(row.scheduledAt),
startedAt: toIso(row.startedAt),
completedAt: toIso(row.completedAt),
error: row.error ?? undefined,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
async listProfiles(): Promise<GatewayProfileRecord[]> {
const rows = await prisma.gatewayProfile.findMany({
@@ -327,4 +416,111 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
},
});
},
async listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]> {
const rows = await prisma.gatewayOperation.findMany({
where: options?.profileName ? { profileName: options.profileName } : undefined,
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(options?.limit ?? 50, 1), 200),
});
return rows.map(mapOperation);
},
async getOperation(id: string): Promise<GatewayOperationRecord | null> {
const row = await prisma.gatewayOperation.findUnique({ where: { id } });
return row ? mapOperation(row) : null;
},
async createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord> {
const row = await prisma.gatewayOperation.create({
data: {
profileName: input.profileName,
type: input.type,
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: (input.payload ?? {}) as GatewayPrisma.JsonObject,
reason: input.reason,
requestedBy: input.requestedBy,
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
},
});
return mapOperation(row);
},
async claimNextOperation(now: Date): Promise<GatewayOperationRecord | null> {
const row = await prisma.$transaction(async (tx) => {
const candidate = await tx.gatewayOperation.findFirst({
where: {
status: 'QUEUED',
OR: [{ scheduledAt: null }, { scheduledAt: { lte: now } }],
},
orderBy: { createdAt: 'asc' },
});
if (!candidate) {
return null;
}
const claimed = await tx.gatewayOperation.updateMany({
where: { id: candidate.id, status: 'QUEUED' },
data: { status: 'RUNNING', startedAt: now, error: null },
});
if (claimed.count !== 1) {
return null;
}
return tx.gatewayOperation.findUnique({ where: { id: candidate.id } });
});
return row ? mapOperation(row) : null;
},
async completeOperation(
id: string,
status: Extract<GatewayOperationStatus, 'SUCCEEDED' | 'FAILED'>,
fields?: { resolvedCommitSha?: string | null; error?: string | null }
): Promise<GatewayOperationRecord> {
const row = await prisma.gatewayOperation.update({
where: { id },
data: {
status,
completedAt: new Date(),
resolvedCommitSha:
fields?.resolvedCommitSha === undefined ? undefined : fields.resolvedCommitSha,
error: fields?.error === undefined ? undefined : fields.error,
},
});
return mapOperation(row);
},
async requeueOperation(id: string, detail?: string, retryAt?: string): Promise<GatewayOperationRecord> {
const row = await prisma.gatewayOperation.update({
where: { id },
data: {
status: 'QUEUED',
startedAt: null,
error: detail,
scheduledAt: retryAt ? new Date(retryAt) : undefined,
},
});
return mapOperation(row);
},
async cancelOperation(id: string): Promise<boolean> {
const result = await prisma.gatewayOperation.updateMany({
where: { id, status: 'QUEUED' },
data: { status: 'CANCELLED', completedAt: new Date() },
});
return result.count === 1;
},
async retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null> {
const row = await prisma.$transaction(async (tx) => {
const previous = await tx.gatewayOperation.findUnique({ where: { id } });
if (!previous || (previous.status !== 'FAILED' && previous.status !== 'CANCELLED')) {
return null;
}
return tx.gatewayOperation.create({
data: {
profileName: previous.profileName,
type: previous.type,
sourceMode: previous.sourceMode,
sourceRef: previous.sourceRef,
payload: previous.payload as GatewayPrisma.JsonObject,
reason: previous.reason,
requestedBy,
scheduledAt: null,
},
});
});
return row ? mapOperation(row) : null;
},
});
@@ -40,6 +40,15 @@ const ensureDir = (dir: string): void => {
};
const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/;
const assertGitRef = (value: string): string => {
const ref = value.trim();
if (!ref || ref.startsWith('-') || ref.includes('..') || !GIT_REF_PATTERN.test(ref)) {
throw new Error('Invalid git ref.');
}
return ref;
};
export class GitWorkspaceManager {
private readonly repoRoot: string;
@@ -52,6 +61,28 @@ export class GitWorkspaceManager {
this.baseEnv = options.baseEnv;
}
async resolveCommit(sourceMode: 'BRANCH' | 'COMMIT', sourceRef: string): Promise<string> {
const ref = assertGitRef(sourceRef);
if (sourceMode === 'BRANCH') {
const fetched = await runGit(['fetch', '--all', '--prune'], this.repoRoot, this.baseEnv);
if (!fetched.ok) {
throw new Error(fetched.output || 'Failed to fetch git branches.');
}
}
const candidates =
sourceMode === 'BRANCH'
? [`refs/remotes/origin/${ref}^{commit}`, `refs/heads/${ref}^{commit}`]
: [`${ref}^{commit}`];
for (const candidate of candidates) {
const result = await runGit(['rev-parse', '--verify', candidate], this.repoRoot, this.baseEnv);
const commitSha = result.output.trim().split('\n')[0];
if (result.ok && /^[0-9a-f]{40}$/i.test(commitSha)) {
return commitSha;
}
}
throw new Error(`${sourceMode === 'BRANCH' ? 'Branch' : 'Commit'} not found.`);
}
async prepare(commitSha: string): Promise<WorkspaceInfo> {
const workspacePath = path.join(this.worktreeRoot, commitSha);
ensureDir(this.worktreeRoot);
+8
View File
@@ -10,6 +10,7 @@ import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { accountRouter } from './account/router.js';
const zUsername = z.string().min(2).max(32);
const zPassword = z.string().min(6).max(128);
@@ -61,6 +62,7 @@ export const appRouter = router({
}),
}),
admin: adminRouter,
account: accountRouter,
auth: router({
bootstrapLocal: procedure
.input(
@@ -330,6 +332,12 @@ export const appRouter = router({
message: 'Invalid username or password.',
});
}
if (user.deleteAfter) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account deletion is pending.',
});
}
const ok = await ctx.users.verifyPassword(user, input.password);
if (!ok) {
throw new TRPCError({
@@ -98,6 +98,22 @@ export const resolveGitCommitSha = async (gitRef: string): Promise<string> => {
return commit;
};
export const resolveGitBranchCommitSha = async (branch: string): Promise<string> => {
const normalized = normalizeGitRef(branch);
if (!normalized) {
throw new Error('git branch is invalid.');
}
await runGit(['fetch', '--all', '--prune']);
for (const candidate of [`refs/remotes/origin/${normalized}`, `refs/heads/${normalized}`]) {
const result = await runGit(['rev-parse', '--verify', `${candidate}^{commit}`]);
const commit = result.output.trim().split('\n')[0];
if (result.ok && /^[0-9a-f]{40}$/i.test(commit)) {
return commit;
}
}
throw new Error('git branch not found.');
};
const readGitFile = async (commitSha: string, relativePath: string): Promise<string> => {
const result = await runGit(['show', `${commitSha}:${relativePath}`]);
if (!result.ok) {
+11
View File
@@ -1,5 +1,8 @@
import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import {
createGatewayPostgresConnector,
@@ -60,6 +63,12 @@ export const createGatewayApiServer = async () => {
origin: true,
credentials: true,
});
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
await app.register(fastifyStatic, {
root: path.resolve(process.cwd(), config.userIconDir),
prefix: '/user-icons/',
decorateReply: false,
});
await app.register(fastifyTRPCPlugin, {
prefix: config.trpcPath,
@@ -75,6 +84,8 @@ export const createGatewayApiServer = async () => {
kakaoClient,
oauthSessions,
publicBaseUrl: config.publicBaseUrl,
userIconDir: path.resolve(process.cwd(), config.userIconDir),
userIconPublicUrl: config.userIconPublicUrl,
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
profiles,
orchestrator,
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
import type { GatewayOperationCreateInput, GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
import { createGatewayApiContext } from '../src/context.js';
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
import { appRouter } from '../src/router.js';
const buildCaller = async (createOperation: GatewayProfileRepository['createOperation']) => {
const users = createInMemoryUserRepository();
const admin = await users.createUser({
username: 'admin',
password: 'secretpass',
displayName: 'Admin',
});
await users.updateRoles(admin.id, ['superuser']);
const sessions = new InMemoryGatewaySessionService({
sessionTtlSeconds: 600,
gameSessionTtlSeconds: 600,
});
const session = await sessions.createSession({ ...admin, roles: ['superuser'] });
const createdInputs: GatewayOperationCreateInput[] = [];
const profile = {
profileName: 'che:2',
profile: 'che',
scenario: '2',
apiPort: 15003,
status: 'STOPPED' as const,
buildStatus: 'SUCCEEDED' as const,
meta: {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
};
const profiles: GatewayProfileRepository = {
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateStatus: async () => profile,
updateBuildStatus: async () => profile,
updateMeta: async () => profile,
listReservedToStart: async () => [],
findQueuedBuild: async () => null,
updateLastError: async () => {},
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
listOperations: async () => [],
getOperation: async () => null,
createOperation: async (input) => {
createdInputs.push(input);
return createOperation(input);
},
claimNextOperation: async () => null,
completeOperation: async () => {
throw new Error('not used');
},
requeueOperation: async () => {
throw new Error('not used');
},
cancelOperation: async () => false,
retryOperation: async () => null,
};
const caller = appRouter.createCaller(
createGatewayApiContext({
users,
sessions,
flushPublisher: { publishUserFlush: async () => {} },
gameTokenSecret: 'test-secret',
gameSessionTtlSeconds: 600,
kakaoClient: {} as never,
oauthSessions: {} as never,
publicBaseUrl: 'http://localhost',
adminLocalAccountEnabled: false,
profiles,
orchestrator: {
start: () => {},
stop: async () => {},
reconcileNow: async () => {},
runScheduleNow: async () => {},
runBuildQueueNow: async () => {},
runOperationsNow: async () => {},
cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }),
listRuntimeStates: async () => [],
},
profileStatus: new InMemoryProfileStatusService(),
requestHeaders: { 'x-session-token': session.sessionToken },
prisma: {
appUser: {
findFirst: async () => ({ id: admin.id }),
},
} as unknown as GatewayPrismaClient,
})
);
return { caller, createdInputs };
};
describe('admin operation API', () => {
it('queues a start operation with the authenticated requester', async () => {
const operation = {
id: '11111111-1111-4111-8111-111111111111',
profileName: 'che:2',
type: 'START' as const,
status: 'QUEUED' as const,
payload: {},
requestedBy: 'admin-id',
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
};
const harness = await buildCaller(async () => operation);
const result = await harness.caller.admin.operations.requestRuntime({
profileName: 'che:2',
action: 'START',
reason: 'maintenance complete',
});
expect(result.type).toBe('START');
expect(harness.createdInputs[0]).toMatchObject({
profileName: 'che:2',
type: 'START',
reason: 'maintenance complete',
});
});
it('reports an active-operation uniqueness conflict', async () => {
const harness = await buildCaller(async () => {
throw { code: 'P2002' };
});
await expect(
harness.caller.admin.operations.requestRuntime({
profileName: 'che:2',
action: 'STOP',
})
).rejects.toMatchObject({ code: 'CONFLICT' });
});
});
+119 -2
View File
@@ -1,4 +1,8 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import sharp from 'sharp';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
@@ -10,7 +14,7 @@ import { appRouter } from '../src/router.js';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
const buildCaller = () => {
const buildCaller = (options: { userIconDir?: string } = {}) => {
const users = createInMemoryUserRepository();
const sessions = new InMemoryGatewaySessionService({
sessionTtlSeconds: 3600,
@@ -59,6 +63,20 @@ const buildCaller = () => {
updateLastError: async () => {},
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
listOperations: async () => [],
getOperation: async () => null,
createOperation: async () => {
throw new Error('not implemented');
},
claimNextOperation: async () => null,
completeOperation: async () => {
throw new Error('not implemented');
},
requeueOperation: async () => {
throw new Error('not implemented');
},
cancelOperation: async () => false,
retryOperation: async () => null,
};
const orchestrator = {
start: () => {},
@@ -66,6 +84,7 @@ const buildCaller = () => {
reconcileNow: async () => {},
runScheduleNow: async () => {},
runBuildQueueNow: async () => {},
runOperationsNow: async () => {},
cleanupStaleWorkspaces: async () => ({
removed: [],
skipped: [],
@@ -83,6 +102,8 @@ const buildCaller = () => {
kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
oauthSessions,
publicBaseUrl: 'http://localhost',
userIconDir: options.userIconDir,
userIconPublicUrl: 'http://localhost/user-icons',
adminLocalAccountEnabled: false,
profiles,
orchestrator,
@@ -95,7 +116,7 @@ const buildCaller = () => {
} as unknown as GatewayPrismaClient,
})
);
return { caller, oauthSessions };
return { caller, oauthSessions, users, sessions };
};
describe('gateway auth flow', () => {
@@ -167,3 +188,99 @@ describe('gateway auth flow', () => {
expect(validated?.user.username).toBe('tester');
});
});
describe('account self service', () => {
it('changes only the authenticated user password after verifying the current password', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'self-service',
password: 'current-password',
});
const session = await sessions.createSession(user);
await expect(
caller.account.changePassword({
sessionToken: session.sessionToken,
currentPassword: 'wrong-password',
newPassword: 'next-password',
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await caller.account.changePassword({
sessionToken: session.sessionToken,
currentPassword: 'current-password',
newPassword: 'next-password',
});
const refreshed = await users.findById(user.id);
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
});
it('revokes the session and schedules deletion after 30 days', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'delete-self',
password: 'current-password',
});
const session = await sessions.createSession(user);
const result = await caller.account.scheduleDeletion({
sessionToken: session.sessionToken,
currentPassword: 'current-password',
});
expect(new Date(result.deleteAfter).getTime()).toBeGreaterThan(Date.now() + 29 * 24 * 60 * 60 * 1000);
expect((await users.findById(user.id))?.deleteAfter).toBe(result.deleteAfter);
expect(await sessions.getSession(session.sessionToken)).toBeNull();
});
it('revokes third-party use consent without allowing it to be re-enabled', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'privacy-self',
password: 'current-password',
});
const session = await sessions.createSession(user);
await caller.account.disallowThirdPartyUse({ sessionToken: session.sessionToken });
expect((await users.findById(user.id))?.thirdPartyUse).toBe(false);
});
it('validates and stores a legacy-sized account icon with a daily change limit', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-'));
try {
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
const user = await users.createUser({
username: 'icon-self',
password: 'current-password',
});
const session = await sessions.createSession(user);
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: '#334455',
},
})
.png()
.toBuffer();
const result = await caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
});
const updated = await users.findById(user.id);
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
expect(updated?.imageServer).toBe(1);
expect(await fs.stat(path.join(iconDir, updated?.picture ?? 'missing'))).toBeTruthy();
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
});
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js';
import type {
GatewayOperationRecord,
GatewayOperationStatus,
GatewayProfileRecord,
GatewayProfileRepository,
} from '../src/orchestrator/profileRepository.js';
import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const profile: GatewayProfileRecord = {
profileName: 'che:2',
profile: 'che',
scenario: '2',
apiPort: 15003,
status: 'STOPPED',
buildStatus: 'SUCCEEDED',
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
meta: {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
};
const buildOperation = (type: 'START' | 'STOP'): GatewayOperationRecord => ({
id: '11111111-1111-4111-8111-111111111111',
profileName: profile.profileName,
type,
status: 'RUNNING',
payload: {},
requestedBy: 'admin',
createdAt: '2026-07-25T01:00:00.000Z',
startedAt: '2026-07-25T01:00:00.000Z',
updatedAt: '2026-07-25T01:00:00.000Z',
});
const createHarness = (operation: GatewayOperationRecord, failStart = false, failStop = false) => {
let nextOperation: GatewayOperationRecord | null = operation;
const statuses: string[] = [];
const completions: GatewayOperationStatus[] = [];
const started: ProcessDefinition[] = [];
const stopped: string[] = [];
const deleted: string[] = [];
const repository: GatewayProfileRepository = {
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateStatus: async (_profileName, status) => {
statuses.push(status);
return { ...profile, status };
},
updateBuildStatus: async () => profile,
updateMeta: async () => profile,
listReservedToStart: async () => [],
findQueuedBuild: async () => null,
updateLastError: async () => {},
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
listOperations: async () => [],
getOperation: async () => operation,
createOperation: async () => operation,
claimNextOperation: async () => {
const result = nextOperation;
nextOperation = null;
return result;
},
completeOperation: async (_id, status) => {
completions.push(status);
return { ...operation, status };
},
requeueOperation: async () => ({ ...operation, status: 'QUEUED' }),
cancelOperation: async () => false,
retryOperation: async () => null,
};
const processManager: ProcessManager = {
list: async () => [],
start: async (definition) => {
if (failStart) {
throw new Error('pm2 unavailable');
}
started.push(definition);
},
stop: async (name) => {
stopped.push(name);
if (failStop) {
throw new Error('pm2 stop failed');
}
},
delete: async (name) => {
deleted.push(name);
if (failStop) {
throw new Error('pm2 delete failed');
}
},
};
const orchestrator = new GatewayOrchestrator({
repository,
processManager,
buildRunner: {
run: async () => ({ ok: true, exitCode: 0, output: '' }),
},
workspaceManager: new GitWorkspaceManager({
repoRoot: '/tmp/not-used',
worktreeRoot: '/tmp/not-used-worktrees',
}),
processConfig: {
workspaceRoot: '/srv/sammo',
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000,
});
return { orchestrator, statuses, completions, started, stopped, deleted };
};
describe('GatewayOrchestrator first-class operations', () => {
it('starts both profile processes and records success', async () => {
const harness = createHarness(buildOperation('START'));
await harness.orchestrator.runOperationsNow();
expect(harness.statuses).toEqual(['RUNNING']);
expect(harness.started.map((definition) => definition.name)).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
]);
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('stops both profile processes and records success', async () => {
const harness = createHarness(buildOperation('STOP'));
await harness.orchestrator.runOperationsNow();
expect(harness.statuses).toEqual(['STOPPED']);
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('records a failed start instead of reporting a false success', async () => {
const harness = createHarness(buildOperation('START'), true);
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
});
it('attempts to stop both roles before reporting a partial PM2 failure', async () => {
const harness = createHarness(buildOperation('STOP'), false, true);
await harness.orchestrator.runOperationsNow();
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
expect(harness.completions).toEqual(['FAILED']);
});
});
@@ -0,0 +1,88 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const temporaryRoots: string[] = [];
const git = (cwd: string, ...args: string[]): string =>
execFileSync('git', args, {
cwd,
encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'Sammo Test',
GIT_AUTHOR_EMAIL: 'sammo-test@example.invalid',
GIT_COMMITTER_NAME: 'Sammo Test',
GIT_COMMITTER_EMAIL: 'sammo-test@example.invalid',
},
}).trim();
const createRepositoryFixture = (): {
source: string;
checkout: string;
worktrees: string;
firstCommit: string;
} => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-workspace-manager-'));
temporaryRoots.push(root);
const remote = path.join(root, 'remote.git');
const source = path.join(root, 'source');
const checkout = path.join(root, 'checkout');
const worktrees = path.join(root, 'worktrees');
fs.mkdirSync(source);
git(root, 'init', '--bare', remote);
git(source, 'init', '-b', 'main');
fs.writeFileSync(path.join(source, 'version.txt'), 'first\n');
git(source, 'add', 'version.txt');
git(source, 'commit', '-m', 'first');
const firstCommit = git(source, 'rev-parse', 'HEAD');
git(source, 'remote', 'add', 'origin', remote);
git(source, 'push', '-u', 'origin', 'main');
git(root, 'clone', '--branch', 'main', remote, checkout);
return { source, checkout, worktrees, firstCommit };
};
afterEach(() => {
for (const root of temporaryRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe('GitWorkspaceManager source resolution', () => {
it('keeps COMMIT pinned while BRANCH follows the latest remote head', async () => {
const fixture = createRepositoryFixture();
const manager = new GitWorkspaceManager({
repoRoot: fixture.checkout,
worktreeRoot: fixture.worktrees,
});
expect(await manager.resolveCommit('COMMIT', fixture.firstCommit)).toBe(fixture.firstCommit);
expect(await manager.resolveCommit('BRANCH', 'main')).toBe(fixture.firstCommit);
fs.writeFileSync(path.join(fixture.source, 'version.txt'), 'second\n');
git(fixture.source, 'add', 'version.txt');
git(fixture.source, 'commit', '-m', 'second');
const secondCommit = git(fixture.source, 'rev-parse', 'HEAD');
git(fixture.source, 'push', 'origin', 'main');
expect(await manager.resolveCommit('COMMIT', fixture.firstCommit)).toBe(fixture.firstCommit);
expect(await manager.resolveCommit('BRANCH', 'main')).toBe(secondCommit);
});
it('rejects option-like and range refs', async () => {
const fixture = createRepositoryFixture();
const manager = new GitWorkspaceManager({
repoRoot: fixture.checkout,
worktreeRoot: fixture.worktrees,
});
await expect(manager.resolveCommit('BRANCH', '--upload-pack=bad')).rejects.toThrow('Invalid git ref');
await expect(manager.resolveCommit('COMMIT', 'HEAD..main')).rejects.toThrow('Invalid git ref');
});
});
@@ -0,0 +1,34 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
export default defineConfig({
testDir: '.',
testMatch: 'server-operations.spec.ts',
fullyParallel: false,
workers: 1,
timeout: 30_000,
expect: {
timeout: 5_000,
},
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/server-operations'),
use: {
baseURL: 'http://127.0.0.1:15130/gateway/',
...devices['Desktop Chrome'],
deviceScaleFactor: 1,
colorScheme: 'dark',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: {
command:
'VITE_APP_BASE_PATH=/gateway pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130',
cwd: repositoryRoot,
url: 'http://127.0.0.1:15130/gateway/',
reuseExistingServer: false,
timeout: 120_000,
},
});
@@ -0,0 +1,223 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
type Operation = {
id: string;
profileName: string;
type: 'RESET' | 'START' | 'STOP';
status: OperationStatus;
sourceMode?: 'BRANCH' | 'COMMIT';
sourceRef?: string;
resolvedCommitSha?: string;
payload: Record<string, unknown>;
requestedBy: string;
createdAt: string;
updatedAt: string;
};
type FixtureState = {
operations: Operation[];
runtimeRunning: boolean;
requestBodies: Array<{ operation: string; body: unknown }>;
};
const profile = (runtimeRunning: boolean) => ({
profileName: 'che:2',
profile: 'che',
scenario: '2',
apiPort: 15003,
status: runtimeRunning ? 'RUNNING' : 'STOPPED',
buildStatus: 'SUCCEEDED',
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef0123456789abcdef01234567',
meta: {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
runtime: {
profileName: 'che:2',
apiRunning: runtimeRunning,
daemonRunning: runtimeRunning,
},
});
const scenarios = [
{
id: 2,
title: '【테스트】황건의 난',
year: 184,
npcCount: 42,
npcExCount: 0,
npcNeutralCount: 0,
nations: [],
},
{
id: 5,
title: '【테스트】군웅할거',
year: 190,
npcCount: 55,
npcExCount: 0,
npcNeutralCount: 0,
nations: [],
},
];
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installFixture = async (page: Page, state: FixtureState) => {
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'playwright-admin-session');
});
await page.route('**/gateway/api/trpc/**', async (route) => {
const names = operationNames(route);
const body = route.request().postDataJSON() as unknown;
const results = names.map((name) => {
if (route.request().method() === 'POST') {
state.requestBodies.push({ operation: name, body });
}
if (name === 'admin.profiles.list') {
return response([profile(state.runtimeRunning)]);
}
if (name === 'admin.operations.list') {
return response(state.operations);
}
if (name === 'admin.profiles.listScenarios') {
return response(scenarios);
}
if (name === 'admin.operations.requestReset') {
const operation: Operation = {
id: '11111111-1111-4111-8111-111111111111',
profileName: 'che:2',
type: 'RESET',
status: 'QUEUED',
sourceMode: 'COMMIT',
sourceRef: '0123456789abcdef0123456789abcdef01234567',
payload: {},
requestedBy: 'admin',
createdAt: '2026-07-25T02:00:00.000Z',
updatedAt: '2026-07-25T02:00:00.000Z',
};
state.operations = [operation];
return response(operation);
}
if (name === 'admin.operations.requestRuntime') {
const serialized = JSON.stringify(body);
const type = serialized.includes('"STOP"') ? 'STOP' : 'START';
state.runtimeRunning = type === 'START';
const operation: Operation = {
id:
type === 'START'
? '22222222-2222-4222-8222-222222222222'
: '33333333-3333-4333-8333-333333333333',
profileName: 'che:2',
type,
status: 'SUCCEEDED',
payload: {},
requestedBy: 'admin',
createdAt: '2026-07-25T03:00:00.000Z',
updatedAt: '2026-07-25T03:00:00.000Z',
};
state.operations = [operation];
return response(operation);
}
throw new Error(`Unhandled tRPC operation: ${name}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
};
test('separates branch and commit semantics and submits a reset from the dedicated page', async ({
page,
}, testInfo) => {
const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] };
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/server-operations');
await expect(page.getByTestId('server-operations-page')).toBeVisible();
await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/);
await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때');
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
const desktopGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => {
const children = Array.from(section.children).map((child) => {
const rect = child.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
return children;
});
expect(desktopGeometry).toHaveLength(2);
expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
const sourceInput = page.getByTestId('source-ref');
await sourceInput.focus();
const focusedInputStyle = await sourceInput.evaluate((element) => {
const style = getComputedStyle(element);
return {
borderColor: style.borderColor,
backgroundColor: style.backgroundColor,
color: style.color,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
outlineStyle: style.outlineStyle,
};
});
await writeFile(
testInfo.outputPath('layout-metrics.json'),
JSON.stringify({ desktopGeometry, focusedInputStyle }, null, 2)
);
await page.screenshot({ path: testInfo.outputPath('desktop-operations.png'), fullPage: true });
await page.getByTestId('source-commit').check();
await expect(page.getByTestId('source-help')).toContainText('전체 SHA로 고정');
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
await page.getByTestId('load-scenarios').click();
await page.getByTestId('scenario-select').selectOption('5');
await page.getByTestId('request-reset').hover();
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('RESET');
const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
await page.setViewportSize({ width: 390, height: 844 });
const mobileGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => {
const children = Array.from(section.children).map((child) => {
const rect = child.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width };
});
return children;
});
expect(mobileGeometry[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y);
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
});
test('starts and stops both runtime roles through the operation controls', async ({ page }) => {
const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] };
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/server-operations');
await page.getByTestId('start-server').click();
await expect(page.getByText('시작 작업을 요청했습니다.')).toBeVisible();
await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible();
await page.getByTestId('stop-server').click();
await expect(page.getByText('정지 작업을 요청했습니다.')).toBeVisible();
await expect(page.getByText('STOPPED', { exact: true }).first()).toBeVisible();
const serializedRequests = state.requestBodies.map((entry) => JSON.stringify(entry.body)).join('\n');
expect(serializedRequests).toContain('"action":"START"');
expect(serializedRequests).toContain('"action":"STOP"');
});
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc pnpm build && playwright test --config e2e/playwright.config.mjs",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint .",
+6
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import LobbyView from '../views/LobbyView.vue';
import AdminView from '../views/AdminView.vue';
import ServerOperationsView from '../views/ServerOperationsView.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -21,6 +22,11 @@ const router = createRouter({
name: 'admin',
component: AdminView,
},
{
path: '/admin/server-operations',
name: 'server-operations',
component: ServerOperationsView,
},
],
});
+11 -3
View File
@@ -1012,9 +1012,17 @@ onMounted(() => {
<template>
<DefaultLayout>
<div class="max-w-6xl mx-auto px-4 py-10 space-y-10">
<div class="space-y-2">
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
<p class="text-sm text-zinc-400">유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.</p>
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="space-y-2">
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
<p class="text-sm text-zinc-400">유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.</p>
</div>
<RouterLink
to="/admin/server-operations"
class="rounded bg-amber-500 px-4 py-2 text-center text-sm font-bold text-black hover:bg-amber-400"
>
서버 배포 · 시나리오 초기화
</RouterLink>
</div>
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
@@ -0,0 +1,623 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import { trpc } from '../utils/trpc';
const adminClient = trpc.admin;
type Profile = {
profileName: string;
profile: string;
scenario: string;
status: string;
buildStatus: string;
buildCommitSha?: string;
buildWorkspace?: string;
buildError?: string;
lastError?: string;
runtime: { apiRunning: boolean; daemonRunning: boolean };
};
type Scenario = {
id: number;
title: string;
year: number | null;
npcCount: number;
};
type Operation = {
id: string;
profileName: string;
type: 'RESET' | 'START' | 'STOP';
status: 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
sourceMode?: 'BRANCH' | 'COMMIT';
sourceRef?: string;
resolvedCommitSha?: string;
reason?: string;
requestedBy: string;
scheduledAt?: string;
startedAt?: string;
completedAt?: string;
error?: string;
createdAt: string;
};
const profiles = ref<Profile[]>([]);
const scenarios = ref<Scenario[]>([]);
const operations = ref<Operation[]>([]);
const selectedProfileName = ref('');
const loading = ref(false);
const catalogLoading = ref(false);
const submitting = ref(false);
const message = ref('');
const errorMessage = ref('');
let pollTimer: ReturnType<typeof setInterval> | undefined;
let stateRequestInFlight = false;
const form = reactive({
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
sourceRef: 'main',
scenarioId: 0,
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full' as 'full' | 'onlyRandom',
autorunEnabled: false,
autorunUserMinutes: 1440,
autorunDevelop: true,
autorunWarp: true,
autorunRecruit: true,
autorunTrain: true,
autorunBattle: true,
openAt: '',
preopenAt: '',
scheduledAt: '',
reason: '',
});
const selectedProfile = computed(
() => profiles.value.find((profile) => profile.profileName === selectedProfileName.value) ?? null
);
const activeOperation = computed(
() =>
operations.value.find(
(operation) =>
operation.profileName === selectedProfileName.value &&
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
) ?? null
);
const sourceHelp = computed(() =>
form.sourceMode === 'BRANCH'
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
);
const toIso = (value: string): string | undefined => {
if (!value) {
return undefined;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
};
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => {
message.value = '';
errorMessage.value = '';
};
const loadState = async (quiet = false) => {
if (stateRequestInFlight) {
return;
}
stateRequestInFlight = true;
if (!quiet) {
loading.value = true;
}
try {
const profileResult = await adminClient.profiles.list.query();
const operationResult = await adminClient.operations.list.query({ limit: 100 });
profiles.value = profileResult as Profile[];
operations.value = operationResult as Operation[];
if (!selectedProfileName.value && profiles.value.length > 0) {
selectedProfileName.value = profiles.value[0].profileName;
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
} finally {
loading.value = false;
stateRequestInFlight = false;
}
};
const loadScenarios = async () => {
clearStatus();
if (!form.sourceRef.trim()) {
errorMessage.value = '브랜치 또는 커밋을 입력해주세요.';
return;
}
catalogLoading.value = true;
try {
const result = await adminClient.profiles.listScenarios.query({
gitRef: form.sourceRef.trim(),
sourceMode: form.sourceMode,
});
scenarios.value = result as Scenario[];
if (!scenarios.value.some((scenario) => scenario.id === form.scenarioId)) {
const profileScenario = Number(selectedProfile.value?.scenario);
form.scenarioId =
scenarios.value.find((scenario) => scenario.id === profileScenario)?.id ?? scenarios.value[0]?.id ?? 0;
}
message.value = `${scenarios.value.length}개 시나리오를 확인했습니다.`;
} catch (error) {
scenarios.value = [];
errorMessage.value = error instanceof Error ? error.message : '소스에서 시나리오를 읽지 못했습니다.';
} finally {
catalogLoading.value = false;
}
};
const requestRuntime = async (action: 'START' | 'STOP') => {
clearStatus();
if (!selectedProfile.value || activeOperation.value) {
return;
}
const label = action === 'START' ? '시작' : '정지';
if (!window.confirm(`${selectedProfile.value.profileName} 서버를 ${label}하시겠습니까?`)) {
return;
}
submitting.value = true;
try {
await adminClient.operations.requestRuntime.mutate({
profileName: selectedProfile.value.profileName,
action,
reason: form.reason.trim() || undefined,
});
message.value = `${label} 작업을 요청했습니다.`;
await loadState(true);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : `${label} 요청에 실패했습니다.`;
} finally {
submitting.value = false;
}
};
const selectedAutorunOptions = (): Array<
'develop' | 'warp' | 'recruit' | 'train' | 'battle'
> => {
const options: Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> = [];
if (form.autorunDevelop) options.push('develop');
if (form.autorunWarp) options.push('warp');
if (form.autorunRecruit) options.push('recruit');
if (form.autorunTrain) options.push('train');
if (form.autorunBattle) options.push('battle');
return options;
};
const requestReset = async () => {
clearStatus();
if (!selectedProfile.value || activeOperation.value) {
return;
}
if (!form.sourceRef.trim() || !form.scenarioId) {
errorMessage.value = '소스와 시나리오를 먼저 선택해주세요.';
return;
}
const sourceLabel = form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
if (
!window.confirm(
`${selectedProfile.value.profileName}의 게임 DB를 초기화합니다.\n${sourceLabel}: ${form.sourceRef}\n시나리오: ${form.scenarioId}`
)
) {
return;
}
submitting.value = true;
try {
await adminClient.operations.requestReset.mutate({
profileName: selectedProfile.value.profileName,
sourceMode: form.sourceMode,
sourceRef: form.sourceRef.trim(),
scheduledAt: toIso(form.scheduledAt),
reason: form.reason.trim() || undefined,
install: {
scenarioId: form.scenarioId,
turnTermMinutes: form.turnTermMinutes,
sync: form.sync,
fiction: form.fiction,
extend: form.extend,
blockGeneralCreate: form.blockGeneralCreate,
npcMode: form.npcMode,
showImgLevel: form.showImgLevel,
tournamentTrig: form.tournamentTrig,
joinMode: form.joinMode,
autorunUser: form.autorunEnabled
? {
limitMinutes: form.autorunUserMinutes,
options: selectedAutorunOptions(),
}
: null,
openAt: toIso(form.openAt),
preopenAt: toIso(form.preopenAt),
},
});
message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 시작했습니다.';
await loadState(true);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '초기화 요청에 실패했습니다.';
} finally {
submitting.value = false;
}
};
const cancelOperation = async (operation: Operation) => {
clearStatus();
if (!window.confirm('대기 중인 작업을 취소하시겠습니까?')) {
return;
}
try {
await adminClient.operations.cancel.mutate({ id: operation.id });
message.value = '작업을 취소했습니다.';
await loadState(true);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '작업 취소에 실패했습니다.';
}
};
const retryOperation = async (operation: Operation) => {
clearStatus();
if (!window.confirm('같은 입력으로 작업을 다시 실행하시겠습니까?')) {
return;
}
try {
await adminClient.operations.retry.mutate({ id: operation.id });
message.value = '재시도 작업을 등록했습니다.';
await loadState(true);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '작업 재시도에 실패했습니다.';
}
};
watch(selectedProfileName, () => {
const scenarioId = Number(selectedProfile.value?.scenario);
if (Number.isFinite(scenarioId)) {
form.scenarioId = scenarioId;
}
});
onMounted(async () => {
await loadState();
await loadScenarios();
pollTimer = setInterval(() => void loadState(true), 3000);
});
onBeforeUnmount(() => {
if (pollTimer) {
clearInterval(pollTimer);
}
});
</script>
<template>
<DefaultLayout>
<div class="max-w-7xl mx-auto px-4 py-8 space-y-6" data-testid="server-operations-page">
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p class="text-xs uppercase tracking-[0.25em] text-amber-400">Operations console</p>
<h2 class="text-2xl font-bold text-white">서버 배포 · 시나리오 초기화</h2>
<p class="mt-2 text-sm text-zinc-400">
실행 소스, 초기화 옵션, 프로세스 상태와 작업 이력을 화면에서 관리합니다.
</p>
</div>
<button
class="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm hover:border-zinc-500 disabled:opacity-50"
:disabled="loading"
data-testid="refresh-operations"
@click="loadState()"
>
상태 새로고침
</button>
</header>
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
{{ errorMessage }}
</div>
<div v-if="message" class="rounded border border-emerald-800 bg-emerald-950/40 px-4 py-3 text-sm text-emerald-200">
{{ message }}
</div>
<section class="grid gap-4 lg:grid-cols-[1.1fr_1.9fr]">
<div class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-4">
<div>
<label class="text-xs text-zinc-400" for="profile-select">운영 프로필</label>
<select
id="profile-select"
v-model="selectedProfileName"
class="mt-2 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
data-testid="profile-select"
>
<option v-for="profile in profiles" :key="profile.profileName" :value="profile.profileName">
{{ profile.profileName }}
</option>
</select>
</div>
<div v-if="selectedProfile" class="grid grid-cols-2 gap-3 text-sm">
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">목표 상태</div>
<div class="mt-1 font-semibold">{{ selectedProfile.status }}</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">빌드</div>
<div class="mt-1 font-semibold">{{ selectedProfile.buildStatus }}</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Game API</div>
<div :class="selectedProfile.runtime.apiRunning ? 'text-emerald-400' : 'text-zinc-500'">
{{ selectedProfile.runtime.apiRunning ? 'RUNNING' : 'STOPPED' }}
</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Turn daemon</div>
<div :class="selectedProfile.runtime.daemonRunning ? 'text-emerald-400' : 'text-zinc-500'">
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
</div>
</div>
</div>
<div v-if="selectedProfile" class="space-y-1 text-xs text-zinc-500">
<div>현재 커밋: <span class="font-mono text-zinc-300">{{ shortSha(selectedProfile.buildCommitSha) }}</span></div>
<div class="break-all">worktree: {{ selectedProfile.buildWorkspace ?? '기본 workspace' }}</div>
<div v-if="selectedProfile.buildError" class="text-red-400">{{ selectedProfile.buildError }}</div>
<div v-if="selectedProfile.lastError" class="text-red-400">{{ selectedProfile.lastError }}</div>
</div>
<div class="grid grid-cols-2 gap-2">
<button
class="rounded bg-emerald-700 px-3 py-2 font-semibold text-white hover:bg-emerald-600 disabled:opacity-40"
:disabled="submitting || Boolean(activeOperation)"
data-testid="start-server"
@click="requestRuntime('START')"
>
서버 시작
</button>
<button
class="rounded bg-red-800 px-3 py-2 font-semibold text-white hover:bg-red-700 disabled:opacity-40"
:disabled="submitting || Boolean(activeOperation)"
data-testid="stop-server"
@click="requestRuntime('STOP')"
>
서버 정지
</button>
</div>
</div>
<form class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5" @submit.prevent="requestReset">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">시나리오 초기화</h3>
<span v-if="activeOperation" class="rounded-full bg-amber-500/15 px-3 py-1 text-xs text-amber-300">
{{ activeOperation.type }} · {{ activeOperation.status }}
</span>
</div>
<fieldset class="space-y-2">
<legend class="text-xs text-zinc-400">소스 종류</legend>
<div class="flex gap-5">
<label class="flex items-center gap-2">
<input v-model="form.sourceMode" type="radio" value="BRANCH" data-testid="source-branch" />
브랜치
</label>
<label class="flex items-center gap-2">
<input v-model="form.sourceMode" type="radio" value="COMMIT" data-testid="source-commit" />
커밋
</label>
</div>
<p class="text-xs text-amber-200/80" data-testid="source-help">{{ sourceHelp }}</p>
</fieldset>
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
<input
v-model="form.sourceRef"
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 font-mono text-sm text-white"
:placeholder="form.sourceMode === 'BRANCH' ? '예: main 또는 release/season-12' : '예: 40자리 commit SHA'"
data-testid="source-ref"
/>
<button
type="button"
class="rounded border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm hover:bg-zinc-700 disabled:opacity-50"
:disabled="catalogLoading"
data-testid="load-scenarios"
@click="loadScenarios"
>
시나리오 확인
</button>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="text-xs text-zinc-400">
시나리오
<select
v-model.number="form.scenarioId"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
data-testid="scenario-select"
>
<option v-for="scenario in scenarios" :key="scenario.id" :value="scenario.id">
{{ scenario.id }} · {{ scenario.title }} (NPC {{ scenario.npcCount }})
</option>
</select>
</label>
<label class="text-xs text-zinc-400">
간격
<select
v-model.number="form.turnTermMinutes"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
>
<option v-for="minutes in [1, 2, 5, 10, 20, 30, 60, 120]" :key="minutes" :value="minutes">
{{ minutes }}
</option>
</select>
</label>
</div>
<details class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
<summary class="cursor-pointer text-sm font-semibold">고급 시나리오 옵션</summary>
<div class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
<label>동기화
<select v-model="form.sync" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option :value="true">사용</option><option :value="false">미사용</option>
</select>
</label>
<label>가상 장수
<select v-model.number="form.fiction" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option :value="1">허용</option><option :value="0">금지</option>
</select>
</label>
<label>연장
<select v-model="form.extend" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option :value="true">사용</option><option :value="false">미사용</option>
</select>
</label>
<label>가입 방식
<select v-model="form.joinMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option value="full">전체</option><option value="onlyRandom">랜덤만</option>
</select>
</label>
<label>장수 생성 제한
<select v-model.number="form.blockGeneralCreate" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option :value="0">없음</option><option :value="1">제한</option><option :value="2">차단</option>
</select>
</label>
<label>NPC 모드
<select v-model.number="form.npcMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option :value="0">기본</option><option :value="1">확장</option><option :value="2">전체</option>
</select>
</label>
<label>이미지 표시
<select v-model.number="form.showImgLevel" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option v-for="level in [0, 1, 2, 3]" :key="level" :value="level">{{ level }}</option>
</select>
</label>
<label class="flex items-center gap-2">
<input v-model="form.tournamentTrig" type="checkbox" /> 토너먼트 사용
</label>
<label class="flex items-center gap-2">
<input v-model="form.autorunEnabled" type="checkbox" /> 유저 자동턴
</label>
<input
v-if="form.autorunEnabled"
v-model.number="form.autorunUserMinutes"
type="number"
min="1"
max="43200"
class="rounded border border-zinc-700 bg-zinc-900 px-2 py-1"
aria-label="자동턴 제한 "
/>
<div v-if="form.autorunEnabled" class="md:col-span-2 flex flex-wrap gap-3 text-xs">
<label><input v-model="form.autorunDevelop" type="checkbox" /> 내정</label>
<label><input v-model="form.autorunWarp" type="checkbox" /> 이동</label>
<label><input v-model="form.autorunRecruit" type="checkbox" /> 징병</label>
<label><input v-model="form.autorunTrain" type="checkbox" /> 훈련</label>
<label><input v-model="form.autorunBattle" type="checkbox" /> 전투</label>
</div>
</div>
</details>
<div class="grid gap-4 md:grid-cols-3">
<label class="text-xs text-zinc-400">작업 예약
<input v-model="form.scheduledAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
</label>
<label class="text-xs text-zinc-400">가오픈
<input v-model="form.preopenAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
</label>
<label class="text-xs text-zinc-400">정식 오픈
<input v-model="form.openAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
</label>
</div>
<input
v-model="form.reason"
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
placeholder="작업 사유 또는 운영 메모"
/>
<button
type="submit"
class="w-full rounded bg-amber-500 px-4 py-3 font-bold text-black hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="submitting || Boolean(activeOperation) || !form.scenarioId"
data-testid="request-reset"
>
{{ form.scheduledAt ? '초기화 예약' : '초기화 시작' }}
</button>
</form>
</section>
<section class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">작업 이력</h3>
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[920px] text-left text-sm" data-testid="operations-table">
<thead class="border-b border-zinc-700 text-xs text-zinc-500">
<tr>
<th class="p-2">요청/예약</th><th class="p-2">프로필</th><th class="p-2">작업</th>
<th class="p-2">상태</th><th class="p-2">소스</th><th class="p-2">해석 커밋</th>
<th class="p-2">요청자/사유</th><th class="p-2">완료/오류</th><th class="p-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="operation in operations" :key="operation.id" class="border-b border-zinc-800 align-top">
<td class="p-2 text-xs">
{{ formatTime(operation.createdAt) }}
<div v-if="operation.scheduledAt" class="mt-1 text-amber-300">
예약 {{ formatTime(operation.scheduledAt) }}
</div>
</td>
<td class="p-2">{{ operation.profileName }}</td>
<td class="p-2">{{ operation.type }}</td>
<td class="p-2 font-semibold">{{ operation.status }}</td>
<td class="p-2 font-mono text-xs">{{ operation.sourceMode ?? '-' }}<br />{{ operation.sourceRef ?? '' }}</td>
<td class="p-2 font-mono text-xs">{{ shortSha(operation.resolvedCommitSha) }}</td>
<td class="max-w-xs p-2 text-xs">
<div class="font-mono">{{ operation.requestedBy }}</div>
<div v-if="operation.reason" class="mt-1 text-zinc-400">{{ operation.reason }}</div>
</td>
<td class="max-w-xs p-2 text-xs">
{{ formatTime(operation.completedAt) }}
<div v-if="operation.error" class="mt-1 text-red-400">{{ operation.error }}</div>
</td>
<td class="p-2">
<button
v-if="operation.status === 'QUEUED'"
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950"
@click="cancelOperation(operation)"
>
취소
</button>
<button
v-else-if="operation.status === 'FAILED' || operation.status === 'CANCELLED'"
class="rounded border border-amber-700 px-2 py-1 text-xs text-amber-300 hover:bg-amber-950"
@click="retryOperation(operation)"
>
재시도
</button>
</td>
</tr>
<tr v-if="operations.length === 0">
<td colspan="9" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</DefaultLayout>
</template>
+3 -2
View File
@@ -68,11 +68,12 @@
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
"test/**/*.ts"
"test/**/*.ts",
"e2e/**/*.ts"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
}