701 lines
28 KiB
TypeScript
701 lines
28 KiB
TypeScript
import { TRPCError } from '@trpc/server';
|
|
import { z } from 'zod';
|
|
import { randomBytes } from 'node:crypto';
|
|
|
|
import type { DatabaseClient, WorldStateRow } from '../../context.js';
|
|
import { authedProcedure, router } from '../../trpc.js';
|
|
import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common';
|
|
import {
|
|
isPersonalityTraitKey,
|
|
isWarTraitKey,
|
|
loadPersonalityTraitModules,
|
|
loadWarTraitModules,
|
|
PersonalityTraitLoader,
|
|
PERSONALITY_TRAIT_KEYS,
|
|
WarTraitLoader,
|
|
WAR_TRAIT_KEYS,
|
|
} from '@sammo-ts/logic';
|
|
import {
|
|
appendInheritanceLog,
|
|
readInheritancePoint,
|
|
resolveInheritConstants,
|
|
setInheritancePoint,
|
|
} from '../../services/inheritance.js';
|
|
|
|
const DEFAULT_JOIN_STAT = {
|
|
total: 165,
|
|
min: 15,
|
|
max: 80,
|
|
bonusMin: 3,
|
|
bonusMax: 5,
|
|
};
|
|
|
|
const resolveJoinStat = (worldState: WorldStateRow) => {
|
|
const config = asRecord(worldState.config);
|
|
const stat = asRecord(config.stat);
|
|
return {
|
|
total: asNumber(stat.total, DEFAULT_JOIN_STAT.total),
|
|
min: asNumber(stat.min, DEFAULT_JOIN_STAT.min),
|
|
max: asNumber(stat.max, DEFAULT_JOIN_STAT.max),
|
|
bonusMin: DEFAULT_JOIN_STAT.bonusMin,
|
|
bonusMax: DEFAULT_JOIN_STAT.bonusMax,
|
|
};
|
|
};
|
|
|
|
const resolveJoinPolicy = (worldState: WorldStateRow) => {
|
|
const config = asRecord(worldState.config);
|
|
const joinMode = typeof config.joinMode === 'string' ? config.joinMode : 'full';
|
|
const blockGeneralCreate =
|
|
typeof config.blockGeneralCreate === 'number' && Number.isFinite(config.blockGeneralCreate)
|
|
? Math.floor(config.blockGeneralCreate)
|
|
: 0;
|
|
return { joinMode, blockGeneralCreate };
|
|
};
|
|
|
|
|
|
const hashString = (value: string): number => {
|
|
let hash = 0;
|
|
for (let i = 0; i < value.length; i += 1) {
|
|
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
|
}
|
|
return hash;
|
|
};
|
|
|
|
const pickFromList = (values: string[], seed: string): string | null => {
|
|
if (!values.length) {
|
|
return null;
|
|
}
|
|
const index = hashString(seed) % values.length;
|
|
return values[index] ?? null;
|
|
};
|
|
|
|
const buildTurnTimeZones = (tickMinutes: number): string[] => {
|
|
const zones: string[] = [];
|
|
for (let i = 0; i < 60; i += 1) {
|
|
const totalMinutes = i * tickMinutes;
|
|
const hour = Math.floor(totalMinutes / 60) % 24;
|
|
const minute = totalMinutes % 60;
|
|
zones.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
|
|
}
|
|
return zones;
|
|
};
|
|
|
|
const alignToTurnBase = (time: Date, tickMinutes: number): Date => {
|
|
const base = new Date(time.getFullYear(), time.getMonth(), time.getDate() - 1, 1, 0, 0, 0);
|
|
const elapsedMinutes = Math.floor((time.getTime() - base.getTime()) / 60000);
|
|
const alignedMinutes = elapsedMinutes - (elapsedMinutes % tickMinutes);
|
|
return new Date(base.getTime() + alignedMinutes * 60000);
|
|
};
|
|
|
|
const nextRangeInt = (rng: LiteHashDRBG, minInclusive: number, maxInclusive: number): number => {
|
|
if (maxInclusive <= minInclusive) {
|
|
return minInclusive;
|
|
}
|
|
return minInclusive + rng.nextInt(maxInclusive - minInclusive);
|
|
};
|
|
|
|
const pickWeightedIndex = (rng: LiteHashDRBG, weights: number[]): number => {
|
|
const total = weights.reduce((acc, value) => acc + value, 0);
|
|
if (total <= 0) {
|
|
return 0;
|
|
}
|
|
let cursor = rng.nextFloat1() * total;
|
|
for (let i = 0; i < weights.length; i += 1) {
|
|
cursor -= weights[i] ?? 0;
|
|
if (cursor <= 0) {
|
|
return i;
|
|
}
|
|
}
|
|
return weights.length - 1;
|
|
};
|
|
|
|
const buildRandomBonus = (rng: LiteHashDRBG, baseStats: [number, number, number]): [number, number, number] => {
|
|
const count = rng.nextInt(2) + 3;
|
|
const bonus: [number, number, number] = [0, 0, 0];
|
|
for (let i = 0; i < count; i += 1) {
|
|
const index = pickWeightedIndex(rng, baseStats);
|
|
bonus[index] += 1;
|
|
}
|
|
return bonus;
|
|
};
|
|
|
|
let cachedPersonalityOptions: Array<{ key: string; name: string; info: string }> | null = null;
|
|
|
|
const loadPersonalityOptions = async () => {
|
|
if (cachedPersonalityOptions) {
|
|
return cachedPersonalityOptions;
|
|
}
|
|
const modules = await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader());
|
|
cachedPersonalityOptions = modules.map((trait) => ({
|
|
key: trait.key,
|
|
name: trait.name,
|
|
info: trait.info ?? '',
|
|
}));
|
|
return cachedPersonalityOptions;
|
|
};
|
|
|
|
const loadWarOptions = async (keys: string[]) => {
|
|
const unique = Array.from(new Set(keys.filter((key) => isWarTraitKey(key))));
|
|
const modules = await loadWarTraitModules(unique, new WarTraitLoader());
|
|
return modules.map((trait) => ({
|
|
key: trait.key,
|
|
name: trait.name,
|
|
info: trait.info ?? '',
|
|
}));
|
|
};
|
|
|
|
export const joinRouter = router({
|
|
getConfig: authedProcedure.query(async ({ ctx }) => {
|
|
const worldState = await ctx.db.worldState.findFirst();
|
|
if (!worldState) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'World state is not initialized.',
|
|
});
|
|
}
|
|
|
|
const config = asRecord(worldState.config);
|
|
const configConst = asRecord(config.const);
|
|
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
|
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
|
|
|
const [personalities, warSpecials, nationRows] = await Promise.all([
|
|
loadPersonalityOptions(),
|
|
loadWarOptions(warKeys),
|
|
ctx.db.nation.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
color: true,
|
|
meta: true,
|
|
},
|
|
orderBy: { id: 'asc' },
|
|
}),
|
|
]);
|
|
|
|
const nations = nationRows.map((nation) => {
|
|
const meta = asRecord(nation.meta);
|
|
return {
|
|
id: nation.id,
|
|
name: nation.name,
|
|
color: nation.color,
|
|
scoutMessage: typeof meta.infoText === 'string' ? meta.infoText : null,
|
|
};
|
|
});
|
|
|
|
const inheritConst = resolveInheritConstants(worldState);
|
|
const inheritTotalPoint = ctx.auth?.user.id
|
|
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
|
: 0;
|
|
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
|
|
const inheritCitiesRaw = await ctx.db.city.findMany({
|
|
where: { level: { in: [5, 6] }, nationId: 0 },
|
|
select: { id: true, name: true, level: true, region: true },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
const inheritCities =
|
|
inheritCitiesRaw.length > 0
|
|
? inheritCitiesRaw
|
|
: await ctx.db.city.findMany({
|
|
where: { level: { in: [5, 6] } },
|
|
select: { id: true, name: true, level: true, region: true },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
|
|
return {
|
|
rules: {
|
|
stat: resolveJoinStat(worldState),
|
|
allowCustomName: true,
|
|
},
|
|
user: {
|
|
id: ctx.auth?.user.id ?? '',
|
|
displayName: ctx.auth?.user.displayName ?? '',
|
|
},
|
|
personalities: [
|
|
{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' },
|
|
...personalities,
|
|
],
|
|
warSpecials,
|
|
nations,
|
|
inherit: {
|
|
totalPoint: inheritTotalPoint,
|
|
costs: {
|
|
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
|
|
inheritBornTurntimePoint: inheritConst.inheritBornTurntimePoint,
|
|
inheritBornCityPoint: inheritConst.inheritBornCityPoint,
|
|
inheritBornStatPoint: inheritConst.inheritBornStatPoint,
|
|
},
|
|
availableCities: inheritCities,
|
|
turnTimeZones: buildTurnTimeZones(tickMinutes),
|
|
availableSpecialWar: warSpecials,
|
|
},
|
|
};
|
|
}),
|
|
createGeneral: authedProcedure
|
|
.input(
|
|
z.object({
|
|
name: z.string().min(1).max(18),
|
|
leadership: z.number().int(),
|
|
strength: z.number().int(),
|
|
intel: z.number().int(),
|
|
pic: z.boolean().optional(),
|
|
character: z.string(),
|
|
inheritSpecial: z.string().optional(),
|
|
inheritTurntimeZone: z.number().int().optional(),
|
|
inheritCity: z.number().int().optional(),
|
|
inheritBonusStat: z.tuple([z.number().int(), z.number().int(), z.number().int()]).optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.auth?.user.id;
|
|
if (!userId) {
|
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
}
|
|
|
|
const worldState = await ctx.db.worldState.findFirst();
|
|
if (!worldState) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'World state is not initialized.',
|
|
});
|
|
}
|
|
|
|
const joinPolicy = resolveJoinPolicy(worldState);
|
|
if (joinPolicy.blockGeneralCreate === 1) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: '장수 생성이 제한된 서버입니다.',
|
|
});
|
|
}
|
|
|
|
const inheritConst = resolveInheritConstants(worldState);
|
|
const configConst = asRecord(asRecord(worldState.config).const);
|
|
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
|
const inheritBonus = input.inheritBonusStat ?? null;
|
|
if (inheritBonus) {
|
|
const bonusSum = inheritBonus.reduce((acc, value) => acc + value, 0);
|
|
if (inheritBonus.some((value) => value < 0)) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: '보너스 능력치가 음수입니다. 다시 가입해주세요!',
|
|
});
|
|
}
|
|
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: '보너스 능력치 합이 잘못 지정되었습니다. 다시 가입해주세요!',
|
|
});
|
|
}
|
|
}
|
|
if (input.inheritSpecial !== undefined) {
|
|
if (!isWarTraitKey(input.inheritSpecial)) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: '전투 특기가 잘못 지정되었습니다.',
|
|
});
|
|
}
|
|
if (availableSpecialWar.length > 0 && !availableSpecialWar.includes(input.inheritSpecial)) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: '허용되지 않은 전투 특기입니다.',
|
|
});
|
|
}
|
|
}
|
|
if (input.inheritTurntimeZone !== undefined) {
|
|
if (input.inheritTurntimeZone < 0 || input.inheritTurntimeZone > 59) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: '턴 시간 지정 범위가 올바르지 않습니다.',
|
|
});
|
|
}
|
|
}
|
|
|
|
const statRule = resolveJoinStat(worldState);
|
|
const statTotal = input.leadership + input.strength + input.intel;
|
|
|
|
if (
|
|
input.leadership < statRule.min ||
|
|
input.strength < statRule.min ||
|
|
input.intel < statRule.min ||
|
|
input.leadership > statRule.max ||
|
|
input.strength > statRule.max ||
|
|
input.intel > statRule.max
|
|
) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: '능력치 범위를 벗어났습니다.',
|
|
});
|
|
}
|
|
|
|
if (statTotal > statRule.total) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `능력치 합이 ${statRule.total}을 초과했습니다.`,
|
|
});
|
|
}
|
|
|
|
const personalityOptions = await loadPersonalityOptions();
|
|
const personalityKeys = personalityOptions.map((trait) => trait.key);
|
|
const resolveGeneralName = async (): Promise<string> => {
|
|
if (joinPolicy.blockGeneralCreate !== 2) {
|
|
return input.name;
|
|
}
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
const candidate = randomBytes(5).toString('hex');
|
|
const exists = await ctx.db.general.findFirst({ where: { name: candidate } });
|
|
if (!exists) {
|
|
return candidate;
|
|
}
|
|
}
|
|
throw new TRPCError({
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
message: '랜덤 장수명 생성에 실패했습니다.',
|
|
});
|
|
};
|
|
|
|
const generalName = await resolveGeneralName();
|
|
const chosenPersonality =
|
|
input.character === 'Random'
|
|
? pickFromList(personalityKeys, `${userId}:${generalName}`) ?? 'None'
|
|
: isPersonalityTraitKey(input.character)
|
|
? input.character
|
|
: 'None';
|
|
|
|
const createGeneral = async (db: DatabaseClient) => {
|
|
const existing = await db.general.findFirst({ where: { userId } });
|
|
if (existing) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: '이미 장수가 생성되어 있습니다.',
|
|
});
|
|
}
|
|
const nameExists = await db.general.findFirst({ where: { name: generalName } });
|
|
if (nameExists) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: '이미 존재하는 장수명입니다.',
|
|
});
|
|
}
|
|
|
|
const maxId = await db.general.aggregate({ _max: { id: true } });
|
|
const nextId = (maxId._max.id ?? 0) + 1;
|
|
const cityList = await db.city.findMany({
|
|
select: { id: true, level: true, nationId: true, name: true },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
if (!cityList.length) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: '도시 정보를 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
const neutralCities = cityList.filter((city) => city.level >= 5 && city.level <= 6 && city.nationId === 0);
|
|
const candidateCities =
|
|
neutralCities.length > 0 ? neutralCities : cityList.filter((city) => city.level >= 5 && city.level <= 6);
|
|
if (!candidateCities.length) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: '생성 가능한 도시가 없습니다.',
|
|
});
|
|
}
|
|
|
|
let inheritRequiredPoint = 0;
|
|
if (input.inheritCity !== undefined) {
|
|
inheritRequiredPoint += inheritConst.inheritBornCityPoint;
|
|
}
|
|
if (input.inheritSpecial !== undefined) {
|
|
inheritRequiredPoint += inheritConst.inheritBornSpecialPoint;
|
|
}
|
|
if (input.inheritTurntimeZone !== undefined) {
|
|
inheritRequiredPoint += inheritConst.inheritBornTurntimePoint;
|
|
}
|
|
if (inheritBonus && inheritBonus.reduce((acc, value) => acc + value, 0) > 0) {
|
|
inheritRequiredPoint += inheritConst.inheritBornStatPoint;
|
|
}
|
|
|
|
const currentPoint = await readInheritancePoint(db, userId, 'previous');
|
|
if (currentPoint < inheritRequiredPoint) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: '유산 포인트가 부족합니다.',
|
|
});
|
|
}
|
|
|
|
const hiddenSeed = String(asRecord(worldState.meta).hiddenSeed ?? 'inherit');
|
|
const rng = new LiteHashDRBG(`${hiddenSeed}:MakeGeneral:${userId}:${generalName}`);
|
|
|
|
const bonusStatSum = inheritBonus ? inheritBonus.reduce((acc, value) => acc + value, 0) : 0;
|
|
const randomBonus =
|
|
!inheritBonus || bonusStatSum === 0
|
|
? buildRandomBonus(
|
|
rng,
|
|
[input.leadership, input.strength, input.intel]
|
|
)
|
|
: (inheritBonus as [number, number, number]);
|
|
|
|
const finalLeadership = input.leadership + randomBonus[0];
|
|
const finalStrength = input.strength + randomBonus[1];
|
|
const finalIntel = input.intel + randomBonus[2];
|
|
const age = 20 + (randomBonus[0] + randomBonus[1] + randomBonus[2]) * 2 - nextRangeInt(rng, 0, 1);
|
|
|
|
const selectedCity =
|
|
typeof input.inheritCity === 'number'
|
|
? candidateCities.find((city) => city.id === input.inheritCity)
|
|
: null;
|
|
if (input.inheritCity !== undefined && !selectedCity) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: '지정한 도시를 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
const cityIndex = nextRangeInt(rng, 0, candidateCities.length - 1);
|
|
const cityId = (selectedCity ?? candidateCities[cityIndex] ?? candidateCities[0]).id;
|
|
|
|
const defaultSpecialDomestic =
|
|
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None';
|
|
const defaultSpecialWar =
|
|
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
|
|
|
const specialWar =
|
|
input.inheritSpecial && isWarTraitKey(input.inheritSpecial) ? input.inheritSpecial : defaultSpecialWar;
|
|
|
|
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
|
|
const baseTime = alignToTurnBase(new Date(), tickMinutes);
|
|
let turnTime = new Date(baseTime.getTime() + rng.nextFloat1() * tickMinutes * 60000);
|
|
if (input.inheritTurntimeZone !== undefined) {
|
|
const offsetMinutes = input.inheritTurntimeZone * tickMinutes + rng.nextFloat1() * tickMinutes;
|
|
turnTime = new Date(baseTime.getTime() + offsetMinutes * 60000);
|
|
}
|
|
if (turnTime.getTime() <= Date.now()) {
|
|
turnTime = new Date(turnTime.getTime() + tickMinutes * 60000);
|
|
}
|
|
|
|
const logEntries: string[] = [];
|
|
if (input.inheritSpecial && isWarTraitKey(input.inheritSpecial)) {
|
|
const [special] = await loadWarOptions([input.inheritSpecial]);
|
|
const specialName = special?.name ?? input.inheritSpecial;
|
|
logEntries.push(`${specialName} 전투 특기를 가진 천재 생성`);
|
|
}
|
|
if (input.inheritCity !== undefined && selectedCity) {
|
|
logEntries.push(`${selectedCity.name}에 장수 생성`);
|
|
}
|
|
if (inheritBonus && inheritBonus.reduce((acc, value) => acc + value, 0) > 0) {
|
|
logEntries.push(
|
|
`${inheritBonus[0]}, ${inheritBonus[1]}, ${inheritBonus[2]} 보너스 능력치로 생성`
|
|
);
|
|
}
|
|
if (input.inheritTurntimeZone !== undefined) {
|
|
const zones = buildTurnTimeZones(tickMinutes);
|
|
const zoneLabel = zones[input.inheritTurntimeZone];
|
|
if (zoneLabel) {
|
|
logEntries.push(`턴 시간 ${zoneLabel} 로 지정`);
|
|
}
|
|
}
|
|
|
|
const general = await db.general.create({
|
|
data: {
|
|
id: nextId,
|
|
userId,
|
|
name: generalName,
|
|
nationId: 0,
|
|
cityId,
|
|
troopId: 0,
|
|
npcState: 0,
|
|
leadership: finalLeadership,
|
|
strength: finalStrength,
|
|
intel: finalIntel,
|
|
personalCode: chosenPersonality ?? 'None',
|
|
specialCode: defaultSpecialDomestic,
|
|
special2Code: specialWar,
|
|
turnTime,
|
|
age,
|
|
startAge: age,
|
|
meta: {
|
|
createdBy: 'join',
|
|
ownerName: ctx.auth?.user.displayName ?? '',
|
|
killturn: 24,
|
|
},
|
|
},
|
|
});
|
|
await db.generalAccessLog.upsert({
|
|
where: { generalId: general.id },
|
|
update: {
|
|
userId,
|
|
lastRefresh: new Date(),
|
|
},
|
|
create: {
|
|
generalId: general.id,
|
|
userId,
|
|
lastRefresh: new Date(),
|
|
},
|
|
});
|
|
|
|
if (inheritRequiredPoint > 0) {
|
|
await setInheritancePoint(db, userId, 'previous', currentPoint - inheritRequiredPoint);
|
|
}
|
|
for (const entry of logEntries) {
|
|
await appendInheritanceLog(db, userId, worldState.currentYear, worldState.currentMonth, entry);
|
|
}
|
|
|
|
return { ok: true, generalId: general.id };
|
|
};
|
|
|
|
return ctx.db.$transaction ? ctx.db.$transaction(createGeneral) : createGeneral(ctx.db);
|
|
}),
|
|
listPossessCandidates: authedProcedure
|
|
.input(
|
|
z.object({
|
|
limit: z.number().int().min(1).max(50).optional(),
|
|
offset: z.number().int().min(0).optional(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const limit = input.limit ?? 20;
|
|
const offset = input.offset ?? 0;
|
|
|
|
const candidates = await ctx.db.general.findMany({
|
|
where: {
|
|
userId: null,
|
|
npcState: { gte: 2 },
|
|
},
|
|
orderBy: { id: 'asc' },
|
|
skip: offset,
|
|
take: limit,
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
npcState: true,
|
|
nationId: true,
|
|
cityId: true,
|
|
leadership: true,
|
|
strength: true,
|
|
intel: true,
|
|
age: true,
|
|
officerLevel: true,
|
|
personalCode: true,
|
|
specialCode: true,
|
|
special2Code: true,
|
|
picture: true,
|
|
imageServer: true,
|
|
},
|
|
});
|
|
|
|
const [nationRows, cityRows] = await Promise.all([
|
|
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
|
|
ctx.db.city.findMany({ select: { id: true, name: true } }),
|
|
]);
|
|
const nationMap = new Map(nationRows.map((nation) => [nation.id, nation]));
|
|
const cityMap = new Map(cityRows.map((city) => [city.id, city]));
|
|
|
|
return candidates.map((candidate) => {
|
|
const nation = nationMap.get(candidate.nationId);
|
|
const city = cityMap.get(candidate.cityId);
|
|
return {
|
|
id: candidate.id,
|
|
name: candidate.name,
|
|
npcState: candidate.npcState,
|
|
nation: nation
|
|
? { id: nation.id, name: nation.name, color: nation.color }
|
|
: { id: 0, name: '재야', color: '#666666' },
|
|
city: city ? { id: city.id, name: city.name } : null,
|
|
stats: {
|
|
leadership: candidate.leadership,
|
|
strength: candidate.strength,
|
|
intelligence: candidate.intel,
|
|
},
|
|
age: candidate.age,
|
|
officerLevel: candidate.officerLevel,
|
|
personality: candidate.personalCode,
|
|
special: candidate.specialCode,
|
|
specialWar: candidate.special2Code,
|
|
picture: candidate.picture,
|
|
imageServer: candidate.imageServer,
|
|
};
|
|
});
|
|
}),
|
|
possessGeneral: authedProcedure
|
|
.input(
|
|
z.object({
|
|
generalId: z.number().int().positive(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.auth?.user.id;
|
|
if (!userId) {
|
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
}
|
|
const existing = await ctx.db.general.findFirst({ where: { userId } });
|
|
if (existing) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: '이미 장수가 생성되어 있습니다.',
|
|
});
|
|
}
|
|
|
|
await ctx.db.$transaction!(async (db) => {
|
|
const [candidate, worldState] = await Promise.all([
|
|
db.general.findUnique({
|
|
where: { id: input.generalId },
|
|
select: { npcState: true, meta: true },
|
|
}),
|
|
db.worldState.findFirst({
|
|
select: { currentYear: true, currentMonth: true },
|
|
}),
|
|
]);
|
|
if (!candidate || candidate.npcState < 2 || !worldState) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: '빙의 가능한 장수를 찾지 못했습니다.',
|
|
});
|
|
}
|
|
|
|
const now = new Date();
|
|
const updated = await db.general.updateMany({
|
|
where: {
|
|
id: input.generalId,
|
|
userId: null,
|
|
npcState: candidate.npcState,
|
|
},
|
|
data: {
|
|
userId,
|
|
npcState: 1,
|
|
meta: {
|
|
...asRecord(candidate.meta),
|
|
npc_org: candidate.npcState,
|
|
owner_name: ctx.auth?.user.displayName ?? '',
|
|
pickYearMonth: worldState.currentYear * 12 + worldState.currentMonth - 1,
|
|
killturn: 6,
|
|
defence_train: 80,
|
|
},
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
if (updated.count === 0) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: '빙의 가능한 장수를 찾지 못했습니다.',
|
|
});
|
|
}
|
|
await db.generalAccessLog.upsert({
|
|
where: { generalId: input.generalId },
|
|
update: {
|
|
userId,
|
|
lastRefresh: now,
|
|
refresh: 0,
|
|
refreshTotal: 0,
|
|
refreshScore: 0,
|
|
refreshScoreTotal: 0,
|
|
},
|
|
create: {
|
|
generalId: input.generalId,
|
|
userId,
|
|
lastRefresh: now,
|
|
},
|
|
});
|
|
});
|
|
|
|
return { ok: true };
|
|
}),
|
|
});
|