토너먼트 준비
This commit is contained in:
@@ -8,6 +8,7 @@ export * from './util/JosaUtil.js';
|
||||
export * from './util/RNG.js';
|
||||
export * from './util/RandUtil.js';
|
||||
export * from './util/TestRNG.js';
|
||||
export * from './util/TournamentRNG.js';
|
||||
export * from './util/sha512.js';
|
||||
export * from './util/parse.js';
|
||||
export * from './turnDaemon/types.js';
|
||||
|
||||
@@ -85,7 +85,17 @@ export type TurnDaemonCommand =
|
||||
destGeneralId: number;
|
||||
destCityId: number;
|
||||
officerLevel: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'tournamentRefund';
|
||||
requestId?: string;
|
||||
bettingId?: number;
|
||||
reason?: string;
|
||||
refunds: Array<{
|
||||
generalId: number;
|
||||
amount: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TurnDaemonCommandResult =
|
||||
| {
|
||||
@@ -132,7 +142,21 @@ export type TurnDaemonCommandResult =
|
||||
| { type: 'dropItem'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'changePermission'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'kick'; ok: boolean; generalId: number; reason?: string }
|
||||
| { type: 'appoint'; ok: boolean; generalId: number; reason?: string };
|
||||
| { type: 'appoint'; ok: boolean; generalId: number; reason?: string }
|
||||
| {
|
||||
type: 'tournamentRefund';
|
||||
ok: true;
|
||||
bettingId?: number;
|
||||
processed: number;
|
||||
missing: number;
|
||||
totalRefund: number;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentRefund';
|
||||
ok: false;
|
||||
bettingId?: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type TurnDaemonEvent =
|
||||
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { LiteHashDRBG } from './LiteHashDRBG.js';
|
||||
import { RandUtil } from './RandUtil.js';
|
||||
|
||||
export interface TournamentRngContext {
|
||||
openYear: number;
|
||||
openMonth: number;
|
||||
stage: number;
|
||||
phase: number;
|
||||
matchIndex: number;
|
||||
participantIndex: number;
|
||||
gameIndex?: number;
|
||||
extraSeed?: string | number;
|
||||
}
|
||||
|
||||
const buildTournamentSeedKey = (baseSeed: string, context: TournamentRngContext): string => {
|
||||
const gameIndex = context.gameIndex !== undefined ? `|game:${context.gameIndex}` : '';
|
||||
const extraSeed = context.extraSeed !== undefined ? `|extra:${context.extraSeed}` : '';
|
||||
return [
|
||||
'Tournament',
|
||||
`open:${context.openYear}-${context.openMonth}`,
|
||||
`stage:${context.stage}`,
|
||||
`phase:${context.phase}`,
|
||||
`match:${context.matchIndex}`,
|
||||
`participant:${context.participantIndex}`,
|
||||
gameIndex,
|
||||
extraSeed,
|
||||
`seed:${baseSeed}`,
|
||||
]
|
||||
.filter((value) => value.length > 0)
|
||||
.join('|');
|
||||
};
|
||||
|
||||
export const createTournamentRng = (baseSeed: string, context: TournamentRngContext): RandUtil =>
|
||||
new RandUtil(LiteHashDRBG.build(buildTournamentSeedKey(baseSeed, context)));
|
||||
|
||||
export const createTournamentSeedKey = (baseSeed: string, context: TournamentRngContext): string =>
|
||||
buildTournamentSeedKey(baseSeed, context);
|
||||
@@ -220,6 +220,19 @@ model LogEntry {
|
||||
@@map("log_entry")
|
||||
}
|
||||
|
||||
model ErrorLog {
|
||||
id Int @id @default(autoincrement())
|
||||
category String
|
||||
source String? @map("source")
|
||||
message String
|
||||
trace String?
|
||||
context Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([category, id])
|
||||
@@map("error_log")
|
||||
}
|
||||
|
||||
model InheritancePoint {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { GamePrisma, GamePrismaClient } from './gamePrisma.js';
|
||||
|
||||
export interface ErrorLogQueryOptions {
|
||||
limit?: number;
|
||||
beforeId?: number;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface ErrorLogCreateInput {
|
||||
category: string;
|
||||
message: string;
|
||||
source?: string;
|
||||
trace?: string;
|
||||
context?: GamePrisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export interface ErrorLogView {
|
||||
id: number;
|
||||
category: string;
|
||||
source: string | null;
|
||||
message: string;
|
||||
trace: string | null;
|
||||
context: GamePrisma.JsonValue;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const buildPaginationWhere = (
|
||||
base: GamePrisma.ErrorLogWhereInput,
|
||||
options: ErrorLogQueryOptions
|
||||
): GamePrisma.ErrorLogWhereInput => {
|
||||
if (options.beforeId) {
|
||||
return {
|
||||
...base,
|
||||
id: { lt: options.beforeId },
|
||||
};
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
const buildFindArgs = (
|
||||
where: GamePrisma.ErrorLogWhereInput,
|
||||
options: ErrorLogQueryOptions
|
||||
): GamePrisma.ErrorLogFindManyArgs => ({
|
||||
where: buildPaginationWhere(where, options),
|
||||
orderBy: { id: 'desc' },
|
||||
take: options.limit ?? 50,
|
||||
});
|
||||
|
||||
export class ErrorLogRepository {
|
||||
constructor(private readonly prisma: GamePrismaClient) {}
|
||||
|
||||
async createErrorLog(input: ErrorLogCreateInput): Promise<ErrorLogView> {
|
||||
return this.prisma.errorLog.create({
|
||||
data: {
|
||||
category: input.category,
|
||||
source: input.source ?? null,
|
||||
message: input.message,
|
||||
trace: input.trace ?? null,
|
||||
context: input.context ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listErrorLogs(options: ErrorLogQueryOptions = {}): Promise<ErrorLogView[]> {
|
||||
const base: GamePrisma.ErrorLogWhereInput = options.category ? { category: options.category } : {};
|
||||
return this.prisma.errorLog.findMany(buildFindArgs(base, options));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
} from './gatewayPrisma.js';
|
||||
export type { GatewayPrismaClient } from './gatewayPrisma.js';
|
||||
export * from './db.js';
|
||||
export * from './errorLogRepository.js';
|
||||
export * from './logRepository.js';
|
||||
export * from './redis.js';
|
||||
export * from './turnEngineDb.js';
|
||||
|
||||
@@ -15,5 +15,6 @@ export * from './ports/worldSnapshot.js';
|
||||
export * from './scenario/index.js';
|
||||
export * from './triggers/index.js';
|
||||
export * from './turn/index.js';
|
||||
export * from './tournament/index.js';
|
||||
export * from './world/index.js';
|
||||
export * from './war/index.js';
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { createTournamentRng } from '@sammo-ts/common';
|
||||
|
||||
import {
|
||||
TournamentType,
|
||||
type TournamentBattleInput,
|
||||
type TournamentBattleResult,
|
||||
type TournamentBattleLogEntry,
|
||||
} from './types.js';
|
||||
|
||||
const clampPositive = (value: number, fallback = 0): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const round = (value: number): number => Math.round(value);
|
||||
|
||||
const getLogRatio = (lvl1: number, lvl2: number): number => {
|
||||
if (lvl1 >= lvl2) {
|
||||
return 1 + Math.log10(1 + lvl1 - lvl2) / 10;
|
||||
}
|
||||
return 1 - Math.log10(1 + lvl2 - lvl1) / 10;
|
||||
};
|
||||
|
||||
const resolveTournamentStat = (type: TournamentType, stats: { leadership: number; strength: number; intel: number }): number => {
|
||||
switch (type) {
|
||||
case TournamentType.LEADERSHIP:
|
||||
return stats.leadership;
|
||||
case TournamentType.STRENGTH:
|
||||
return stats.strength;
|
||||
case TournamentType.INTEL:
|
||||
return stats.intel;
|
||||
case TournamentType.TOTAL:
|
||||
default:
|
||||
return (stats.leadership + stats.strength + stats.intel) * (7 / 15);
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveTournamentBattle = (input: TournamentBattleInput): TournamentBattleResult => {
|
||||
const { attacker, defender, battleType, context } = input;
|
||||
const attackerStat = resolveTournamentStat(input.type, attacker.stats);
|
||||
const defenderStat = resolveTournamentStat(input.type, defender.stats);
|
||||
|
||||
const rng = createTournamentRng(input.baseSeed, {
|
||||
openYear: context.openYear,
|
||||
openMonth: context.openMonth,
|
||||
stage: context.stage,
|
||||
phase: context.phase,
|
||||
matchIndex: context.matchIndex,
|
||||
participantIndex: 0,
|
||||
extraSeed: `${attacker.id}:${defender.id}`,
|
||||
});
|
||||
|
||||
const energyBaseAttacker = round(attackerStat * getLogRatio(attacker.level, defender.level) * 10);
|
||||
const energyBaseDefender = round(defenderStat * getLogRatio(attacker.level, defender.level) * 10);
|
||||
let energyAttacker = energyBaseAttacker;
|
||||
let energyDefender = energyBaseDefender;
|
||||
|
||||
const maxTurns = battleType === 0 ? 10 : 100;
|
||||
const log: string[] = [];
|
||||
const logEntries: TournamentBattleLogEntry[] = [];
|
||||
|
||||
log.push(`<S>●</> <Y>${attacker.name}</> <C>(${energyBaseAttacker})</> vs <C>(${energyBaseDefender})</> <Y>${defender.name}</>`);
|
||||
|
||||
let totalDamageAttacker = 0;
|
||||
let totalDamageDefender = 0;
|
||||
let selected = 2;
|
||||
|
||||
for (let phase = 1; phase <= maxTurns; phase += 1) {
|
||||
const baseDamageAttacker = round(defenderStat * (rng.nextInt(21) + 90) / 130);
|
||||
const baseDamageDefender = round(attackerStat * (rng.nextInt(21) + 90) / 130);
|
||||
let damageAttacker = baseDamageAttacker;
|
||||
let damageDefender = baseDamageDefender;
|
||||
|
||||
if (attackerStat >= rng.nextInt(100)) {
|
||||
damageDefender += round(attackerStat * (rng.nextInt(41) + 10) / 130);
|
||||
}
|
||||
if (defenderStat >= rng.nextInt(100)) {
|
||||
damageAttacker += round(defenderStat * (rng.nextInt(41) + 10) / 130);
|
||||
}
|
||||
|
||||
let criticalAttacker = false;
|
||||
let criticalDefender = false;
|
||||
let factorAttacker = 1;
|
||||
let factorDefender = 1;
|
||||
|
||||
if (energyBaseAttacker / 5 > energyAttacker && damageAttacker > damageDefender && attackerStat >= rng.nextInt(300)) {
|
||||
factorDefender = round((rng.nextInt(301) + 200) / 100);
|
||||
criticalAttacker = true;
|
||||
log.push(`<S>●</> <Y>${attacker.name}</>의 분노의 일격!`);
|
||||
}
|
||||
if (energyBaseDefender / 5 > energyDefender && damageDefender > damageAttacker && defenderStat >= rng.nextInt(300)) {
|
||||
factorAttacker = round((rng.nextInt(301) + 200) / 100);
|
||||
criticalDefender = true;
|
||||
log.push(`<S>●</> <Y>${defender.name}</>의 분노의 일격!`);
|
||||
}
|
||||
|
||||
damageAttacker = round(damageAttacker * factorAttacker);
|
||||
damageDefender = round(damageDefender * factorDefender);
|
||||
|
||||
if (phase === 1) {
|
||||
if (attackerStat * 0.9 > defenderStat && attackerStat >= rng.nextInt(400)) {
|
||||
damageDefender += round(attackerStat * (rng.nextInt(31) + 70) / 100);
|
||||
}
|
||||
if (defenderStat * 0.9 > attackerStat && defenderStat >= rng.nextInt(400)) {
|
||||
damageAttacker += round(defenderStat * (rng.nextInt(31) + 70) / 100);
|
||||
}
|
||||
} else {
|
||||
if (!criticalAttacker && attackerStat >= rng.nextInt(1000)) {
|
||||
damageDefender += round(attackerStat * (rng.nextInt(31) + 20) / 100);
|
||||
}
|
||||
if (!criticalDefender && defenderStat >= rng.nextInt(1000)) {
|
||||
damageAttacker += round(defenderStat * (rng.nextInt(31) + 20) / 100);
|
||||
}
|
||||
}
|
||||
|
||||
damageAttacker = clampPositive(round(damageAttacker), 0);
|
||||
damageDefender = clampPositive(round(damageDefender), 0);
|
||||
|
||||
energyAttacker -= damageAttacker;
|
||||
energyDefender -= damageDefender;
|
||||
|
||||
totalDamageAttacker += damageAttacker;
|
||||
totalDamageDefender += damageDefender;
|
||||
|
||||
const entryText =
|
||||
`<S>●</> ${String(phase).padStart(2, '0')}合 : ` +
|
||||
`<C>${String(round(energyAttacker)).padStart(3, '0')}</>` +
|
||||
`<span class="ev_highlight">(-${String(round(damageAttacker)).padStart(3, '0')})</span>` +
|
||||
' vs ' +
|
||||
`<span class="ev_highlight">(-${String(round(damageDefender)).padStart(3, '0')})</span>` +
|
||||
`<C>${String(round(energyDefender)).padStart(3, '0')}</>`;
|
||||
|
||||
log.push(entryText);
|
||||
logEntries.push({
|
||||
phase,
|
||||
attackerEnergy: round(energyAttacker),
|
||||
defenderEnergy: round(energyDefender),
|
||||
attackerDamage: damageAttacker,
|
||||
defenderDamage: damageDefender,
|
||||
text: entryText,
|
||||
});
|
||||
|
||||
if (energyAttacker <= 0 && energyDefender <= 0) {
|
||||
if (battleType === 0) {
|
||||
selected = 2;
|
||||
break;
|
||||
}
|
||||
selected = energyAttacker > energyDefender ? 0 : 1;
|
||||
break;
|
||||
}
|
||||
if (energyAttacker <= 0) {
|
||||
selected = 1;
|
||||
break;
|
||||
}
|
||||
if (energyDefender <= 0) {
|
||||
selected = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected === 0) {
|
||||
log.push(`<S>●</> <Y>${attacker.name}</> <S>승리</>!`);
|
||||
} else if (selected === 1) {
|
||||
log.push(`<S>●</> <Y>${defender.name}</> <S>승리</>!`);
|
||||
} else {
|
||||
log.push(`<S>●</> <Y>${attacker.name}</> <S>무승부</>!`);
|
||||
}
|
||||
|
||||
const winnerId = selected === 0 ? attacker.id : selected === 1 ? defender.id : null;
|
||||
const loserId = selected === 0 ? defender.id : selected === 1 ? attacker.id : null;
|
||||
|
||||
return {
|
||||
winnerId,
|
||||
loserId,
|
||||
draw: selected === 2,
|
||||
rounds: logEntries.length,
|
||||
totalDamage: {
|
||||
attacker: totalDamageAttacker,
|
||||
defender: totalDamageDefender,
|
||||
},
|
||||
log,
|
||||
logEntries,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types.js';
|
||||
export * from './battle.js';
|
||||
@@ -0,0 +1,60 @@
|
||||
export const TournamentType = {
|
||||
TOTAL: 0,
|
||||
LEADERSHIP: 1,
|
||||
STRENGTH: 2,
|
||||
INTEL: 3,
|
||||
} as const;
|
||||
|
||||
export type TournamentType = (typeof TournamentType)[keyof typeof TournamentType];
|
||||
|
||||
export interface TournamentStats {
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
}
|
||||
|
||||
export interface TournamentParticipant {
|
||||
id: number;
|
||||
name: string;
|
||||
stats: TournamentStats;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface TournamentBattleLogEntry {
|
||||
phase: number;
|
||||
attackerEnergy: number;
|
||||
defenderEnergy: number;
|
||||
attackerDamage: number;
|
||||
defenderDamage: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TournamentBattleResult {
|
||||
winnerId: number | null;
|
||||
loserId: number | null;
|
||||
draw: boolean;
|
||||
rounds: number;
|
||||
totalDamage: {
|
||||
attacker: number;
|
||||
defender: number;
|
||||
};
|
||||
log: string[];
|
||||
logEntries: TournamentBattleLogEntry[];
|
||||
}
|
||||
|
||||
export interface TournamentBattleContext {
|
||||
openYear: number;
|
||||
openMonth: number;
|
||||
stage: number;
|
||||
phase: number;
|
||||
matchIndex: number;
|
||||
}
|
||||
|
||||
export interface TournamentBattleInput {
|
||||
type: TournamentType;
|
||||
battleType: 0 | 1; // 0: 승무패, 1: 승패
|
||||
attacker: TournamentParticipant;
|
||||
defender: TournamentParticipant;
|
||||
context: TournamentBattleContext;
|
||||
baseSeed: string;
|
||||
}
|
||||
Reference in New Issue
Block a user