feat(game-ui): port Ref progress bars
This commit is contained in:
@@ -249,7 +249,7 @@ export const generalRouter = router({
|
||||
return null;
|
||||
}
|
||||
|
||||
const [city, nation] = await Promise.all([
|
||||
const [city, nation, worldState] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
@@ -259,11 +259,20 @@ export const generalRouter = router({
|
||||
level: true,
|
||||
nationId: true,
|
||||
population: true,
|
||||
populationMax: true,
|
||||
agriculture: true,
|
||||
agricultureMax: true,
|
||||
commerce: true,
|
||||
commerceMax: true,
|
||||
security: true,
|
||||
securityMax: true,
|
||||
trust: true,
|
||||
trade: true,
|
||||
defence: true,
|
||||
defenceMax: true,
|
||||
wall: true,
|
||||
wallMax: true,
|
||||
region: true,
|
||||
supplyState: true,
|
||||
frontState: true,
|
||||
},
|
||||
@@ -285,9 +294,12 @@ export const generalRouter = router({
|
||||
},
|
||||
})
|
||||
: null,
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
]);
|
||||
|
||||
const metaRecord = asRecord(general.meta);
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const settings = resolveUserSettings(metaRecord);
|
||||
const penalties = resolvePenalty(general.penalty);
|
||||
|
||||
@@ -326,6 +338,12 @@ export const generalRouter = router({
|
||||
progression: {
|
||||
experienceLevel: readNumber(metaRecord.explevel, 0),
|
||||
dedicationLevel: readNumber(metaRecord.dedlevel, 0),
|
||||
statExperience: {
|
||||
leadership: readNumber(metaRecord.leadership_exp, 0),
|
||||
strength: readNumber(metaRecord.strength_exp, 0),
|
||||
intelligence: readNumber(metaRecord.intel_exp, 0),
|
||||
},
|
||||
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
|
||||
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
|
||||
},
|
||||
items: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { LogCategory } from '@sammo-ts/logic';
|
||||
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
@@ -91,6 +92,13 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const worldConfig = asRecord(worldState.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const statUpgradeLimit =
|
||||
typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit)
|
||||
? constValues.upgradeLimit
|
||||
: 30;
|
||||
|
||||
const generals = generalRows.map((general) => {
|
||||
const meta =
|
||||
general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta)
|
||||
@@ -137,6 +145,16 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
specialDomestic: general.specialCode,
|
||||
specialWar: general.special2Code,
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: metaNumber('explevel'),
|
||||
statExperience: {
|
||||
leadership: metaNumber('leadership_exp'),
|
||||
strength: metaNumber('strength_exp'),
|
||||
intelligence: metaNumber('intel_exp'),
|
||||
},
|
||||
statUpgradeLimit,
|
||||
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
|
||||
},
|
||||
battleStats: {
|
||||
kills: metaNumber('rank_killnum') || metaNumber('killnum'),
|
||||
deaths: metaNumber('deathnum'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
|
||||
|
||||
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
@@ -39,7 +39,7 @@ export const troopRouter = router({
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
|
||||
}
|
||||
|
||||
const [nation, troops, generals, cities] = await Promise.all([
|
||||
const [nation, troops, generals, cities, worldState] = await Promise.all([
|
||||
ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: { id: true, name: true, meta: true },
|
||||
@@ -58,11 +58,17 @@ export const troopRouter = router({
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
turnTime: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
experience: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.city.findMany({
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
]);
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||
@@ -80,6 +86,12 @@ export const troopRouter = router({
|
||||
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
|
||||
const generalMap = new Map(generals.map((general) => [general.id, general]));
|
||||
const reservedByLeader = new Map<number, string[]>();
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const statUpgradeLimit =
|
||||
typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit)
|
||||
? constValues.upgradeLimit
|
||||
: 30;
|
||||
for (const turn of turns) {
|
||||
const list = reservedByLeader.get(turn.generalId) ?? [];
|
||||
list.push(turn.actionCode);
|
||||
@@ -107,12 +119,35 @@ export const troopRouter = router({
|
||||
: null,
|
||||
members: generals
|
||||
.filter((general) => general.troopId === troop.troopLeaderId)
|
||||
.map((general) => ({
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
cityId: general.cityId,
|
||||
cityName: cityNames.get(general.cityId) ?? '알 수 없음',
|
||||
})),
|
||||
.map((general) => {
|
||||
const meta = asRecord(general.meta);
|
||||
const metaNumber = (key: string): number => {
|
||||
const value = meta[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
cityId: general.cityId,
|
||||
cityName: cityNames.get(general.cityId) ?? '알 수 없음',
|
||||
stats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
},
|
||||
experience: general.experience,
|
||||
progression: {
|
||||
experienceLevel: metaNumber('explevel'),
|
||||
statExperience: {
|
||||
leadership: metaNumber('leadership_exp'),
|
||||
strength: metaNumber('strength_exp'),
|
||||
intelligence: metaNumber('intel_exp'),
|
||||
},
|
||||
statUpgradeLimit,
|
||||
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
|
||||
@@ -75,6 +75,7 @@ const auth: GameSessionTokenPayload = {
|
||||
|
||||
const createContext = (options: {
|
||||
me?: GeneralRow | null;
|
||||
city?: Record<string, unknown> | null;
|
||||
targets?: GeneralRow[];
|
||||
nationMeta?: Record<string, unknown>;
|
||||
requestCommand?: ReturnType<typeof vi.fn>;
|
||||
@@ -95,7 +96,7 @@ const createContext = (options: {
|
||||
findMany: vi.fn(async () => targets.filter((general) => general.nationId === (me?.nationId ?? 0))),
|
||||
update: vi.fn(),
|
||||
},
|
||||
city: { findUnique: vi.fn(async () => null) },
|
||||
city: { findUnique: vi.fn(async () => options.city ?? null) },
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 1,
|
||||
@@ -115,6 +116,7 @@ const createContext = (options: {
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: { const: { upgradeLimit: 20 } },
|
||||
})),
|
||||
},
|
||||
logEntry: {
|
||||
@@ -162,6 +164,90 @@ const createContext = (options: {
|
||||
};
|
||||
|
||||
describe('in-game my information ownership', () => {
|
||||
it('returns every ref progress-bar input from the owned general and current city read model', async () => {
|
||||
const fixture = createContext({
|
||||
me: buildGeneral({
|
||||
meta: {
|
||||
explevel: 4,
|
||||
dedlevel: 3,
|
||||
leadership_exp: 7,
|
||||
strength_exp: 8,
|
||||
intel_exp: 9,
|
||||
dex1: 350,
|
||||
dex2: 1_375,
|
||||
dex3: 3_500,
|
||||
dex4: 7_125,
|
||||
dex5: 12_650,
|
||||
},
|
||||
}),
|
||||
city: {
|
||||
id: 1,
|
||||
name: '계',
|
||||
level: 5,
|
||||
nationId: 1,
|
||||
population: 322_886,
|
||||
populationMax: 388_500,
|
||||
agriculture: 6_911,
|
||||
agricultureMax: 7_500,
|
||||
commerce: 7_451,
|
||||
commerceMax: 8_000,
|
||||
security: 5_792,
|
||||
securityMax: 6_000,
|
||||
trust: 72,
|
||||
trade: 101,
|
||||
defence: 7_529,
|
||||
defenceMax: 7_800,
|
||||
wall: 7_819,
|
||||
wallMax: 8_100,
|
||||
region: 1,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
|
||||
general: {
|
||||
progression: {
|
||||
experienceLevel: 4,
|
||||
dedicationLevel: 3,
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 12_650],
|
||||
},
|
||||
},
|
||||
city: {
|
||||
population: 322_886,
|
||||
populationMax: 388_500,
|
||||
agriculture: 6_911,
|
||||
agricultureMax: 7_500,
|
||||
commerce: 7_451,
|
||||
commerceMax: 8_000,
|
||||
security: 5_792,
|
||||
securityMax: 6_000,
|
||||
trust: 72,
|
||||
trade: 101,
|
||||
defence: 7_529,
|
||||
defenceMax: 7_800,
|
||||
wall: 7_819,
|
||||
wallMax: 8_100,
|
||||
},
|
||||
});
|
||||
expect(fixture.db.city.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
populationMax: true,
|
||||
agricultureMax: true,
|
||||
commerceMax: true,
|
||||
securityMax: true,
|
||||
trust: true,
|
||||
trade: true,
|
||||
defenceMax: true,
|
||||
wallMax: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
|
||||
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
|
||||
const fixture = createContext({ requestCommand });
|
||||
@@ -422,6 +508,12 @@ describe('battle-center general and user permissions', () => {
|
||||
id: 7,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
progression: {
|
||||
experienceLevel: 0,
|
||||
statExperience: { leadership: 0, strength: 0, intelligence: 0 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [0, 0, 0, 0, 0],
|
||||
},
|
||||
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [0, 0, 0, 0, 0] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -90,17 +90,24 @@ const buildContext = (options: {
|
||||
}
|
||||
return options.target?.id === where.id ? options.target : null;
|
||||
}),
|
||||
findMany: vi.fn(async () => [me, ...(options.target ? [options.target] : [])]),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === me.nationId ? { id: me.nationId, meta: options.nationMeta ?? {} } : null
|
||||
where.id === me.nationId ? { id: me.nationId, name: '테스트국', meta: options.nationMeta ?? {} } : null
|
||||
),
|
||||
},
|
||||
troop: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { troopLeaderId: number } }) =>
|
||||
options.troop?.troopLeaderId === where.troopLeaderId ? options.troop : null
|
||||
),
|
||||
findMany: vi.fn(async () =>
|
||||
options.troop ? [options.troop] : [{ troopLeaderId: me.id, nationId: me.nationId, name: '백마대' }]
|
||||
),
|
||||
},
|
||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) },
|
||||
worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) },
|
||||
generalTurn: { findMany: vi.fn(async () => []) },
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
@@ -127,6 +134,43 @@ const buildContext = (options: {
|
||||
};
|
||||
|
||||
describe('troop router permissions and mutations', () => {
|
||||
it('returns the Ref general progress inputs for same-nation troop popups', async () => {
|
||||
const me = buildGeneral({
|
||||
troopId: 1,
|
||||
meta: {
|
||||
explevel: 4,
|
||||
leadership_exp: 7,
|
||||
strength_exp: 8,
|
||||
intel_exp: 9,
|
||||
dex1: 350,
|
||||
dex2: 1_375,
|
||||
dex3: 3_500,
|
||||
dex4: 7_125,
|
||||
dex5: 12_650,
|
||||
},
|
||||
});
|
||||
const fixture = buildContext({ me, result: null });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).troop.getList()).resolves.toMatchObject({
|
||||
troops: [
|
||||
{
|
||||
members: [
|
||||
{
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
experience: 0,
|
||||
progression: {
|
||||
experienceLevel: 4,
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 12_650],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a troop only for the general owned by the authenticated user', async () => {
|
||||
const { context, requestCommand } = buildContext({
|
||||
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
|
||||
|
||||
Reference in New Issue
Block a user