Files
core2026/app/game-api/src/services/generalBasicCardProjection.ts
T
Hide_D 4c33c39506 fix: 개인 턴 경계에 맞춰 입력기 연월을 표시한다
Ref처럼 장수와 월 실행 시각을 logical tick bucket으로 비교해 이미 실행된 장수만 다음 달부터 예약 턴을 표시한다. 12월 연도 전환과 Date-only fallback을 단위 테스트 및 production Chromium으로 검증한다.
2026-08-21 17:17:23 +00:00

96 lines
3.9 KiB
TypeScript

import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
export interface GeneralBasicStats {
leadership: number;
strength: number;
intelligence: number;
}
export const resolveGeneralTypeCall = (stats: GeneralBasicStats, chiefStatMin: number, statGradeLevel = 5): string => {
const { leadership, strength, intelligence } = stats;
if (leadership < 40) {
if (strength + intelligence < 40) return '아둔';
if (intelligence >= chiefStatMin && strength < intelligence * 0.8) return '학자';
if (strength >= chiefStatMin && intelligence < strength * 0.8) return '장사';
return '명사';
}
const maxStat = Math.max(leadership, strength, intelligence);
const sumTwoStats = Math.min(leadership + strength, strength + intelligence, intelligence + leadership);
if (maxStat >= chiefStatMin + statGradeLevel && sumTwoStats >= maxStat * 1.7) return '만능';
if (strength >= chiefStatMin - statGradeLevel && intelligence < strength * 0.8) return '용장';
if (intelligence >= chiefStatMin - statGradeLevel && strength < intelligence * 0.8) return '명장';
if (leadership >= chiefStatMin - statGradeLevel && strength + intelligence < leadership) return '차장';
return '평범';
};
export const resolveLeadershipBonus = (officerLevel: number, nationLevel: number): number => {
if (officerLevel === 12) return nationLevel * 2;
if (officerLevel >= 5) return nationLevel;
return 0;
};
export const resolveRefreshScoreText = (score: number): string => {
if (score < 50) return '안함';
if (score < 100) return '무관심';
if (score < 200) return '보통';
if (score < 400) return '가끔';
if (score < 800) return '자주';
if (score < 1_600) return '열심';
if (score < 3_200) return '중독';
if (score < 6_400) return '폐인';
if (score < 12_800) return '경고';
return '헐...';
};
export const resolveRemainingMinutes = (
turnTime: Date,
lastExecuted: Date | null,
turnTermSeconds: number
): number | null => {
if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null;
let nextTurnMillis = turnTime.getTime();
if (nextTurnMillis < lastExecuted.getTime()) {
nextTurnMillis += turnTermSeconds * 1_000;
}
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;
};