feat: 군대 관련 기능 추가 및 명령어 정의 업데이트

This commit is contained in:
2026-01-04 13:54:30 +00:00
parent 4fe4af868a
commit b2a625d4e7
16 changed files with 534 additions and 8 deletions
+8 -1
View File
@@ -106,13 +106,20 @@ export interface NationRow {
meta: unknown;
}
export interface TroopRow {
troopLeaderId: number;
nationId: number;
name: string;
}
export type DatabaseClient = InfraDatabaseClient<
WorldStateRow,
GeneralRow,
CityRow,
NationRow,
GeneralTurnRow,
NationTurnRow
NationTurnRow,
TroopRow
>;
export interface GameApiContext {
+99 -1
View File
@@ -196,7 +196,7 @@ export const appRouter = router({
});
}
const [city, nation] = await Promise.all([
const [city, nation, nationGenerals] = await Promise.all([
general.cityId > 0
? ctx.db.city.findUnique({
where: { id: general.cityId },
@@ -207,6 +207,11 @@ export const appRouter = router({
where: { id: general.nationId },
})
: null,
general.nationId > 0
? ctx.db.general.findMany({
where: { nationId: general.nationId },
})
: Promise.resolve(null),
]);
return buildTurnCommandTable({
@@ -214,6 +219,7 @@ export const appRouter = router({
general,
city,
nation,
nationGenerals,
});
}),
reserved: router({
@@ -619,6 +625,98 @@ export const appRouter = router({
return { msgType, msgId: result.receiverId };
}),
}),
troop: router({
join: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
troopId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
if (general.troopId !== 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Already in a troop.',
});
}
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'General is not part of a nation.',
});
}
const troop = await ctx.db.troop.findUnique({
where: { troopLeaderId: input.troopId },
});
if (!troop || troop.nationId !== general.nationId) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Troop is invalid.',
});
}
await ctx.db.general.update({
where: { id: general.id },
data: { troopId: input.troopId },
});
return { ok: true };
}),
exit: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
if (general.troopId === 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Not in a troop.',
});
}
if (general.troopId !== general.id) {
await ctx.db.general.update({
where: { id: general.id },
data: { troopId: 0 },
});
return { ok: true, wasLeader: false };
}
await ctx.db.$transaction([
ctx.db.general.updateMany({
where: { troopId: general.troopId },
data: { troopId: 0 },
}),
ctx.db.troop.deleteMany({
where: { troopLeaderId: general.troopId },
}),
]);
return { ok: true, wasLeader: true };
}),
}),
turnDaemon: router({
run: procedure
.input(
+12 -2
View File
@@ -117,6 +117,8 @@ class MemoryStateView implements StateView {
switch (req.kind) {
case 'general':
return `general:${req.id}`;
case 'generalList':
return 'general:list';
case 'city':
return `city:${req.id}`;
case 'nation':
@@ -382,7 +384,8 @@ const mapNationRow = (row: NationRow): Nation => ({
const buildStateView = (
general: General,
city: City | null,
nation: Nation | null
nation: Nation | null,
generalList: General[] | null
): StateView => {
const view = new MemoryStateView();
view.set({ kind: 'general', id: general.id }, general);
@@ -392,6 +395,9 @@ const buildStateView = (
if (nation) {
view.set({ kind: 'nation', id: nation.id }, nation);
}
if (generalList) {
view.set({ kind: 'generalList' }, generalList);
}
return view;
};
@@ -538,12 +544,16 @@ export const buildTurnCommandTable = async (options: {
general: GeneralRow;
city: CityRow | null;
nation: NationRow | null;
nationGenerals: GeneralRow[] | null;
}): Promise<TurnCommandTable> => {
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
const general = mapGeneralRow(options.general);
const city = options.city ? mapCityRow(options.city) : null;
const nation = options.nation ? mapNationRow(options.nation) : null;
const view = buildStateView(general, city, nation);
const generalList = options.nationGenerals
? options.nationGenerals.map(mapGeneralRow)
: null;
const view = buildStateView(general, city, nation, generalList);
const ctx: ConstraintContext = {
actorId: general.id,
+10 -1
View File
@@ -3,7 +3,16 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true
"composite": true,
"baseUrl": ".",
"paths": {
"@sammo-ts/common": ["../../packages/common/src/index.ts"],
"@sammo-ts/common/*": ["../../packages/common/src/*"],
"@sammo-ts/infra": ["../../packages/infra/src/index.ts"],
"@sammo-ts/infra/*": ["../../packages/infra/src/*"],
"@sammo-ts/logic": ["../../packages/logic/src/index.ts"],
"@sammo-ts/logic/*": ["../../packages/logic/src/*"]
}
},
"include": ["src"],
"references": [
@@ -120,6 +120,7 @@ type WorldView = {
getGeneralById(id: number): TurnGeneral | null;
getCityById(id: number): City | null;
getNationById(id: number): Nation | null;
getTroopById(id: number): Troop | null;
getDiplomacyEntry(
srcNationId: number,
destNationId: number
@@ -127,6 +128,7 @@ type WorldView = {
listGenerals(): TurnGeneral[];
listCities(): City[];
listNations(): Nation[];
listTroops(): Troop[];
listDiplomacy(): TurnDiplomacy[];
};
@@ -230,6 +232,7 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => {
getCityById: (id) => cityOverrides.get(id) ?? world.getCityById(id),
getNationById: (id) =>
nationOverrides.get(id) ?? world.getNationById(id),
getTroopById: (id) => world.getTroopById(id),
getDiplomacyEntry: (srcNationId, destNationId) =>
diplomacyOverrides.get(
buildDiplomacyKey(srcNationId, destNationId)
@@ -246,6 +249,7 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => {
mergeList(world.listNations(), nationOverrides).map((nation) => ({
...nation,
})),
listTroops: () => world.listTroops().map((troop) => ({ ...troop })),
listDiplomacy: () =>
mergeDiplomacyList(
world.listDiplomacy(),
@@ -330,6 +334,8 @@ class WorldStateView implements StateView {
return this.overrides.general;
}
return this.world.getGeneralById(req.id);
case 'generalList':
return this.world.listGenerals();
case 'destGeneral':
return this.world.getGeneralById(req.id);
case 'city':
+7 -1
View File
@@ -23,7 +23,13 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@sammo-ts/common": ["../../packages/common/src/index.ts"],
"@sammo-ts/common/*": ["../../packages/common/src/*"],
"@sammo-ts/infra": ["../../packages/infra/src/index.ts"],
"@sammo-ts/infra/*": ["../../packages/infra/src/*"],
"@sammo-ts/logic": ["../../packages/logic/src/index.ts"],
"@sammo-ts/logic/*": ["../../packages/logic/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],