Merge branch 'main' into feature/frontend-legacy-parity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -164,3 +164,4 @@ docker-compose.override.yml
|
||||
docs/image-storage.md
|
||||
playwright-report/
|
||||
test-results/
|
||||
uploads/
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
@@ -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 .",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 통과만으로 일반 장수 명령
|
||||
전체를 `확인` 또는 이식 완료로 표시하지 않는다.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
+6
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Generated
+6
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user