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
+2
View File
@@ -26,6 +26,8 @@ GATEWAY_REDIS_PREFIX=sammo:gateway
GATEWAY_DB_SCHEMA=public
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees
GATEWAY_USER_ICON_DIR=uploads/user-icons
GATEWAY_USER_ICON_PUBLIC_URL=http://localhost:13000/user-icons
SESSION_TTL_SECONDS=604800
GAME_SESSION_TTL_SECONDS=21600
OAUTH_SESSION_TTL_SECONDS=600
+1
View File
@@ -164,3 +164,4 @@ docker-compose.override.yml
docs/image-storage.md
playwright-report/
test-results/
uploads/
+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"
}
]
}
}
@@ -0,0 +1,720 @@
# 일반 장수 명령 차등 테스트 설계
## 상태
- 문서 상태: 구현 전 승인 가능한 설계
- 비교 기준: `ref/sam``ng_compare` 브랜치
- 대상: 휴식과 `cr_건국`을 포함한 일반 장수 예약 명령 55개
- 범위: 명령 결과, RNG 소비, 로그, 예약 턴 lifecycle, DB 영속화
이 문서는 테스트 구현 자체가 아니다. 아래의 파일, 실행기, 격리 스택과
fixture가 구현되고 완료 기준을 통과하기 전까지 55개 명령의 동적 호환 상태를
`확인`으로 올리지 않는다.
## 결정 요약
일반 장수 명령은 하나의 canonical fixture로 다음 세 결과를 만든다.
1. ref PHP가 격리된 MariaDB에서 실제 예약 턴을 실행한 결과
2. core2026이 `InMemoryTurnWorld`에서 같은 예약 턴을 실행한 결과
3. 2의 dirty state를 격리된 PostgreSQL에 flush하고 다시 읽은 결과
두 비교를 모두 통과해야 한다.
```text
canonical fixture
├── ref fixture adapter ──> PHP full turn ──> MariaDB ──> ref projection
│ │
└── core fixture adapter ─> InMemory full turn ──────────┼─ exact semantic diff
│ │
└── DB hooks ─> PostgreSQL ─┘
```
- `ref projection == core memory projection`은 호환 로직을 검증한다.
- `core memory projection == core persisted projection`은 loader/flush를
검증한다.
- raw MariaDB dump와 raw PostgreSQL dump는 비교하지 않는다. 테이블 구조와
필드 소유권이 다르므로 의미 필드로 정규화한 JSON을 비교한다.
- 명령 결과와 RNG는 원칙적으로 exact 비교한다. 저장 레이아웃 차이만
projection에서 제거한다.
## 목표와 비목표
### 목표
- 동일한 게임 상태, 명령, 인자, 시각과 seed로 ref/core를 실행한다.
- 성공, 실행 중 실패, 제약 실패, alternative, 다중 턴과 전처리 경로를
구분한다.
- 장수·도시·국가·부대·외교·예약 큐·rank·로그 등 모든 관찰 가능한
side effect를 비교한다.
- RNG의 domain, 호출 순서, 연산, 인자와 결과를 비교한다.
- core 메모리 결과가 실제 PostgreSQL에 같은 의미로 저장되고 재시작 후
같은 상태로 로드되는지 검증한다.
- 명령이 예상 밖의 raw DB 필드를 바꾸면 projection 누락으로 실패시킨다.
- 각 명령의 테스트 근거와 아직 없는 경로를 기계적으로 집계한다.
### 비목표
- MariaDB와 PostgreSQL의 물리 schema, sequence, index 또는 내부 row ID를
동일하게 만드는 일
- 레거시 제품 브랜치 `devel`에 비교 endpoint를 추가하는 일
- 운영/개발 DB를 초기화하거나 기존 ref DB를 fixture 저장소로 사용하는 일
- 현재 구현을 정답으로 삼아 snapshot을 자동 승인하는 일
- UI와 API 요청 형식 검증을 이 suite 하나로 대체하는 일
## 테스트 계층
### 1. core logic regression
기존 `InMemoryTurnWorld`와 예약 턴 handler를 사용한다. 빠른 테스트이며
fixture의 core memory projection을 단언한다. DB 제약이나 직렬화는 보장하지
않는다.
### 2. ref ↔ core differential integration
Docker의 ref PHP CLI와 격리 MariaDB를 호출하므로 integration test로
분류한다. 두 엔진의 canonical 결과와 RNG trace를 비교한다.
### 3. core persistence integration
동일한 core 실행 결과를 `databaseHooks`로 격리 PostgreSQL에 flush하고
새 connection으로 다시 load한다. 메모리 projection과 재조회 projection을
비교한다.
### 4. 선택적 daemon system test
예약 API, command queue와 daemon lifecycle까지 필요한 대표 명령만 별도
system test로 둔다. 55개 수치 호환성 검증을 이 느린 계층에 모두 넣지 않는다.
## 제안 파일 구조
구현 시 다음 경계를 사용한다.
```text
core2026/
tools/integration-tests/
fixtures/general/
manifest.json
base/
scenario-2.json
che_화계/
success-basic.json
failure-probability.json
probability-clamp-max.json
injury-and-item.json
src/general-command/
fixtureSchema.ts
canonicalSchema.ts
compareCanonical.ts
referenceRunner.ts
referenceProjection.ts
coreMemoryRunner.ts
coreMemoryProjection.ts
coreDatabaseRunner.ts
coreDatabaseProjection.ts
changedPathAudit.ts
databaseSandbox.ts
tracingRng.ts
test/
generalCommandDifferential.test.ts
generalCommandPersistence.test.ts
generalCommandComparator.test.ts
ref/sam/ # ng_compare 전용
hwe/compare/
general_command_trace.php
GeneralCommandFixture.php
GeneralCommandProjection.php
ComparisonTracingRNG.php
docker_compose_files/
general-command-differential/
compose.yml
.env.example
README.md
scripts/
prepare-secrets.sh
initialize-templates.sh
run-fixture.sh
verify-isolation.sh
secrets/
mariadb_password.example
postgres_password.example
```
기존 `battleDifferential.test.ts`의 workspace 탐색, `docker compose exec`,
stdin JSON 전달과 tracing RNG 패턴을 재사용한다. 일반 명령용 코드는 전투
fixture와 섞지 않는다.
integration Vitest는 case DB 수명주기를 예측할 수 있도록
`fileParallelism: false`, 기본 `testTimeout: 120_000`을 유지한다. CI
sharding은 별도 Compose project와 worker prefix를 받은 프로세스 사이에서만
수행한다.
## 실행 격리
### 전용 Compose stack
`general-command-differential`은 개발, ref UI, input-event E2E와 수명주기가
다르므로 별도 Compose stack으로 둔다.
필수 service:
- `ref-db`: 고정 버전 MariaDB, 외부 port 미공개
- `ref-runner`: 기존 ref PHP image, CLI 명령만 허용
- `core-db`: 고정 버전 PostgreSQL, 프로젝트 전용 loopback port
- 선택 profile `system`: Redis와 core daemon runner
DB data directory는 suite 전용 `tmpfs`를 기본으로 한다. 테스트가 중단되어도
운영·개발 volume을 가리킬 수 없게 Compose project, container, network와
port 이름을 별도로 고정한다.
실제 비밀값은 Git에서 제외된 secret file로만 주입한다. ref의 생성된
`d_setting/DB.php` overlay는 `/run` 또는 `mktemp` 아래에 mode `0600`으로
만들어 container에 read-only mount하고 종료 시 삭제한다. JSON 결과,
명령행과 보고서에는 credential을 넣지 않는다.
### ref는 transaction rollback을 사용하지 않는다
레거시 `general`, `city`, `general_turn`, `general_record`, `rank_data`
핵심 테이블은 Aria engine이다. 따라서 transaction rollback은 fixture
격리를 보장하지 못한다.
ref 격리는 다음 순서로 수행한다.
1. suite 시작 시 schema와 비교 기준 scenario config로 immutable template
DB 두 개(root/HWE)를 만든다.
2. case마다 검증된 prefix
`sammo_gc_ref_<worker>_<caseHash>`의 DB를 새로 만든다.
3. template dump를 case DB에 복원하고 fixture override를 적용한다.
4. case DB를 가리키는 임시 `RootDB.php`/`DB.php` overlay로 PHP CLI를
한 번 실행한다.
5. canonical 결과를 읽은 뒤 case DB를 삭제한다.
6. cleanup 대상 이름이 허용 prefix와 정확히 일치하지 않으면 삭제를
거부한다.
이 흐름은 기존 `test-fast-forward-sandbox.sh`의 DB 복제, DB 이름 override,
원본 DB 불변 검사 패턴을 재사용한다. 기존 `sammo_ref_hwe`는 읽거나
복제 기준으로도 사용하지 않고, suite가 직접 만든 template만 사용한다.
### core DB 격리
suite 전용 PostgreSQL 안에 `public``che` schema 및 migration을 적용한
template database를 만든다. case마다 template에서 새 database를 만들고
case 종료 후 검증된 prefix에 한해 삭제한다.
각 case는 다음을 보장한다.
- 새 Prisma connection 사용
- 명시적인 profile/scenario
- fixture에 없는 이전 row가 없음
- flush 이후 connection을 닫고 새 connection으로 재조회
- dirty-state acknowledge는 DB commit 이후에만 수행
- 실패한 case도 case database만 정리
현재 `.env.ci`가 가리키는 개발 DB의 `public`/`che` schema를 truncate하는
기존 initialization test는 이 suite의 backend로 사용하지 않는다.
## 기준 scenario와 base state
fixture는 양쪽에 공통으로 존재하는 `scenario_2`의 rule, map과 unit set을
사용한다. 전체 scenario의 NPC와 국가를 그대로 seed하면 검색, 정렬과
무작위 후보가 fixture 밖 row에 영향을 받으므로 다음 base를 별도로 만든다.
- scenario config, `GameConst`, map과 unit set은 `scenario_2`에서 로드
- map의 모든 도시는 중립 기본 row로 생성
- 장수, 국가, 부대, 외교와 예약 턴은 fixture가 명시한 것만 생성
- game/root env는 명령 생성과 턴 실행에 필요한 key 전체를 명시
- 시간은 UTC ISO 문자열과 게임 연·월을 함께 고정
- 자동 턴은 기본적으로 끄고 fixture가 요구할 때만 켬
- 국가 예약 명령은 기본 휴식으로 고정
- actor만 due 상태로 두고 대상/보조 장수의 turn time은 실행 범위 밖으로 둠
- fixture의 test hidden seed를 임시 `UniqueConst.php` overlay와 core world
meta 양쪽에 같은 값으로 주입
base generator가 양쪽 입력을 따로 만들되, 기준 값은 하나의 canonical
base JSON에서 가져온다.
## Fixture 계약
fixture는 구현 내부 객체가 아니라 게임 의미를 기술한다. Zod schema와
JSON Schema를 함께 생성하고 resource validation에 포함한다.
개념 예시는 다음과 같다.
```json
{
"schemaVersion": 1,
"id": "che_화계/success-basic",
"scenario": "scenario_2",
"execution": {
"mode": "full-turn",
"year": 200,
"month": 1,
"turnTime": "0200-01-01T00:00:00.000Z",
"hiddenSeed": "general-differential-test-seed",
"actorGeneralId": 101,
"command": {
"key": "che_화계",
"args": { "destCityId": 2 }
},
"autorun": false
},
"world": {
"generals": [],
"cities": [],
"nations": [],
"troops": [],
"diplomacy": [],
"generalTurns": [],
"nationTurns": [],
"rank": [],
"events": [],
"rootUsers": []
},
"observe": {
"generalIds": [101, 201],
"cityIds": [1, 2],
"nationIds": [1, 2],
"metaKeys": ["intel_exp", "firenum", "killturn", "myset", "inherit_lived_month"],
"collections": ["logs", "generalTurns", "rank"]
},
"expect": {
"outcome": "success"
},
"evidence": {
"legacyFiles": ["hwe/sammo/Command/General/che_화계.php", "hwe/func.php"],
"contract": "화계 성공 기본 경로"
}
}
```
실제 fixture에는 생략 없이 모든 필수 entity field를 넣는다. `hiddenSeed`
테스트 전용 공개값이며 운영 seed를 복사하지 않는다.
### Fixture 불변식
- 양쪽에서 같은 numeric ID를 사용한다.
- 이름과 정렬 순서에 영향을 주는 문자열도 명시한다.
- `undefined`를 사용하지 않는다. 값 없음은 `null`, collection 없음은 `[]`,
object 없음은 `{}`로 표현한다.
- 자동 증가 DB ID는 fixture의 의미 식별자로 사용하지 않는다.
- 확률 결과를 임의 stub으로 강제하지 않는다. seed와 입력 상태로 원하는
분기를 만들고 RNG trace를 고정한다.
- fixture의 기대값은 core 실행 결과에서 생성하지 않는다. ref trace와
레거시 코드 근거를 함께 기록한다.
- fixture update는 별도 review 대상이며 snapshot 자동 갱신 명령을
제공하지 않는다.
## 실행 모드
### `full-turn`
기본 모드다. 양쪽의 실제 예약 턴 실행 순서를 거친다.
- lived-month 증가
- preprocess trigger, 부상 회복, 병력 군량
- block
- 필요 시 국가 명령
- 일반 명령 제약, term stack, cooldown, alternative
- 명령별 RNG
- queue shift
- killturn, myset, autorun limit
- next turn time
- retirement/deletion
- DB flush와 로그 확정
55개 호환 판정은 이 모드의 결과로 한다.
### `command-only`
수식과 RNG 분기를 좁게 진단하는 보조 모드다. 전체 호환 판정의 근거로
단독 사용하지 않는다. full-turn 실패가 preprocess/queue 문제인지 명령
resolver 문제인지 분리할 때 사용한다.
## ref runner 계약
`general_command_trace.php`는 다음 조건을 모두 만족해야 한다.
- `PHP_SAPI === 'cli'`
- 명시적인 `SAMMO_GENERAL_COMPARE=1` guard
- stdin의 fixture 한 개만 처리
- case 전용 DB 이름 외 연결 거부
- fixture seed 후 `TurnExecutionHelper`의 실제 경로 호출
- logger를 flush하고 DB 결과를 읽은 뒤 JSON 한 개 출력
- stdout에는 JSON만, 진단은 stderr
- 기존 명령 계산·정렬·RNG·DB mutation 순서를 바꾸지 않음
- 종료 전 원본 template/main DB가 변하지 않았음을 runner가 검사
출력은 engine-specific raw state와 trace를 담는다. canonical 변환은
`referenceProjection.ts`가 수행한다. PHP와 TS projection이 서로의
오류를 그대로 복제하지 않도록 한 구현을 공유하지 않는다.
현재 `TurnExecutionHelper`는 내부에서 RNG를 직접 생성하므로 `ng_compare`
최소 test seam이 필요하다. 기본값은 기존 `new RandUtil(new
LiteHashDRBG(seed))`를 그대로 사용하고, CLI guard가 활성화된 경우에만
같은 DRBG를 tracing proxy로 감싸는 factory를 주입한다. observer on/off에서
동일 fixture의 DB 결과가 같다는 계측 무영향 테스트를 ref에 둔다.
## core runner 계약
### memory runner
- fixture를 `TurnWorldSnapshot`, `TurnWorldState`,
`InMemoryReservedTurnStore`로 변환
- production `createReservedTurnHandler``InMemoryTurnProcessor` 사용
- fixture가 선언한 한 장수의 한 due turn만 실행
- 실행 전후 world, dirty state, queue, logs와 RNG trace 반환
`reservedTurnHandler`도 RNG를 내부 생성하므로 production default를 보존하는
선택적 `rngFactory(domain, seed)` test seam을 둔다. 옵션을 생략한 경로는
현재 구현과 byte-for-byte 같은 DRBG를 만들고, 테스트만 tracing wrapper를
반환한다. seed 구성 자체를 runner에서 다시 구현하지 않는다.
### database runner
- 같은 fixture를 case PostgreSQL에 seed
- production loader로 새 `InMemoryTurnWorld` 생성
- 같은 handler/processor 실행
- production `databaseHooks`로 commit
- 모든 connection을 닫고 새 loader/Prisma query로 결과 재조회
- memory projection과 persisted projection 비교
테스트 전용 runner가 `buildCityUpdate` 같은 private production helper를
복제해 직접 호출해서는 안 된다. 반드시 production hook 경계를 지나야
`City.state`/`City.meta.state`와 같은 투영 오류를 검출할 수 있다.
## RNG trace
RNG trace entry는 다음 형태다.
```json
{
"domain": "generalCommand",
"sequence": 3,
"operation": "nextInt",
"arguments": { "maxInclusive": 99 },
"result": 42
}
```
domain은 최소 다음을 구분한다.
- `preprocess`
- `nationCommand`
- `generalCommand`
- `uniqueLottery`
- 명령이 추가로 분리한 명시적 child domain
비교 규칙:
- entry 수 exact
- domain과 sequence exact
- primitive RNG operation exact
- arguments exact
- integer/byte/bit result exact
- float는 JSON number의 실제 값 exact
PHP/JavaScript의 동일 계산 결과가 표현 차이만 보이는 경우에도 RNG trace
허용치를 넓히지 않는다. 게임 상태 수치에 불가피한 부동소수점 차이가 있으면
필드별 compatibility rule을 근거와 함께 별도 등록한다.
## Canonical snapshot
결과 envelope:
```text
schemaVersion
fixtureId
engine ref | core-memory | core-db
execution
requestedCommand
resolvedCommand
outcome success | command-failure | constraint-denied | fallback | error
blockedReason
nextTurnTime
before
after
delta
rng
unmappedChanges
```
`before``after`는 다음 collection을 ID/복합 key로 정렬한다.
### 일반 상태
- world: year, month, tick/turn term, killturn 설정과 명령이 읽거나 바꾼 env
- generals: scalar stats, 소속, 관직, 자원, 병력, 부상, 장비, 특기, 성격,
능력 경험, turn time, lastTurn
- general meta: killturn, myset, autorun/cooldown, 계승, command별 변경 key
- item inventory: instance ID 자체보다 item key, slot, charges와 values
- rank: `(generalId, type, value)`
### 도시·국가·관계
- cities: 소속, state, 인구와 최대치, 내정치와 최대치, 수비·성벽, 보급,
전선, trust, trade, region, conflict
- nations: 수도, 군주, 규모, 자원, 기술, power, type과 명령 관련 meta
- troops: leader/id, nation, name와 membership에 의해 바뀐 장수 troopId
- diplomacy: 양방향 row를 `(srcNationId, destNationId)`로 정렬하고 state,
term, dead/showing과 의미 meta 비교
### side-effect collection
- generalTurns, nationTurns: logical key, action, args와 순서
- logs: scope, category, subtype, year, month, 대상 ID와 최종 formatting text
- messages
- events
- hall/archive rows
- inheritance point/log/result
- access-log 변경
- 생성·삭제된 entity ID
DB auto ID, `createdAt`, `updatedAt`, connection별 sequence와 물리 JSON key
순서는 제거한다. JSON object key는 정렬하지만 array 순서는 보존한다.
core memory log는 production `finalizeLogEntry`를 같은 고정 year/month/time
context로 통과시킨 뒤 persisted/ref log와 비교한다.
## 명령 seed 밖의 난수
사료 NPC 랜덤임관의 PHP 전역 `shuffle()`처럼 `RandUtil` 밖의 난수는
명령 seed만으로 재현할 수 없다. 이 값을 무시하거나 fixture에서 해당
분기를 제외하지 않는다.
비교 환경에서는 외부 비결정값을 명시적 input tape로 취급한다.
```json
{
"externalDecisions": [
{
"domain": "legacyGlobalShuffle",
"input": [201, 202, 203],
"output": [203, 201, 202]
}
]
}
```
- ref `ng_compare`는 CLI guard 아래에서만 해당 shuffle 호출을 작은 wrapper로
통과시키고 tape의 permutation을 사용한다.
- core runner도 같은 permutation을 입력으로 사용한다.
- input/output 원소가 정확한 permutation이 아니면 실패한다.
- guard가 꺼진 wrapper는 PHP builtin `shuffle()`을 그대로 한 번 호출한다.
- 계측 on/off의 deterministic 경로 무영향 테스트와, guard-off shuffle의
permutation property test를 별도로 둔다.
- canonical trace에는 external decision의 소비 순서도 포함한다.
따라서 이 경로의 호환 의미는 “같은 외부 shuffle 결과가 주어졌을 때 이후
후보 평가, `RandUtil` 소비와 선택 결과가 같다”이다. PHP 전역 RNG 자체를
core command seed와 같다고 주장하지 않는다.
## 변경 경로 감사
fixture가 명시한 필드만 비교하면 예상하지 못한 side effect를 놓칠 수 있다.
각 runner는 engine raw state의 before/after diff도 만든다.
- 알려진 raw path는 canonical mapping registry에 연결한다.
- 명령이 바꾼 raw path가 canonical path나 명시적 ignore rule에 연결되지
않으면 `unmappedChanges`에 넣고 실패한다.
- ignore rule은 timestamp, auto ID처럼 게임 의미가 없는 필드만 허용한다.
- ignore entry에는 engine, raw path pattern, 이유와 근거 파일을 기록한다.
- broad wildcard로 `aux`, `meta` 또는 전체 table을 무시하지 않는다.
이 gate는 새로운 aux/meta key나 누락된 persistence table을 조용히
통과시키지 않기 위한 것이다.
## 비교 규칙
기본은 deep exact equality다.
정규화 허용:
- snake_case ↔ camelCase
- `intel``intelligence`
- ref scalar/aux/rank ↔ core typed field/meta/rank row
- `None``null`인 장비 없음 표현
- DB가 부여한 ID와 timestamp 제거
- JSON object key 정렬
정규화 금지:
- 반올림, truncation 또는 clamp 결과 변경
- RNG 호출 추가/삭제/재정렬
- collection 정렬로 실제 처리 순서 은폐
- 로그 문구나 조사 차이를 임의로 제거
- 누락 row를 기본값으로 만들어 일치시킴
- 도시 `state``meta.state`처럼 소유권이 다른 필드를 같은 값으로 간주
필드별 허용 차이는 `compatibility-rules.json`에 다음을 반드시 기록한다.
- fixture 또는 command
- canonical path
- 허용 조건
- 레거시/core 근거 파일
- 사용자 상태와 이후 턴에 영향이 없는 이유
- 제거 예정 여부
## 화계 첫 acceptance matrix
설계 검증의 첫 명령은 `che_화계`로 한다. 최소 fixture:
| fixture | 보호할 계약 |
| ------------------------ | ------------------------------------------------------------------------------------- |
| `success-basic` | 비용, 성공, 농업·상업 피해, state 32, 경험·공헌·지력 경험·firenum, queue/LastTurn/log |
| `failure-probability` | 실패 RNG, 피해·부상·아이템 소비 없음, 실패 경험/공헌 범위 |
| `probability-clamp-zero` | 음수 계산 결과 0 clamp와 RNG 소비 |
| `probability-clamp-max` | 0.5 상한, 거리 나눗셈 순서 |
| `defence-population` | 대상국 장수만 포함, 최대 지력, 인원 log2, 보급·치안 보정 |
| `injury-and-item` | 장수별 부상 판정/상한 80, crew/train/atmos 0.98 절삭, 일회용 아이템 소비 |
| `damage-clamp` | 낮은 농업·상업에서 0 미만 방지 |
| `constraint-denied` | 중립/같은 도시/자원/보급/불가침 제약과 queue fallback |
`success-basic`은 현재의 `City.meta.state``City.state` 혼동을 반드시
실패로 검출해야 한다. 이 fixture가 해당 production line을 고의로 잘못
바꿨을 때 실패하고 복구하면 통과하는 것을 mutation audit에 기록한다.
## 55개 명령 coverage manifest
`manifest.json`은 PHP command inventory와 TS registry의 합집합을 기준으로
생성 검증한다. 각 명령에는 다음 case class가 필요하다.
- 실행 가능한 기본 경로
- full constraint 거부
- 확률 분기가 있으면 성공과 실패
- 값 clamp/상한/하한이 있으면 경계
- 다중 턴이면 stack 중간과 완료/레거시 reset
- alternative가 있으면 원 명령과 대체 명령
- 생성/삭제/소속 변경이 있으면 관련 collection
- 아이템/특기/국가/관직 보정이 있으면 최소 한 개
- 명령별 외부 side effect가 있으면 해당 collection
적용 불가능한 case class는 `notApplicable`과 레거시 근거를 기록한다.
fixture가 하나 있다는 이유만으로 명령을 covered로 세지 않는다.
inventory gate가 검증할 집계:
- ref command 수
- core command 수
- command별 필수 case class 충족
- fixture schema validation
- orphan fixture
- skip/todo/notApplicable 근거
- unmapped changed path 수
## 실패 출력과 artifact
실패 시 다음만 출력한다.
- fixture ID와 세 engine
- 첫 canonical mismatch path와 양쪽 값
- 전체 RNG trace에서 최초 divergence와 전후 제한된 window
- unmapped raw changed path
- 재현 명령
전체 DB dump, credential, 운영 hidden seed, 사용자 개인정보는 출력하거나
artifact로 저장하지 않는다. 필요하면 canonical JSON만 Git 제외된
`artifacts/general-command/<fixture>/`에 저장하고 민감 필드를 검사한다.
## 실행 명령 계약
구현 후 제공할 명령:
```bash
# stack 준비와 격리 검증
pnpm general-diff:prepare
pnpm general-diff:verify-isolation
# 화계 한 fixture
pnpm general-diff:test --fixture che_화계/success-basic
# 한 명령 전체
pnpm general-diff:test --command che_화계
# 55개 coverage 및 전체 differential/persistence
pnpm general-diff:check
```
명령은 기존 개발 DB를 발견하거나 전용 stack marker가 없으면 실행을
거부한다. `general-diff:check`는 skip이 있으면 실패한다. 진단용
`--allow-skip`은 CI와 완료 판정에서 금지한다.
## CI 단계
### PR fast gate
- fixture/schema/manifest validation
- comparator unit test
- 변경된 명령과 공통 실행기 영향 명령의 differential
- 해당 fixture의 core persistence
### compatibility gate
- 55개 manifest의 모든 필수 case
- ref/core RNG 및 canonical state exact diff
- core memory/persisted exact diff
- unmapped changes 0
- skip 0
명령별 case를 shard할 수 있지만 같은 case DB를 공유하지 않는다.
## Comparator 자체 검증
`generalCommandComparator.test.ts`는 실제 production 결과 없이도 다음
synthetic mismatch를 각각 검출해야 한다.
- 숫자 1 차이
- null과 누락
- array 순서
- RNG operation/result
- 로그 대상과 format text
- queue shift 방향
- 생성 대신 수정
- 삭제 누락
- `City.state``meta.state`
- 알려지지 않은 aux/meta/raw DB 변경
protected behavior를 고의로 perturb한 mutation audit를 fixture review에
포함한다. 단순히 현재 구현을 snapshot으로 저장하고 다시 읽는 테스트는
호환 근거로 인정하지 않는다.
## 구현 순서
1. canonical fixture/schema와 comparator unit test
2. 전용 Compose stack과 원본 DB 불변 isolation test
3. ref `general_command_trace.php``ng_compare`에 추가
4. core memory runner
5. core DB runner와 새 connection reload
6. 화계 acceptance matrix 완성 및 현재 city state 결함 수정
7. 계략 4종으로 공통 runner 검증
8. side-effect family별 대표 명령 확장
9. 55개 manifest 완성
10. project-generated Prisma client를 사용하는 전용 connection readiness
check 추가
11. `pnpm general-diff:check`를 compatibility gate로 등록
ref 계측 commit, core test/infra commit, 제품 버그 수정 commit은 분리한다.
## 완료 기준
다음이 모두 증명되어야 이 설계의 구현을 완료로 본다.
- 전용 stack이 기존 ref/dev DB를 바꾸지 않는 isolation test 통과
- ref runner가 CLI/test guard 밖에서 접근 불가
- 화계 8개 acceptance fixture 통과
- 화계 `state=32`가 core DB 재조회에도 유지됨
- 각 fixture의 ref/core RNG trace exact 일치
- 외부 비결정 경로는 input tape 소비와 이후 결과 exact 일치
- 55개 명령의 필수 manifest case 충족
- ref/core memory canonical diff 0
- core memory/persisted canonical diff 0
- unmapped changed path 0
- skip/todo 0
- comparator mutation audit 통과
- 관련 typecheck, lint, build와 integration test 통과
- `docs/ref-core2026-mapping.md`에서 동적 검증 근거와 미확인 항목 갱신
- 실행 명령, 기준 commit과 fixture 목록을 `report/`에 기록
이 조건 전에는 정적 제약·로그 검사와 smoke test 통과만으로 일반 장수 명령
전체를 `확인` 또는 이식 완료로 표시하지 않는다.
+21 -3
View File
@@ -116,7 +116,9 @@ Nation-level choices run only for NPCs (`npc >= 2`) or for autorun users:
- **Resource distribution**
- `do유저장포상`, `doNPC포상`, `doNPC몰수`
- uses resource floors (`reqNation*`, `reqNPC*`, `reqHuman*`)
- weighted by target general's deficit and recent activity.
- sorts by each general's gold/rice, excludes inactive (`killturn <= 5`)
targets, and preserves the legacy geometric-mean amounts and candidate
weights.
- **Diplomacy**
- `do불가침제의`: respond to assistance requests with NAP offer.
- `do선전포고`: probabilistic declaration when strong enough.
@@ -131,8 +133,10 @@ General-level decisions are layered:
2. Reserved command is honored if valid (unless `휴식`).
3. Immediate recovery if `injury > cureThreshold`.
4. Special cases:
- NPC troop leaders (type 5) always `집합`.
- wanderers decide on founding / moving / disbanding.
- A nationless NPC troop leader shortens `killturn` and keeps its reserved
command; an affiliated type-5 leader refreshes `killturn` and uses `집합`.
- wandering lords decide on founding, one-edge movement toward a cached
target, or disbanding.
5. Iterate policy `priority`, invoking `do{Action}`.
6. Fallback to `do중립`.
@@ -189,3 +193,17 @@ To port the AI to an in-memory state model without behavior drift:
These guidelines mirror the current "derive once, then select via priority"
pattern and minimize resimulation deltas in the rewrite.
## Migrated decision-parity regression
`app/game-engine/test/generalAiLegacyDecisionParity.test.ts` records focused
final-command expectations extracted from `ref/sam` `ng_compare@fe9ae978`.
Its matrix varies diplomacy/war state, city development and population,
technology/year ceilings, general gold/rice and casualty ranks, stats and
affinity, reserved/special NPC state, nation treasury reserves, and command
availability. It also asserts RNG-sensitive candidate weights where consuming
the same random branch is part of the final decision.
This is compatibility evidence for the represented decision branches. The
long-running NPC scenario suites remain smoke tests and are not a substitute
for this branch-level matrix.
+22
View File
@@ -80,6 +80,23 @@ Gateway runs a lightweight cron loop (setInterval) that:
### Build Workflow (Admin)
- 관리자는 gateway의 `/admin/server-operations` 전용 화면에서 profile별
runtime, build, worktree, 오류와 작업 이력을 확인하고 시작·정지·초기화를
요청한다.
- 운영 요청은 `gateway_operation` 행으로 기록된다. 같은 profile에는
`QUEUED` 또는 `RUNNING` 작업이 하나만 존재할 수 있으며, 상태는
`QUEUED -> RUNNING -> SUCCEEDED|FAILED` 또는 실행 전 `CANCELLED`
전이된다. 실패·취소 작업은 같은 입력으로 재시도할 수 있다.
- `BRANCH` 소스는 요청 시 유효성을 검사하지만 branch 이름을 보존하고,
실행 직전에 원격을 다시 fetch하여 최신 commit을 확정한다. `COMMIT`
소스는 요청 시 full SHA로 정규화하여 이후 branch 이동과 무관하게
동일 commit을 사용한다. 실제 사용한 SHA는 작업의
`resolvedCommitSha`와 profile의 `buildCommitSha`에 기록한다.
- 초기화 작업은 profile 프로세스를 정지한 뒤 선택 commit worktree에서
build하고, 같은 worktree의 scenario/map/unitset으로 profile DB를 seed한
`PREOPEN` 또는 `RUNNING`으로 전환하여 API와 daemon을 함께 시작한다.
대기 중 작업은 취소할 수 있으나 DB 변경이 시작된 `RUNNING` 작업은
중간 취소하지 않는다.
- Admin triggers a build request for a profile.
- Gateway queues a build job with `(profileName, commitSha)` and prepares a
per-commit workspace (`/.worktrees/{commitSha}` by default).
@@ -96,6 +113,11 @@ Gateway runs a lightweight cron loop (setInterval) that:
worktrees. A profile without `buildWorkspace` intentionally falls back to the
main workspace; this is the `hwe`/main compatibility path.
`GatewayProfile.meta.adminActions` polling is retained only for requests
created by the older admin page. New operations must use `gateway_operation`
so concurrency, audit history, cancellation, retry, source intent, and the
resolved commit remain first-class data.
## Current Implementation Status
- Turn daemon lifecycle + in-memory state live in `app/game-engine` with DB flush hooks.
+24 -22
View File
@@ -4,7 +4,7 @@
- Audit date: 2026-07-25
- Code baseline: `main@46ae79dbe7a0fb64aff9bdcc76eadafd75e10c9e`
- Executed test sources: 66 TypeScript `*.test.ts` files
- Executed test sources: 67 TypeScript `*.test.ts` files
- Excluded from the source count: ignored `dist/` outputs and non-executable
fixtures/helpers
- Historical generated copies removed by this audit:
@@ -61,25 +61,26 @@ environment-dependent check.
### `app/game-engine`
| Test source | Disposition | Layer | What it establishes |
| ----------------------------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
| `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. |
| `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
| `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. |
| `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. |
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. Broad thresholds remain intentional smoke bounds; unconditional diagnostic output was removed. |
| `test/npcNationTechResearch.test.ts` | kept | smoke | Long-running monotonic tech growth and higher later recruitment cost. |
| `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
| `test/npcNationWarDeclaration.test.ts` | kept | smoke | Declaration, war transition, preparation, conquest, unification, and dispatch occurrence in one end-to-end NPC scenario. |
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
| Test source | Disposition | Layer | What it establishes |
| ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
| `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. |
| `test/generalAiLegacyDecisionParity.test.ts` | added | compatibility | Ref-backed final NPC command matrix across diplomacy, war readiness, city development/population, technology ceilings, gold/rice and casualty ranks, stats/affinity, special NPC state, treasury reserves, and command availability. |
| `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
| `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. |
| `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. |
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. The implementation-specific early recruitment bound was removed because legacy AI forbids peace/declaration recruitment; remaining thresholds are smoke bounds. |
| `test/npcNationTechResearch.test.ts` | corrected | smoke | Long-running monotonic tech growth. Net per-tick general gold is not treated as recruitment price because nation awards can occur in the same tick. |
| `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
| `test/npcNationWarDeclaration.test.ts` | corrected | smoke | Declaration, war transition, a battle-ready cohort, front deployment, conquest, unification, and dispatch occurrence without assuming every recruit is already fully trained. |
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
### `packages/logic`
@@ -145,8 +146,9 @@ to legacy-compatible:
- battle compatibility is supported only by the differential fixtures and the
suites explicitly marked `compatibility`;
- broad NPC and multi-command scenarios guard liveness/invariants, not exact
monthly balance or complete side effects;
- broad NPC and multi-command scenarios guard liveness/invariants, while
`generalAiLegacyDecisionParity.test.ts` establishes exact final choices only
for its explicitly represented input branches;
- DB/Redis/PM2 claims require their integration suites to run rather than skip;
- changing an implementation and observing a green unit suite is still not a
substitute for a new PHP trace when compatibility-sensitive behavior changes.
+4
View File
@@ -78,6 +78,10 @@ Goal: verify state input -> state output and that flush behaves as expected.
- Integration tests: execute the same command and confirm DB persistence.
- Mock target: InMemory Repository (Fake).
- "Send to DB" behavior is validated via real DB tests.
- Cross-engine compatibility compares a canonical semantic snapshot rather than
raw MariaDB/PostgreSQL dumps. General-turn commands use the three-way
ref DB ↔ core InMemory ↔ core PostgreSQL design in
[`architecture/general-command-differential-testing.md`](./architecture/general-command-differential-testing.md).
### 3) Turn Flow Tests
+454 -431
View File
@@ -1,47 +1,47 @@
generator client {
provider = "prisma-client-js"
output = "./generated/game"
engineType = "binary"
provider = "prisma-client-js"
output = "./generated/game"
engineType = "binary"
}
datasource db {
provider = "postgresql"
provider = "postgresql"
}
enum LogScope {
SYSTEM
NATION
GENERAL
USER
SYSTEM
NATION
GENERAL
USER
}
enum LogCategory {
HISTORY
SUMMARY
ACTION
BATTLE_BRIEF
BATTLE_DETAIL
USER
HISTORY
SUMMARY
ACTION
BATTLE_BRIEF
BATTLE_DETAIL
USER
}
enum AuctionStatus {
OPEN
FINALIZING
FINISHED
CANCELED
OPEN
FINALIZING
FINISHED
CANCELED
}
enum AuctionType {
BUY_RICE
SELL_RICE
UNIQUE_ITEM
BUY_RICE
SELL_RICE
UNIQUE_ITEM
}
enum DiplomacyLetterState {
PROPOSED
ACTIVATED
CANCELLED
REPLACED
PROPOSED
ACTIVATED
CANCELLED
REPLACED
}
enum InputEventStatus {
@@ -78,538 +78,561 @@ model InputEvent {
}
model WorldState {
id Int @id @default(autoincrement())
scenarioCode String @map("scenario_code")
currentYear Int @map("current_year")
currentMonth Int @map("current_month")
tickSeconds Int @map("tick_seconds")
config Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
scenarioCode String @map("scenario_code")
currentYear Int @map("current_year")
currentMonth Int @map("current_month")
tickSeconds Int @map("tick_seconds")
config Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
@@map("world_state")
@@map("world_state")
}
model Nation {
id Int @id
name String
color String
capitalCityId Int? @map("capital_city_id")
gold Int @default(0)
rice Int @default(0)
tech Float @default(0)
level Int @default(0)
typeCode String @default("che_중립") @map("type_code")
meta Json @default(dbgenerated("'{}'::jsonb"))
id Int @id
name String
color String
capitalCityId Int? @map("capital_city_id")
chiefGeneralId Int? @map("chief_general_id")
gold Int @default(0)
rice Int @default(0)
tech Float @default(0)
level Int @default(0)
typeCode String @default("che_중립") @map("type_code")
meta Json @default(dbgenerated("'{}'::jsonb"))
@@map("nation")
@@map("nation")
}
model City {
id Int @id
name String
level Int
nationId Int @default(0) @map("nation_id")
supplyState Int @default(1) @map("supply_state")
frontState Int @default(0) @map("front_state")
population Int @map("pop")
populationMax Int @map("pop_max")
agriculture Int @map("agri")
agricultureMax Int @map("agri_max")
commerce Int @map("comm")
commerceMax Int @map("comm_max")
security Int @map("secu")
securityMax Int @map("secu_max")
trust Int @default(0)
trade Int @default(100)
defence Int @map("def")
defenceMax Int @map("def_max")
wall Int @map("wall")
wallMax Int @map("wall_max")
region Int
conflict Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
id Int @id
name String
level Int
nationId Int @default(0) @map("nation_id")
supplyState Int @default(1) @map("supply_state")
frontState Int @default(0) @map("front_state")
population Int @map("pop")
populationMax Int @map("pop_max")
agriculture Int @map("agri")
agricultureMax Int @map("agri_max")
commerce Int @map("comm")
commerceMax Int @map("comm_max")
security Int @map("secu")
securityMax Int @map("secu_max")
trust Int @default(0)
trade Int @default(100)
defence Int @map("def")
defenceMax Int @map("def_max")
wall Int @map("wall")
wallMax Int @map("wall_max")
region Int
conflict Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
@@map("city")
@@map("city")
}
model General {
id Int @id
userId String? @map("user_id")
name String
nationId Int @default(0) @map("nation_id")
cityId Int @default(0) @map("city_id")
troopId Int @default(0) @map("troop_id")
npcState Int @default(0) @map("npc_state")
affinity Int?
bornYear Int @default(180) @map("born_year")
deadYear Int @default(300) @map("dead_year")
picture String?
imageServer Int @default(0) @map("image_server")
leadership Int @default(50)
strength Int @default(50)
intel Int @default(50)
injury Int @default(0)
experience Int @default(0)
dedication Int @default(0)
officerLevel Int @default(0) @map("officer_level")
gold Int @default(1000)
rice Int @default(1000)
crew Int @default(0)
crewTypeId Int @default(0) @map("crew_type_id")
train Int @default(0)
atmos Int @default(0)
weaponCode String @default("None") @map("weapon_code")
bookCode String @default("None") @map("book_code")
horseCode String @default("None") @map("horse_code")
itemCode String @default("None") @map("item_code")
turnTime DateTime @map("turn_time")
recentWarTime DateTime? @map("recent_war_time")
age Int @default(20)
startAge Int @default(20) @map("start_age")
personalCode String @default("None") @map("personal_code")
specialCode String @default("None") @map("special_code")
special2Code String @default("None") @map("special2_code")
lastTurn Json @default(dbgenerated("'{}'::jsonb")) @map("last_turn")
meta Json @default(dbgenerated("'{}'::jsonb"))
penalty Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at")
id Int @id
userId String? @map("user_id")
name String
nationId Int @default(0) @map("nation_id")
cityId Int @default(0) @map("city_id")
troopId Int @default(0) @map("troop_id")
npcState Int @default(0) @map("npc_state")
affinity Int?
bornYear Int @default(180) @map("born_year")
deadYear Int @default(300) @map("dead_year")
picture String?
imageServer Int @default(0) @map("image_server")
leadership Int @default(50)
strength Int @default(50)
intel Int @default(50)
injury Int @default(0)
experience Int @default(0)
dedication Int @default(0)
officerLevel Int @default(0) @map("officer_level")
gold Int @default(1000)
rice Int @default(1000)
crew Int @default(0)
crewTypeId Int @default(0) @map("crew_type_id")
train Int @default(0)
atmos Int @default(0)
weaponCode String @default("None") @map("weapon_code")
bookCode String @default("None") @map("book_code")
horseCode String @default("None") @map("horse_code")
itemCode String @default("None") @map("item_code")
turnTime DateTime @map("turn_time")
recentWarTime DateTime? @map("recent_war_time")
age Int @default(20)
startAge Int @default(20) @map("start_age")
personalCode String @default("None") @map("personal_code")
specialCode String @default("None") @map("special_code")
special2Code String @default("None") @map("special2_code")
lastTurn Json @default(dbgenerated("'{}'::jsonb")) @map("last_turn")
meta Json @default(dbgenerated("'{}'::jsonb"))
penalty Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at")
@@map("general")
@@map("general")
}
model GeneralAccessLog {
id Int @id @default(autoincrement())
generalId Int @unique @map("general_id")
userId String? @map("user_id")
lastRefresh DateTime? @map("last_refresh")
refresh Int @default(0)
refreshTotal Int @default(0) @map("refresh_total")
refreshScore Int @default(0) @map("refresh_score")
refreshScoreTotal Int @default(0) @map("refresh_score_total")
id Int @id @default(autoincrement())
generalId Int @unique @map("general_id")
userId String? @map("user_id")
lastRefresh DateTime? @map("last_refresh")
refresh Int @default(0)
refreshTotal Int @default(0) @map("refresh_total")
refreshScore Int @default(0) @map("refresh_score")
refreshScoreTotal Int @default(0) @map("refresh_score_total")
@@index([userId])
@@map("general_access_log")
@@index([userId])
@@map("general_access_log")
}
model MessageReadState {
generalId Int @id @map("general_id")
latestPrivateMessage Int @default(0) @map("latest_private_message")
latestDiplomacyMessage Int @default(0) @map("latest_diplomacy_message")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("message_read_state")
}
model Message {
id Int @id @default(autoincrement())
mailbox Int
type String
src Int
dest Int
time DateTime
validUntil DateTime @map("valid_until")
message Json
@@map("message")
}
model RankData {
id Int @id @default(autoincrement())
nationId Int @default(0) @map("nation_id")
generalId Int @map("general_id")
type String @db.VarChar(20)
value Int @default(0)
id Int @id @default(autoincrement())
nationId Int @default(0) @map("nation_id")
generalId Int @map("general_id")
type String @db.VarChar(20)
value Int @default(0)
@@unique([generalId, type])
@@index([type, value], name: "by_type")
@@index([nationId, type, value], name: "by_nation")
@@map("rank_data")
@@unique([generalId, type])
@@index([type, value], name: "by_type")
@@index([nationId, type, value], name: "by_nation")
@@map("rank_data")
}
model HallOfFame {
id Int @id @default(autoincrement())
serverId String @map("server_id")
season Int
scenario Int
generalNo Int @map("general_no")
type String @db.VarChar(20)
value Float
owner String? @map("owner")
aux Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement())
serverId String @map("server_id")
season Int
scenario Int
generalNo Int @map("general_no")
type String @db.VarChar(20)
value Float
owner String? @map("owner")
aux Json @default(dbgenerated("'{}'::jsonb"))
@@unique([serverId, type, generalNo])
@@unique([owner, serverId, type], name: "hall_owner")
@@index([serverId, type, value], name: "server_show")
@@index([season, scenario, type, value], name: "scenario")
@@map("hall")
@@unique([serverId, type, generalNo])
@@unique([owner, serverId, type], name: "hall_owner")
@@index([serverId, type, value], name: "server_show")
@@index([season, scenario, type, value], name: "scenario")
@@map("hall")
}
model GameHistory {
id Int @id @default(autoincrement())
serverId String @map("server_id")
date DateTime
winnerNation Int? @map("winner_nation")
map String? @map("map")
season Int
scenario Int
scenarioName String @map("scenario_name")
env Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement())
serverId String @map("server_id")
date DateTime
winnerNation Int? @map("winner_nation")
map String? @map("map")
season Int
scenario Int
scenarioName String @map("scenario_name")
env Json @default(dbgenerated("'{}'::jsonb"))
@@unique([serverId])
@@index([date])
@@map("ng_games")
@@unique([serverId])
@@index([date])
@@map("ng_games")
}
model OldNation {
id Int @id @default(autoincrement())
serverId String @map("server_id")
nation Int @default(0)
data Json @default(dbgenerated("'{}'::jsonb"))
date DateTime @default(now())
id Int @id @default(autoincrement())
serverId String @map("server_id")
nation Int @default(0)
data Json @default(dbgenerated("'{}'::jsonb"))
date DateTime @default(now())
@@unique([serverId, nation])
@@index([serverId, nation], name: "by_server")
@@map("ng_old_nations")
@@unique([serverId, nation])
@@index([serverId, nation], name: "by_server")
@@map("ng_old_nations")
}
model OldGeneral {
id Int @id @default(autoincrement())
serverId String @map("server_id")
generalNo Int @map("general_no")
owner String? @map("owner")
name String
lastYearMonth Int @map("last_yearmonth")
turnTime DateTime @map("turntime")
data Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement())
serverId String @map("server_id")
generalNo Int @map("general_no")
owner String? @map("owner")
name String
lastYearMonth Int @map("last_yearmonth")
turnTime DateTime @map("turntime")
data Json @default(dbgenerated("'{}'::jsonb"))
@@unique([serverId, generalNo], name: "by_no")
@@index([serverId, name], name: "by_name")
@@index([owner, serverId], name: "owner")
@@map("ng_old_generals")
@@unique([serverId, generalNo], name: "by_no")
@@index([serverId, name], name: "by_name")
@@index([owner, serverId], name: "owner")
@@map("ng_old_generals")
}
model Emperor {
id Int @id @default(autoincrement()) @map("no")
serverId String? @map("server_id")
phase String? @default("")
nationCount String? @map("nation_count")
nationName String? @map("nation_name")
nationHist String? @map("nation_hist")
genCount String? @map("gen_count")
personalHist String? @map("personal_hist")
specialHist String? @map("special_hist")
name String? @default("")
type String? @default("")
color String? @default("")
year Int? @default(0)
month Int? @default(0)
power Int? @default(0)
gennum Int? @default(0)
citynum Int? @default(0)
pop String? @default("0")
poprate String? @default("")
gold Int? @default(0)
rice Int? @default(0)
l12name String? @default("")
l12pic String? @default("")
l11name String? @default("")
l11pic String? @default("")
l10name String? @default("")
l10pic String? @default("")
l9name String? @default("")
l9pic String? @default("")
l8name String? @default("")
l8pic String? @default("")
l7name String? @default("")
l7pic String? @default("")
l6name String? @default("")
l6pic String? @default("")
l5name String? @default("")
l5pic String? @default("")
tiger String? @default("")
eagle String? @default("")
gen String? @default("")
history Json @default(dbgenerated("'{}'::jsonb"))
aux Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement()) @map("no")
serverId String? @map("server_id")
phase String? @default("")
nationCount String? @map("nation_count")
nationName String? @map("nation_name")
nationHist String? @map("nation_hist")
genCount String? @map("gen_count")
personalHist String? @map("personal_hist")
specialHist String? @map("special_hist")
name String? @default("")
type String? @default("")
color String? @default("")
year Int? @default(0)
month Int? @default(0)
power Int? @default(0)
gennum Int? @default(0)
citynum Int? @default(0)
pop String? @default("0")
poprate String? @default("")
gold Int? @default(0)
rice Int? @default(0)
l12name String? @default("")
l12pic String? @default("")
l11name String? @default("")
l11pic String? @default("")
l10name String? @default("")
l10pic String? @default("")
l9name String? @default("")
l9pic String? @default("")
l8name String? @default("")
l8pic String? @default("")
l7name String? @default("")
l7pic String? @default("")
l6name String? @default("")
l6pic String? @default("")
l5name String? @default("")
l5pic String? @default("")
tiger String? @default("")
eagle String? @default("")
gen String? @default("")
history Json @default(dbgenerated("'{}'::jsonb"))
aux Json @default(dbgenerated("'{}'::jsonb"))
@@map("emperior")
@@map("emperior")
}
model Troop {
troopLeaderId Int @id @map("troop_leader")
nationId Int @map("nation")
name String
troopLeaderId Int @id @map("troop_leader")
nationId Int @map("nation")
name String
@@map("troop")
@@map("troop")
}
model GeneralTurn {
id Int @id @default(autoincrement())
generalId Int @map("general_id")
turnIdx Int @map("turn_idx")
actionCode String @map("action_code")
arg Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
generalId Int @map("general_id")
turnIdx Int @map("turn_idx")
actionCode String @map("action_code")
arg Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@unique([generalId, turnIdx])
@@map("general_turn")
@@unique([generalId, turnIdx])
@@map("general_turn")
}
model NationTurn {
id Int @id @default(autoincrement())
nationId Int @map("nation_id")
officerLevel Int @map("officer_level")
turnIdx Int @map("turn_idx")
actionCode String @map("action_code")
arg Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
nationId Int @map("nation_id")
officerLevel Int @map("officer_level")
turnIdx Int @map("turn_idx")
actionCode String @map("action_code")
arg Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@unique([nationId, officerLevel, turnIdx])
@@map("nation_turn")
@@unique([nationId, officerLevel, turnIdx])
@@map("nation_turn")
}
model Diplomacy {
id Int @id @default(autoincrement())
srcNationId Int @map("src_nation_id")
destNationId Int @map("dest_nation_id")
stateCode Int @map("state_code")
term Int @default(0)
isDead Boolean @default(false) @map("is_dead")
isShowing Boolean @default(true) @map("is_showing")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
srcNationId Int @map("src_nation_id")
destNationId Int @map("dest_nation_id")
stateCode Int @map("state_code")
term Int @default(0)
isDead Boolean @default(false) @map("is_dead")
isShowing Boolean @default(true) @map("is_showing")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@unique([srcNationId, destNationId])
@@map("diplomacy")
@@unique([srcNationId, destNationId])
@@map("diplomacy")
}
model DiplomacyLetter {
id Int @id @default(autoincrement())
srcNationId Int @map("src_nation_id")
destNationId Int @map("dest_nation_id")
prevId Int? @map("prev_id")
state DiplomacyLetterState @default(PROPOSED)
textBrief String @map("text_brief")
textDetail String @map("text_detail")
date DateTime @default(now()) @map("date")
srcSignerId Int @map("src_signer")
destSignerId Int? @map("dest_signer")
aux Json @default(dbgenerated("'{}'::jsonb"))
id Int @id @default(autoincrement())
srcNationId Int @map("src_nation_id")
destNationId Int @map("dest_nation_id")
prevId Int? @map("prev_id")
state DiplomacyLetterState @default(PROPOSED)
textBrief String @map("text_brief")
textDetail String @map("text_detail")
date DateTime @default(now()) @map("date")
srcSignerId Int @map("src_signer")
destSignerId Int? @map("dest_signer")
aux Json @default(dbgenerated("'{}'::jsonb"))
@@index([srcNationId, destNationId])
@@index([destNationId, srcNationId])
@@index([state, date])
@@map("diplomacy_letter")
@@index([srcNationId, destNationId])
@@index([destNationId, srcNationId])
@@index([state, date])
@@map("diplomacy_letter")
}
model YearbookHistory {
id Int @id @default(autoincrement())
profileName String @map("profile_name")
year Int
month Int
map Json
nations Json
hash String @default("")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
profileName String @map("profile_name")
year Int
month Int
map Json
nations Json
hash String @default("")
createdAt DateTime @default(now()) @map("created_at")
@@unique([profileName, year, month])
@@index([profileName, year, month])
@@map("yearbook_history")
@@unique([profileName, year, month])
@@index([profileName, year, month])
@@map("yearbook_history")
}
model Event {
id Int @id @default(autoincrement())
targetCode String @map("target_code")
priority Int @default(0)
condition Json @default(dbgenerated("'{}'::jsonb"))
action Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
targetCode String @map("target_code")
priority Int @default(0)
condition Json @default(dbgenerated("'{}'::jsonb"))
action Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@map("event")
@@map("event")
}
model LogEntry {
id Int @id @default(autoincrement())
scope LogScope
category LogCategory
subType String? @map("sub_type")
year Int
month Int
text String
generalId Int? @map("general_id")
nationId Int? @map("nation_id")
userId Int? @map("user_id")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
scope LogScope
category LogCategory
subType String? @map("sub_type")
year Int
month Int
text String
generalId Int? @map("general_id")
nationId Int? @map("nation_id")
userId Int? @map("user_id")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@index([scope, category, id])
@@index([generalId, category, id])
@@index([nationId, category, id])
@@index([userId, category, id])
@@map("log_entry")
@@index([scope, category, id])
@@index([generalId, category, id])
@@index([nationId, category, id])
@@index([userId, category, id])
@@map("log_entry")
}
model ErrorLog {
id Int @id @default(autoincrement())
category String
source String? @map("source")
message String
trace String?
context Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
category String
source String? @map("source")
message String
trace String?
context Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@index([category, id])
@@map("error_log")
@@index([category, id])
@@map("error_log")
}
model InheritancePoint {
id Int @id @default(autoincrement())
userId String @map("user_id")
key String
value Float @default(0)
aux Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
userId String @map("user_id")
key String
value Float @default(0)
aux Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, key])
@@index([userId])
@@map("inheritance_point")
@@unique([userId, key])
@@index([userId])
@@map("inheritance_point")
}
model InheritanceLog {
id Int @id @default(autoincrement())
userId String @map("user_id")
year Int
month Int
text String
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
userId String @map("user_id")
year Int
month Int
text String
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, id])
@@map("inheritance_log")
@@index([userId, id])
@@map("inheritance_log")
}
model InheritanceResult {
id Int @id @default(autoincrement())
serverId String @map("server_id")
owner String @map("owner")
generalId Int @map("general_id")
year Int
month Int
value Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
serverId String @map("server_id")
owner String @map("owner")
generalId Int @map("general_id")
year Int
month Int
value Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@index([serverId, owner])
@@map("inheritance_result")
@@index([serverId, owner])
@@map("inheritance_result")
}
model Auction {
id Int @id @default(autoincrement())
type AuctionType
targetCode String? @map("target_code")
hostGeneralId Int @map("host_general_id")
hostName String? @map("host_name")
detail Json @default(dbgenerated("'{}'::jsonb"))
status AuctionStatus @default(OPEN)
closeAt DateTime @map("close_at")
latestEventId String @default("") @map("latest_event_id")
latestEventAt DateTime @default(now()) @map("latest_event_at")
finalizingAt DateTime? @map("finalizing_at")
finishedAt DateTime? @map("finished_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
type AuctionType
targetCode String? @map("target_code")
hostGeneralId Int @map("host_general_id")
hostName String? @map("host_name")
detail Json @default(dbgenerated("'{}'::jsonb"))
status AuctionStatus @default(OPEN)
closeAt DateTime @map("close_at")
latestEventId String @default("") @map("latest_event_id")
latestEventAt DateTime @default(now()) @map("latest_event_at")
finalizingAt DateTime? @map("finalizing_at")
finishedAt DateTime? @map("finished_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
bids AuctionBid[]
bids AuctionBid[]
@@index([status, closeAt])
@@map("auction")
@@index([status, closeAt])
@@map("auction")
}
model AuctionBid {
id Int @id @default(autoincrement())
auctionId Int @map("auction_id")
generalId Int @map("general_id")
amount Int
eventId String @map("event_id")
eventAt DateTime @map("event_at")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
auctionId Int @map("auction_id")
generalId Int @map("general_id")
amount Int
eventId String @map("event_id")
eventAt DateTime @map("event_at")
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade)
auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade)
@@index([auctionId, amount])
@@index([auctionId, eventAt])
@@map("auction_bid")
@@index([auctionId, amount])
@@index([auctionId, eventAt])
@@map("auction_bid")
}
model InheritanceUserState {
userId String @id @map("user_id")
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
userId String @id @map("user_id")
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
@@map("inheritance_user_state")
@@map("inheritance_user_state")
}
model BoardPost {
id Int @id @default(autoincrement())
nationId Int @map("nation_id")
isSecret Boolean @default(false) @map("is_secret")
authorGeneralId Int @map("author_general_id")
authorName String @map("author_name")
title String
contentHtml String @map("content_html")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
nationId Int @map("nation_id")
isSecret Boolean @default(false) @map("is_secret")
authorGeneralId Int @map("author_general_id")
authorName String @map("author_name")
title String
contentHtml String @map("content_html")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
comments BoardComment[]
comments BoardComment[]
@@index([nationId, isSecret, createdAt])
@@map("board_post")
@@index([nationId, isSecret, createdAt])
@@map("board_post")
}
model BoardComment {
id Int @id @default(autoincrement())
postId Int @map("post_id")
nationId Int @map("nation_id")
isSecret Boolean @default(false) @map("is_secret")
authorGeneralId Int @map("author_general_id")
authorName String @map("author_name")
contentText String @map("content_text")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
postId Int @map("post_id")
nationId Int @map("nation_id")
isSecret Boolean @default(false) @map("is_secret")
authorGeneralId Int @map("author_general_id")
authorName String @map("author_name")
contentText String @map("content_text")
createdAt DateTime @default(now()) @map("created_at")
post BoardPost @relation(fields: [postId], references: [id], onDelete: Cascade)
post BoardPost @relation(fields: [postId], references: [id], onDelete: Cascade)
@@index([postId, createdAt])
@@map("board_comment")
@@index([postId, createdAt])
@@map("board_comment")
}
model VotePoll {
id Int @id @default(autoincrement())
title String
body String @default("")
options Json
multipleOptions Int @default(1) @map("multiple_options")
revealMode String @map("reveal_mode")
openerGeneralId Int @map("opener_general_id")
openerName String @map("opener_name")
startAt DateTime @default(now()) @map("start_at")
endAt DateTime? @map("end_at")
closedAt DateTime? @map("closed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id Int @id @default(autoincrement())
title String
body String @default("")
options Json
multipleOptions Int @default(1) @map("multiple_options")
revealMode String @map("reveal_mode")
openerGeneralId Int @map("opener_general_id")
openerName String @map("opener_name")
startAt DateTime @default(now()) @map("start_at")
endAt DateTime? @map("end_at")
closedAt DateTime? @map("closed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
votes Vote[]
comments VoteComment[]
votes Vote[]
comments VoteComment[]
@@map("vote_poll")
@@map("vote_poll")
}
model Vote {
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
selection Json
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
selection Json
createdAt DateTime @default(now()) @map("created_at")
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
@@unique([voteId, generalId])
@@index([voteId])
@@map("vote")
@@unique([voteId, generalId])
@@index([voteId])
@@map("vote")
}
model VoteComment {
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
generalName String @map("general_name")
nationName String @map("nation_name")
text String
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
generalName String @map("general_name")
nationName String @map("nation_name")
text String
createdAt DateTime @default(now()) @map("created_at")
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
@@index([voteId, createdAt])
@@map("vote_comment")
@@index([voteId, createdAt])
@@map("vote_comment")
}
@@ -0,0 +1,6 @@
ALTER TABLE "app_user"
ADD COLUMN "picture" TEXT NOT NULL DEFAULT 'default.jpg',
ADD COLUMN "image_server" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "icon_updated_at" TIMESTAMP(3),
ADD COLUMN "third_party_use" BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN "delete_after" TIMESTAMP(3);
+84 -35
View File
@@ -1,53 +1,77 @@
generator client {
provider = "prisma-client-js"
output = "./generated/gateway"
engineType = "binary"
provider = "prisma-client-js"
output = "./generated/gateway"
engineType = "binary"
}
datasource db {
provider = "postgresql"
provider = "postgresql"
}
enum OAuthType {
NONE
KAKAO
NONE
KAKAO
}
enum GatewayProfileStatus {
RESERVED
PREOPEN
RUNNING
PAUSED
COMPLETED
STOPPED
DISABLED
RESERVED
PREOPEN
RUNNING
PAUSED
COMPLETED
STOPPED
DISABLED
}
enum GatewayBuildStatus {
IDLE
IDLE
QUEUED
RUNNING
FAILED
SUCCEEDED
}
enum GatewayOperationType {
RESET
START
STOP
}
enum GatewayOperationStatus {
QUEUED
RUNNING
FAILED
SUCCEEDED
FAILED
CANCELLED
}
enum GatewaySourceMode {
BRANCH
COMMIT
}
model AppUser {
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
picture String @default("default.jpg")
imageServer Int @default(0) @map("image_server")
iconUpdatedAt DateTime? @map("icon_updated_at")
thirdPartyUse Boolean @default(true) @map("third_party_use")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
@@map("app_user")
@@map("app_user")
}
model GatewayProfile {
@@ -71,14 +95,39 @@ model GatewayProfile {
meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
operations GatewayOperation[]
@@unique([profile, scenario])
@@map("gateway_profile")
@@unique([profile, scenario])
@@map("gateway_profile")
}
model GatewayOperation {
id String @id @default(uuid())
profileName String @map("profile_name")
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade)
type GatewayOperationType
status GatewayOperationStatus @default(QUEUED)
sourceMode GatewaySourceMode? @map("source_mode")
sourceRef String? @map("source_ref")
resolvedCommitSha String? @map("resolved_commit_sha")
payload Json @default(dbgenerated("'{}'::jsonb"))
reason String?
requestedBy String @map("requested_by")
scheduledAt DateTime? @map("scheduled_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
error String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([status, scheduledAt, createdAt])
@@index([profileName, createdAt])
@@map("gateway_operation")
}
model SystemSetting {
id Int @id @default(1) @map("no")
notice String @default("") @map("notice")
id Int @id @default(1) @map("no")
notice String @default("") @map("notice")
@@map("system")
@@map("system")
}
@@ -0,0 +1,35 @@
CREATE TYPE "GatewayOperationType" AS ENUM ('RESET', 'START', 'STOP');
CREATE TYPE "GatewayOperationStatus" AS ENUM ('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED');
CREATE TYPE "GatewaySourceMode" AS ENUM ('BRANCH', 'COMMIT');
CREATE TABLE "gateway_operation" (
"id" TEXT NOT NULL,
"profile_name" TEXT NOT NULL,
"type" "GatewayOperationType" NOT NULL,
"status" "GatewayOperationStatus" NOT NULL DEFAULT 'QUEUED',
"source_mode" "GatewaySourceMode",
"source_ref" TEXT,
"resolved_commit_sha" TEXT,
"payload" JSONB NOT NULL DEFAULT '{}'::jsonb,
"reason" TEXT,
"requested_by" TEXT NOT NULL,
"scheduled_at" TIMESTAMP(3),
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gateway_operation_pkey" PRIMARY KEY ("id"),
CONSTRAINT "gateway_operation_profile_name_fkey"
FOREIGN KEY ("profile_name") REFERENCES "gateway_profile"("profile_name")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "gateway_operation_status_scheduled_at_created_at_idx"
ON "gateway_operation"("status", "scheduled_at", "created_at");
CREATE INDEX "gateway_operation_profile_name_created_at_idx"
ON "gateway_operation"("profile_name", "created_at");
CREATE UNIQUE INDEX "gateway_operation_one_active_per_profile_idx"
ON "gateway_operation"("profile_name")
WHERE "status" IN ('QUEUED', 'RUNNING');
@@ -0,0 +1,6 @@
CREATE TABLE "message_read_state" (
"general_id" INTEGER PRIMARY KEY,
"latest_private_message" INTEGER NOT NULL DEFAULT 0,
"latest_diplomacy_message" INTEGER NOT NULL DEFAULT 0,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,2 @@
ALTER TABLE "nation"
ADD COLUMN "chief_general_id" INTEGER;
+2
View File
@@ -7,6 +7,8 @@ export interface DatabaseClient {
worldState: GamePrisma.WorldStateDelegate;
general: GamePrisma.GeneralDelegate;
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
messageReadState: GamePrisma.MessageReadStateDelegate;
message: GamePrisma.MessageDelegate;
city: GamePrisma.CityDelegate;
nation: GamePrisma.NationDelegate;
diplomacy: GamePrisma.DiplomacyDelegate;
+2
View File
@@ -84,6 +84,7 @@ export interface TurnEngineNationRow {
name: string;
color: string;
capitalCityId: number | null;
chiefGeneralId: number | null;
gold: number;
rice: number;
tech: number;
@@ -287,6 +288,7 @@ export interface TurnEngineNationCreateManyInput {
name: string;
color: string;
capitalCityId: number | null;
chiefGeneralId: number | null;
gold: number;
rice: number;
tech: number;
@@ -403,7 +403,11 @@ export class ActionDefinition<
const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null;
const defenderGenerals = generals.filter(
(general) => general.cityId === defenderCity.id && general.nationId === defenderCity.nationId
(general) =>
general.cityId === defenderCity.id &&
general.nationId === defenderCity.nationId &&
general.crew > 0 &&
(unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false)
);
const battle = resolveWarBattle({
+6
View File
@@ -233,6 +233,9 @@ importers:
'@fastify/cors':
specifier: ^11.2.0
version: 11.2.0
'@fastify/static':
specifier: ^9.0.0
version: 9.0.0
'@prisma/client':
specifier: ^7.2.0
version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2)
@@ -266,6 +269,9 @@ importers:
redis:
specifier: ^5.10.0
version: 5.10.0
sharp:
specifier: ^0.34.4
version: 0.34.5
zod:
specifier: ^4.3.5
version: 4.3.5
@@ -295,7 +295,7 @@ describe('auction integration flow', () => {
const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: adminSessionRef.value ?? '',
profile: 'che:2',
profile: 'che:908',
});
const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({
gatewayToken: adminGatewayToken.gameToken,