merge: 최신 main을 국가 메시지 편집기 보완에 통합한다
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import {
|
||||
resolveGeneralTypeCall,
|
||||
resolveLeadershipBonus,
|
||||
resolveNextTurnMonthOffset,
|
||||
resolveRefreshScoreText,
|
||||
resolveRemainingMinutes,
|
||||
} from '../../services/generalBasicCardProjection.js';
|
||||
@@ -265,6 +266,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
dedication: true,
|
||||
age: true,
|
||||
turnTime: true,
|
||||
turnTick: true,
|
||||
recentWarTime: true,
|
||||
crewTypeId: true,
|
||||
personalCode: true,
|
||||
@@ -333,7 +335,14 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
})
|
||||
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
||||
ctx.db.worldState.findFirst({
|
||||
select: { currentYear: true, currentMonth: true, tickSeconds: true, config: true, meta: true },
|
||||
select: {
|
||||
currentYear: true,
|
||||
currentMonth: true,
|
||||
tickSeconds: true,
|
||||
lastTurnTick: true,
|
||||
config: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
officerCityId > 0
|
||||
? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } })
|
||||
@@ -517,9 +526,17 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
injury: general.injury,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
bill: getBillByLevel(dedicationLevel),
|
||||
age: general.age,
|
||||
retirementYear,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
nextTurnMonthOffset: resolveNextTurnMonthOffset({
|
||||
turnTime: general.turnTime,
|
||||
turnTick: general.turnTick,
|
||||
lastExecuted: parsedLastExecuted,
|
||||
lastTurnTick: worldState?.lastTurnTick,
|
||||
turnSeconds: worldState?.tickSeconds ?? 0,
|
||||
}),
|
||||
recentWar: general.recentWarTime?.toISOString() ?? null,
|
||||
defenceTrain: settings.defence_train,
|
||||
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
|
||||
|
||||
@@ -68,7 +68,10 @@ export const lobbyRouter = router({
|
||||
preopenAt: worldState.meta.preopenAt ?? '',
|
||||
turntime: worldState.meta.turntime ?? '',
|
||||
serverTime: gameTime.now.toISOString(),
|
||||
serverWallTime: gameTime.wallNow.toISOString(),
|
||||
clockMode: gameTime.mode ?? 'realtime',
|
||||
clockRunning: gameTime.running,
|
||||
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
|
||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||
npcMode: worldState.config.npcMode ?? 0,
|
||||
defaultStatTotal: asNumber(asRecord(rawConfig.stat).total, 165),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
import { LogCategory } from '@sammo-ts/logic';
|
||||
import { getBillByLevel, LogCategory } from '@sammo-ts/logic';
|
||||
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import {
|
||||
@@ -201,6 +201,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
},
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
bill: getBillByLevel(dedicationLevel),
|
||||
injury: general.injury,
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
|
||||
@@ -79,7 +79,7 @@ export const troopRouter = router({
|
||||
troopLeaderIds.length === 0
|
||||
? []
|
||||
: await ctx.db.generalTurn.findMany({
|
||||
where: { generalId: { in: troopLeaderIds } },
|
||||
where: { generalId: { in: troopLeaderIds }, turnIdx: { lt: 5 } },
|
||||
select: { generalId: true, turnIdx: true, actionCode: true },
|
||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||
});
|
||||
@@ -94,7 +94,8 @@ export const troopRouter = router({
|
||||
: 30;
|
||||
for (const turn of turns) {
|
||||
const list = reservedByLeader.get(turn.generalId) ?? [];
|
||||
list.push(turn.actionCode);
|
||||
// Ref 부대 편성은 앞쪽 슬롯이 집합인지 여부만 공개하고 다른 명령은 가립니다.
|
||||
list.push(turn.actionCode === 'che_집합' ? '집합' : '-');
|
||||
reservedByLeader.set(turn.generalId, list);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,25 @@ import type { DatabaseClient } from '../context.js';
|
||||
|
||||
export interface CurrentGameTime {
|
||||
now: Date;
|
||||
wallNow: Date;
|
||||
tick: number | null;
|
||||
mode: GameClockMode | null;
|
||||
running: boolean;
|
||||
startsAt: Date | null;
|
||||
dateToTick(date: Date): number | null;
|
||||
}
|
||||
|
||||
export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date()): Promise<CurrentGameTime> => {
|
||||
if (!db.worldState) {
|
||||
return { now: wallNow, tick: null, mode: null, dateToTick: () => null };
|
||||
return {
|
||||
now: wallNow,
|
||||
wallNow,
|
||||
tick: null,
|
||||
mode: null,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
dateToTick: () => null,
|
||||
};
|
||||
}
|
||||
const state = await db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
@@ -24,7 +35,15 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
},
|
||||
});
|
||||
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
|
||||
return { now: wallNow, tick: null, mode: null, dateToTick: () => null };
|
||||
return {
|
||||
now: wallNow,
|
||||
wallNow,
|
||||
tick: null,
|
||||
mode: null,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
dateToTick: () => null,
|
||||
};
|
||||
}
|
||||
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
|
||||
const storedTick = Number(state.clockTick);
|
||||
@@ -39,10 +58,14 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
turnSeconds: state.tickSeconds,
|
||||
});
|
||||
const tick = clock.nowTick(wallNow);
|
||||
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime();
|
||||
return {
|
||||
now: clock.tickToDate(tick),
|
||||
wallNow,
|
||||
tick,
|
||||
mode,
|
||||
running,
|
||||
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null,
|
||||
dateToTick: (date) => clock.dateToTick(date),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
|
||||
export interface GeneralBasicStats {
|
||||
leadership: number;
|
||||
strength: number;
|
||||
@@ -53,3 +55,41 @@ export const resolveRemainingMinutes = (
|
||||
}
|
||||
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
|
||||
};
|
||||
|
||||
export interface NextTurnMonthOffsetInput {
|
||||
turnTime: Date;
|
||||
turnTick?: bigint | number | null;
|
||||
lastExecuted: Date | null;
|
||||
lastTurnTick?: bigint | number | null;
|
||||
turnSeconds: number;
|
||||
}
|
||||
|
||||
const normalizeTick = (value: bigint | number | null | undefined): bigint | null => {
|
||||
if (typeof value === 'bigint') return value;
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return BigInt(value);
|
||||
return null;
|
||||
};
|
||||
|
||||
const turnBucket = (tick: bigint): bigint => {
|
||||
const ticksPerTurn = BigInt(GAME_TICKS_PER_TURN);
|
||||
const quotient = tick / ticksPerTurn;
|
||||
return tick % ticksPerTurn < 0 ? quotient - 1n : quotient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ref Command.GetReservedCommand cuts both clocks to a gameplay-turn bucket.
|
||||
* A general in the next bucket has already acted in the displayed world month,
|
||||
* so the first reserved command belongs to the following month.
|
||||
*/
|
||||
export const resolveNextTurnMonthOffset = (input: NextTurnMonthOffsetInput): 0 | 1 => {
|
||||
const turnTick = normalizeTick(input.turnTick);
|
||||
const lastTurnTick = normalizeTick(input.lastTurnTick);
|
||||
if (turnTick !== null && lastTurnTick !== null) {
|
||||
return turnBucket(turnTick) > turnBucket(lastTurnTick) ? 1 : 0;
|
||||
}
|
||||
|
||||
const turnTimeMs = input.turnTime.getTime();
|
||||
const lastExecutedMs = input.lastExecuted?.getTime() ?? Number.NaN;
|
||||
if (!Number.isFinite(turnTimeMs) || !Number.isFinite(lastExecutedMs) || input.turnSeconds <= 0) return 0;
|
||||
return turnTimeMs >= lastExecutedMs + input.turnSeconds * 1_000 ? 1 : 0;
|
||||
};
|
||||
|
||||
@@ -57,8 +57,11 @@ describe('auction worker clock-shift race', () => {
|
||||
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||
const time = {
|
||||
now,
|
||||
wallNow: now,
|
||||
tick: 36_000_000,
|
||||
mode: 'manual' as const,
|
||||
running: false,
|
||||
startsAt: null,
|
||||
dateToTick: () => 72_000_000,
|
||||
};
|
||||
const closeAt = new Date('2099-01-01T00:00:00.000Z');
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
import { loadCurrentGameTime } from '../src/services/gameClock.js';
|
||||
|
||||
const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient =>
|
||||
({
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'),
|
||||
clockTick: 36_000_000n,
|
||||
clockMode: mode,
|
||||
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
})),
|
||||
},
|
||||
}) as unknown as DatabaseClient;
|
||||
|
||||
describe('current game time projection', () => {
|
||||
it('holds a realtime clock at its persisted tick until the future wall anchor', async () => {
|
||||
const db = buildDatabase();
|
||||
|
||||
const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
|
||||
expect(preopen).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
wallNow: new Date('2026-08-21T10:30:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
mode: 'realtime',
|
||||
running: false,
|
||||
startsAt: new Date('2026-08-21T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
|
||||
expect(opened).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:05.000Z'),
|
||||
tick: 36_300_000,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a manual clock stopped without scheduling an automatic start', async () => {
|
||||
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
running: false,
|
||||
startsAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
resolveGeneralTypeCall,
|
||||
resolveLeadershipBonus,
|
||||
resolveNextTurnMonthOffset,
|
||||
resolveRefreshScoreText,
|
||||
resolveRemainingMinutes,
|
||||
} from '../src/services/generalBasicCardProjection.js';
|
||||
@@ -41,4 +42,55 @@ describe('general basic card Ref projection', () => {
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull();
|
||||
});
|
||||
|
||||
it('moves the first reserved month only after the general turn bucket has passed', () => {
|
||||
const lastExecuted = new Date('2026-08-13T00:00:00.000Z');
|
||||
const lastTurnTick = 36_000_000n * 11n;
|
||||
|
||||
expect(
|
||||
resolveNextTurnMonthOffset({
|
||||
turnTime: new Date('2026-08-13T00:07:00.000Z'),
|
||||
turnTick: lastTurnTick + 12_000_000n,
|
||||
lastExecuted,
|
||||
lastTurnTick,
|
||||
turnSeconds: 600,
|
||||
})
|
||||
).toBe(0);
|
||||
expect(
|
||||
resolveNextTurnMonthOffset({
|
||||
turnTime: new Date('2026-08-13T00:17:00.000Z'),
|
||||
turnTick: lastTurnTick + 48_000_000n,
|
||||
lastExecuted,
|
||||
lastTurnTick,
|
||||
turnSeconds: 600,
|
||||
})
|
||||
).toBe(1);
|
||||
expect(
|
||||
resolveNextTurnMonthOffset({
|
||||
turnTime: new Date('2026-08-13T00:10:00.000Z'),
|
||||
turnTick: 1n,
|
||||
lastExecuted,
|
||||
lastTurnTick: -1n,
|
||||
turnSeconds: 600,
|
||||
})
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the same boundary for legacy Date-only schedules', () => {
|
||||
const lastExecuted = new Date('2026-08-13T00:00:00.000Z');
|
||||
expect(
|
||||
resolveNextTurnMonthOffset({
|
||||
turnTime: new Date('2026-08-13T00:07:00.000Z'),
|
||||
lastExecuted,
|
||||
turnSeconds: 600,
|
||||
})
|
||||
).toBe(0);
|
||||
expect(
|
||||
resolveNextTurnMonthOffset({
|
||||
turnTime: new Date('2026-08-13T00:10:00.000Z'),
|
||||
lastExecuted,
|
||||
turnSeconds: 600,
|
||||
})
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,6 +297,7 @@ describe('in-game my information ownership', () => {
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 12_650],
|
||||
},
|
||||
bill: 1_000,
|
||||
},
|
||||
city: {
|
||||
population: 322_886,
|
||||
@@ -433,6 +434,7 @@ describe('in-game my information ownership', () => {
|
||||
stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 },
|
||||
},
|
||||
progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' },
|
||||
bill: 800,
|
||||
itemNames: { horse: '노새(+3)' },
|
||||
itemInfo: { horse: '통솔 +3' },
|
||||
},
|
||||
@@ -855,6 +857,7 @@ describe('battle-center general and user permissions', () => {
|
||||
statUpgradeLimit: 20,
|
||||
dex: [0, 0, 0, 0, 0],
|
||||
},
|
||||
bill: 600,
|
||||
serviceYears: 3,
|
||||
battleStats: {
|
||||
kills: 5,
|
||||
|
||||
@@ -68,6 +68,32 @@ describe('lobby season state', () => {
|
||||
|
||||
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
|
||||
expect(result.clockMode).toBe('manual');
|
||||
expect(result.clockRunning).toBe(false);
|
||||
expect(result.clockStartsAt).toBeNull();
|
||||
expect(new Date(result.serverWallTime).getTime()).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => {
|
||||
const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
|
||||
const result = await appRouter
|
||||
.createCaller(
|
||||
buildContext(
|
||||
{},
|
||||
{
|
||||
baseTime: new Date('2026-08-21T09:00:00.000Z'),
|
||||
tick: 36_000_000n,
|
||||
mode: 'realtime',
|
||||
wallAnchor,
|
||||
}
|
||||
)
|
||||
)
|
||||
.lobby.info();
|
||||
|
||||
expect(result.serverTime).toBe('2026-08-21T10:00:00.000Z');
|
||||
expect(result.clockMode).toBe('realtime');
|
||||
expect(result.clockRunning).toBe(false);
|
||||
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
|
||||
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
|
||||
});
|
||||
|
||||
it('preserves zero as the first official game index', async () => {
|
||||
|
||||
@@ -77,10 +77,12 @@ const buildContext = (options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
requestId?: string;
|
||||
transaction?: ReturnType<typeof vi.fn>;
|
||||
turns?: Array<{ generalId: number; turnIdx: number; actionCode: string }>;
|
||||
result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
|
||||
}) => {
|
||||
const me = options.me ?? buildGeneral();
|
||||
const requestCommand = vi.fn(async () => options.result);
|
||||
const generalTurnFindMany = vi.fn(async () => options.turns ?? []);
|
||||
const db = {
|
||||
...(options.transaction ? { $transaction: options.transaction } : {}),
|
||||
general: {
|
||||
@@ -110,7 +112,7 @@ const buildContext = (options: {
|
||||
},
|
||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) },
|
||||
worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) },
|
||||
generalTurn: { findMany: vi.fn(async () => []) },
|
||||
generalTurn: { findMany: generalTurnFindMany },
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
@@ -134,7 +136,7 @@ const buildContext = (options: {
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
return { context, requestCommand };
|
||||
return { context, requestCommand, generalTurnFindMany };
|
||||
};
|
||||
|
||||
describe('troop router permissions and mutations', () => {
|
||||
@@ -175,6 +177,30 @@ describe('troop router permissions and mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only the first five Ref-redacted troop command labels without exposing action codes', async () => {
|
||||
const fixture = buildContext({
|
||||
me: buildGeneral({ troopId: 1 }),
|
||||
turns: [
|
||||
{ generalId: 1, turnIdx: 0, actionCode: 'che_집합' },
|
||||
{ generalId: 1, turnIdx: 1, actionCode: 'che_이동' },
|
||||
{ generalId: 1, turnIdx: 2, actionCode: 'che_징병' },
|
||||
{ generalId: 1, turnIdx: 3, actionCode: '휴식' },
|
||||
{ generalId: 1, turnIdx: 4, actionCode: 'che_화계' },
|
||||
],
|
||||
result: null,
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(fixture.context).troop.getList();
|
||||
|
||||
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '-', '-', '-', '-']);
|
||||
expect(JSON.stringify(result.troops)).not.toContain('che_');
|
||||
expect(fixture.generalTurnFindMany).toHaveBeenCalledWith({
|
||||
where: { generalId: { in: [1] }, turnIdx: { lt: 5 } },
|
||||
select: { generalId: true, turnIdx: true, actionCode: true },
|
||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||
});
|
||||
});
|
||||
|
||||
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