Files
asciinevrdie/lib/src/core/engine/progress_service.dart
JiWoong Sul d07a0c5554 style: dart format 적용
- 전체 Dart 소스 및 테스트 파일 포매팅 통일
- trailing comma, 줄바꿈, 인덴트 정리
2026-02-13 16:08:23 +09:00

1253 lines
40 KiB
Dart

import 'dart:math' as math;
import 'package:asciineverdie/data/class_data.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/data/race_data.dart';
import 'package:asciineverdie/src/core/model/class_traits.dart';
import 'package:asciineverdie/src/core/animation/monster_size.dart';
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/engine/combat_tick_service.dart';
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
import 'package:asciineverdie/src/core/engine/market_service.dart';
import 'package:asciineverdie/src/core/engine/potion_service.dart';
import 'package:asciineverdie/src/core/engine/reward_service.dart';
import 'package:asciineverdie/src/core/engine/skill_service.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/combat_state.dart';
import 'package:asciineverdie/src/core/model/combat_stats.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/util/balance_constants.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
class ProgressTickResult {
const ProgressTickResult({
required this.state,
this.leveledUp = false,
this.completedQuest = false,
this.completedAct = false,
this.playerDied = false,
this.gameComplete = false,
});
final GameState state;
final bool leveledUp;
final bool completedQuest;
final bool completedAct;
/// 플레이어 사망 여부 (Phase 4)
final bool playerDied;
/// 게임 클리어 여부 (Act V 완료)
final bool gameComplete;
bool get shouldAutosave =>
leveledUp || completedQuest || completedAct || playerDied || gameComplete;
}
/// Drives quest/plot/task progression by applying queued actions and rewards.
class ProgressService {
ProgressService({
required this.config,
required this.mutations,
required this.rewards,
});
final PqConfig config;
final GameMutations mutations;
final RewardService rewards;
/// 새 게임 초기화 (원본 GoButtonClick, Main.pas:741-767)
/// Prologue 태스크들을 큐에 추가하고 첫 태스크 시작
GameState initializeNewGame(GameState state) {
// 초기 큐 설정 - ASCII NEVER DIE 세계관 프롤로그 (l10n 지원)
final prologueTexts = l10n.prologueTexts;
final initialQueue = <QueueEntry>[
QueueEntry(
kind: QueueKind.task,
durationMillis: 10 * 1000,
caption: prologueTexts[0],
taskType: TaskType.load,
),
QueueEntry(
kind: QueueKind.task,
durationMillis: 6 * 1000,
caption: prologueTexts[1],
taskType: TaskType.load,
),
QueueEntry(
kind: QueueKind.task,
durationMillis: 6 * 1000,
caption: prologueTexts[2],
taskType: TaskType.load,
),
QueueEntry(
kind: QueueKind.task,
durationMillis: 4 * 1000,
caption: prologueTexts[3],
taskType: TaskType.load,
),
QueueEntry(
kind: QueueKind.plot,
durationMillis: 2 * 1000,
caption: l10n.taskCompiling,
taskType: TaskType.plot,
),
];
// 첫 번째 태스크 시작 (원본 752줄)
final taskResult = pq_logic.startTask(
state.progress,
l10n.taskCompiling,
2 * 1000,
);
// ExpBar 초기화 (원본 743-746줄)
final expBar = ProgressBarState(
position: 0,
max: ExpConstants.requiredExp(1),
);
// PlotBar 초기화 - Prologue 5분 (300초)
final plotBar = const ProgressBarState(position: 0, max: 300);
final progress = taskResult.progress.copyWith(
exp: expBar,
plot: plotBar,
currentTask: TaskInfo(
caption: '${l10n.taskCompiling}...',
type: TaskType.load,
),
plotStageCount: 1, // Prologue
questCount: 0,
plotHistory: [
HistoryEntry(caption: l10n.taskPrologue, isComplete: false),
],
questHistory: const [],
);
return _recalculateEncumbrance(
state.copyWith(
progress: progress,
queue: QueueState(entries: initialQueue),
),
);
}
/// Starts a task and tags its type (kill, plot, load, neutral).
GameState startTask(
GameState state, {
required String caption,
required int durationMillis,
TaskType taskType = TaskType.neutral,
}) {
final taskResult = pq_logic.startTask(
state.progress,
caption,
durationMillis,
);
final progress = taskResult.progress.copyWith(
currentTask: TaskInfo(caption: taskResult.caption, type: taskType),
);
return state.copyWith(progress: progress);
}
/// Tick the timer loop (equivalent to Timer1Timer in the original code).
ProgressTickResult tick(GameState state, int elapsedMillis) {
final int clamped = elapsedMillis.clamp(0, 10000).toInt();
// 1. 스킬 시스템 업데이트 (시간, 버프, MP 회복)
var nextState = _updateSkillSystem(state, clamped);
var progress = nextState.progress;
var queue = nextState.queue;
// 2. 태스크 바 진행 중이면 전투 틱 처리
if (progress.task.position < progress.task.max) {
return _processTaskInProgress(nextState, clamped);
}
// 3. 태스크 완료 처리
final gain = progress.currentTask.type == TaskType.kill;
final incrementSeconds = progress.task.max ~/ 1000;
final int monsterExpReward =
progress.currentCombat?.monsterStats.expReward ?? 0;
var leveledUp = false;
var questDone = false;
var actDone = false;
var gameComplete = false;
// 4. 킬 태스크 완료 처리
if (gain) {
final killResult = _handleKillTaskCompletion(nextState, progress, queue);
if (killResult.earlyReturn != null) return killResult.earlyReturn!;
nextState = killResult.state;
progress = killResult.progress;
queue = killResult.queue;
}
// 5. 시장/판매/구매 태스크 완료 처리
final marketResult = _handleMarketTaskCompletion(
nextState,
progress,
queue,
);
if (marketResult.earlyReturn != null) return marketResult.earlyReturn!;
nextState = marketResult.state;
progress = marketResult.progress;
queue = marketResult.queue;
// 6. 경험치/레벨업 처리
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
final expResult = _handleExpGain(nextState, progress, monsterExpReward);
nextState = expResult.state;
progress = expResult.progress;
leveledUp = expResult.leveledUp;
}
// 7. 퀘스트 진행 처리
final questResult = _handleQuestProgress(
nextState,
progress,
queue,
gain,
incrementSeconds,
);
nextState = questResult.state;
progress = questResult.progress;
queue = questResult.queue;
questDone = questResult.completed;
// 8. 플롯 진행 및 Act Boss 소환 처리
progress = _handlePlotProgress(nextState, progress, gain, incrementSeconds);
// 9. 다음 태스크 디큐/생성
final dequeueResult = _handleTaskDequeue(nextState, progress, queue);
nextState = dequeueResult.state;
progress = dequeueResult.progress;
queue = dequeueResult.queue;
actDone = dequeueResult.actDone;
gameComplete = dequeueResult.gameComplete;
nextState = _recalculateEncumbrance(
nextState.copyWith(progress: progress, queue: queue),
);
return ProgressTickResult(
state: nextState,
leveledUp: leveledUp,
completedQuest: questDone,
completedAct: actDone,
gameComplete: gameComplete,
);
}
/// 스킬 시스템 업데이트 (시간, 버프 정리, MP 회복)
GameState _updateSkillSystem(GameState state, int elapsedMs) {
final skillService = SkillService(rng: state.rng);
var skillSystem = skillService.updateElapsedTime(
state.skillSystem,
elapsedMs,
);
skillSystem = skillService.cleanupExpiredBuffs(skillSystem);
var nextState = state.copyWith(skillSystem: skillSystem);
// 비전투 시 MP 회복
final isInCombat =
state.progress.currentTask.type == TaskType.kill &&
state.progress.currentCombat != null &&
state.progress.currentCombat!.isActive;
if (!isInCombat && nextState.stats.mp < nextState.stats.mpMax) {
final mpRegen = skillService.calculateMpRegen(
elapsedMs: elapsedMs,
isInCombat: false,
wis: nextState.stats.wis,
);
if (mpRegen > 0) {
final newMp = (nextState.stats.mp + mpRegen).clamp(
0,
nextState.stats.mpMax,
);
nextState = nextState.copyWith(
stats: nextState.stats.copyWith(mpCurrent: newMp),
);
}
}
return nextState;
}
/// 태스크 진행 중 처리 (전투 틱 포함)
ProgressTickResult _processTaskInProgress(GameState state, int elapsedMs) {
var progress = state.progress;
final uncapped = progress.task.position + elapsedMs;
final int newTaskPos = uncapped > progress.task.max
? progress.task.max
: uncapped;
var updatedCombat = progress.currentCombat;
var updatedSkillSystem = state.skillSystem;
var updatedPotionInventory = state.potionInventory;
var nextState = state;
// 킬 태스크 중 전투 진행
if (progress.currentTask.type == TaskType.kill &&
updatedCombat != null &&
updatedCombat.isActive) {
final combatTickService = CombatTickService(rng: state.rng);
final combatResult = combatTickService.processTick(
state: state,
combat: updatedCombat,
skillSystem: updatedSkillSystem,
elapsedMs: elapsedMs,
);
updatedCombat = combatResult.combat;
updatedSkillSystem = combatResult.skillSystem;
if (combatResult.potionInventory != null) {
updatedPotionInventory = combatResult.potionInventory!;
}
// 플레이어 사망 체크
if (!updatedCombat.playerStats.isAlive) {
final monsterName = updatedCombat.monsterStats.name;
nextState = _processPlayerDeath(
state,
killerName: monsterName,
cause: DeathCause.monster,
);
return ProgressTickResult(state: nextState, playerDied: true);
}
}
progress = progress.copyWith(
task: progress.task.copyWith(position: newTaskPos),
currentCombat: updatedCombat,
);
nextState = _recalculateEncumbrance(
state.copyWith(
progress: progress,
skillSystem: updatedSkillSystem,
potionInventory: updatedPotionInventory,
),
);
return ProgressTickResult(state: nextState);
}
/// 킬 태스크 완료 처리 (HP 회복, 전리품, 보스 처리)
({
GameState state,
ProgressState progress,
QueueState queue,
ProgressTickResult? earlyReturn,
})
_handleKillTaskCompletion(
GameState state,
ProgressState progress,
QueueState queue,
) {
var nextState = state;
// 전투 후 HP 회복
final combat = progress.currentCombat;
if (combat != null && combat.isActive) {
final remainingHp = combat.playerStats.hpCurrent;
final maxHp = combat.playerStats.hpMax;
final conBonus = nextState.stats.con ~/ 2;
var healAmount = (maxHp * 0.5).round() + conBonus;
final klass = ClassData.findById(nextState.traits.classId);
if (klass != null) {
final postCombatHealRate = klass.getPassiveValue(
ClassPassiveType.postCombatHeal,
);
if (postCombatHealRate > 0) {
healAmount += (maxHp * postCombatHealRate).round();
}
}
final newHp = (remainingHp + healAmount).clamp(0, maxHp);
nextState = nextState.copyWith(
stats: nextState.stats.copyWith(hpCurrent: newHp),
);
}
// 전리품 획득
final lootResult = _winLoot(nextState);
nextState = lootResult.state;
// 물약 드랍 로그 추가
var combatForReset = progress.currentCombat;
if (lootResult.droppedPotion != null && combatForReset != null) {
final potionDropEvent = CombatEvent.potionDrop(
timestamp: nextState.skillSystem.elapsedMs,
potionName: lootResult.droppedPotion!.name,
isHp: lootResult.droppedPotion!.isHpPotion,
);
final updatedEvents = [...combatForReset.recentEvents, potionDropEvent];
combatForReset = combatForReset.copyWith(
recentEvents: updatedEvents.length > 10
? updatedEvents.sublist(updatedEvents.length - 10)
: updatedEvents,
);
progress = progress.copyWith(currentCombat: combatForReset);
}
// Boss 승리 처리
if (progress.pendingActCompletion) {
final cinematicEntries = pq_logic.interplotCinematic(
config,
nextState.rng,
nextState.traits.level,
progress.plotStageCount,
);
queue = QueueState(entries: [...queue.entries, ...cinematicEntries]);
progress = progress.copyWith(
currentCombat: null,
monstersKilled: progress.monstersKilled + 1,
pendingActCompletion: false,
);
} else {
progress = progress.copyWith(
currentCombat: null,
monstersKilled: progress.monstersKilled + 1,
);
}
nextState = nextState.copyWith(progress: progress, queue: queue);
// 최종 보스 처치 체크
if (progress.finalBossState == FinalBossState.fighting) {
progress = progress.copyWith(finalBossState: FinalBossState.defeated);
nextState = nextState.copyWith(progress: progress);
final actResult = completeAct(nextState);
return (
state: actResult.state,
progress: actResult.state.progress,
queue: actResult.state.queue,
earlyReturn: ProgressTickResult(
state: actResult.state,
completedAct: true,
gameComplete: true,
),
);
}
return (
state: nextState,
progress: progress,
queue: queue,
earlyReturn: null,
);
}
/// 시장/판매/구매 태스크 완료 처리
({
GameState state,
ProgressState progress,
QueueState queue,
ProgressTickResult? earlyReturn,
})
_handleMarketTaskCompletion(
GameState state,
ProgressState progress,
QueueState queue,
) {
var nextState = state;
final marketService = MarketService(rng: state.rng);
final taskType = progress.currentTask.type;
if (taskType == TaskType.buying) {
nextState = marketService.completeBuying(nextState);
progress = nextState.progress;
} else if (taskType == TaskType.market || taskType == TaskType.sell) {
final sellResult = marketService.processSell(nextState);
nextState = sellResult.state;
progress = nextState.progress;
queue = nextState.queue;
if (sellResult.continuesSelling) {
nextState = _recalculateEncumbrance(
nextState.copyWith(progress: progress, queue: queue),
);
return (
state: nextState,
progress: progress,
queue: queue,
earlyReturn: ProgressTickResult(state: nextState),
);
}
}
return (
state: nextState,
progress: progress,
queue: queue,
earlyReturn: null,
);
}
/// 경험치 획득 및 레벨업 처리
({GameState state, ProgressState progress, bool leveledUp}) _handleExpGain(
GameState state,
ProgressState progress,
int monsterExpReward,
) {
var nextState = state;
var leveledUp = false;
final race = RaceData.findById(nextState.traits.raceId);
final expMultiplier = race?.expMultiplier ?? 1.0;
final adjustedExp = (monsterExpReward * expMultiplier).round();
final newExpPos = progress.exp.position + adjustedExp;
if (newExpPos >= progress.exp.max) {
final overflowExp = newExpPos - progress.exp.max;
nextState = _levelUp(nextState);
leveledUp = true;
progress = nextState.progress;
if (overflowExp > 0 && nextState.traits.level < 100) {
progress = progress.copyWith(
exp: progress.exp.copyWith(position: overflowExp),
);
}
} else {
progress = progress.copyWith(
exp: progress.exp.copyWith(position: newExpPos),
);
}
return (state: nextState, progress: progress, leveledUp: leveledUp);
}
/// 퀘스트 진행 처리
({GameState state, ProgressState progress, QueueState queue, bool completed})
_handleQuestProgress(
GameState state,
ProgressState progress,
QueueState queue,
bool gain,
int incrementSeconds,
) {
var nextState = state;
var questDone = false;
final canQuestProgress =
gain &&
progress.plotStageCount > 1 &&
progress.questCount > 0 &&
progress.quest.max > 0;
if (canQuestProgress) {
if (progress.quest.position + incrementSeconds >= progress.quest.max) {
nextState = completeQuest(nextState);
questDone = true;
progress = nextState.progress;
queue = nextState.queue;
} else {
progress = progress.copyWith(
quest: progress.quest.copyWith(
position: progress.quest.position + incrementSeconds,
),
);
}
}
return (
state: nextState,
progress: progress,
queue: queue,
completed: questDone,
);
}
/// 플롯 진행 및 Act Boss 소환 처리
ProgressState _handlePlotProgress(
GameState state,
ProgressState progress,
bool gain,
int incrementSeconds,
) {
if (gain &&
progress.plot.max > 0 &&
progress.plot.position >= progress.plot.max &&
!progress.pendingActCompletion) {
final actProgressionService = ActProgressionService(config: config);
final actBoss = actProgressionService.createActBoss(state);
return progress.copyWith(
plot: progress.plot.copyWith(position: 0),
currentCombat: actBoss,
pendingActCompletion: true,
);
} else if (progress.currentTask.type != TaskType.load &&
progress.plot.max > 0 &&
!progress.pendingActCompletion) {
final uncappedPlot = progress.plot.position + incrementSeconds;
final int newPlotPos = uncappedPlot > progress.plot.max
? progress.plot.max
: uncappedPlot;
return progress.copyWith(
plot: progress.plot.copyWith(position: newPlotPos),
);
}
return progress;
}
/// 태스크 디큐 및 생성 처리
({
GameState state,
ProgressState progress,
QueueState queue,
bool actDone,
bool gameComplete,
})
_handleTaskDequeue(
GameState state,
ProgressState progress,
QueueState queue,
) {
var nextState = state;
var actDone = false;
var gameComplete = false;
final dq = pq_logic.dequeue(progress, queue);
if (dq != null) {
progress = dq.progress.copyWith(
currentTask: TaskInfo(caption: dq.caption, type: dq.taskType),
);
queue = dq.queue;
if (dq.kind == QueueKind.plot) {
nextState = nextState.copyWith(progress: progress, queue: queue);
final actResult = completeAct(nextState);
nextState = actResult.state;
actDone = true;
gameComplete = actResult.gameComplete;
progress = nextState.progress;
queue = nextState.queue;
}
} else {
nextState = nextState.copyWith(progress: progress, queue: queue);
final newTaskResult = _generateNextTask(nextState);
progress = newTaskResult.progress;
queue = newTaskResult.queue;
}
return (
state: nextState,
progress: progress,
queue: queue,
actDone: actDone,
gameComplete: gameComplete,
);
}
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
({ProgressState progress, QueueState queue}) _generateNextTask(
GameState state,
) {
var progress = state.progress;
final queue = state.queue;
final oldTaskType = progress.currentTask.type;
// 1. Encumbrance 초과 시 시장 이동
if (_shouldGoToMarket(progress)) {
return _createMarketTask(progress, queue);
}
// 2. 전환 태스크 (buying/heading)
if (_needsTransitionTask(oldTaskType)) {
return _createTransitionTask(state, progress, queue);
}
// 3. Act Boss 리트라이
if (state.progress.pendingActCompletion) {
return _createActBossRetryTask(state, progress, queue);
}
// 4. 최종 보스 전투
if (state.progress.finalBossState == FinalBossState.fighting &&
!state.progress.isInBossLevelingMode) {
if (state.progress.bossLevelingEndTime != null) {
progress = progress.copyWith(clearBossLevelingEndTime: true);
}
final actProgressionService = ActProgressionService(config: config);
return actProgressionService.startFinalBossFight(state, progress, queue);
}
// 5. 일반 몬스터 전투
return _createMonsterTask(state, progress, queue);
}
/// 시장 이동 조건 확인
bool _shouldGoToMarket(ProgressState progress) {
return progress.encumbrance.position >= progress.encumbrance.max &&
progress.encumbrance.max > 0;
}
/// 전환 태스크 필요 여부 확인
bool _needsTransitionTask(TaskType oldTaskType) {
return oldTaskType != TaskType.kill &&
oldTaskType != TaskType.neutral &&
oldTaskType != TaskType.buying;
}
/// 시장 이동 태스크 생성
({ProgressState progress, QueueState queue}) _createMarketTask(
ProgressState progress,
QueueState queue,
) {
final taskResult = pq_logic.startTask(
progress,
l10n.taskHeadingToMarket(),
4 * 1000,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market),
currentCombat: null,
);
return (progress: updatedProgress, queue: queue);
}
/// 전환 태스크 생성 (buying 또는 heading)
({ProgressState progress, QueueState queue}) _createTransitionTask(
GameState state,
ProgressState progress,
QueueState queue,
) {
final gold = state.inventory.gold;
final equipPrice = state.traits.level * 50;
// Gold 충분 시 장비 구매
if (gold > equipPrice) {
final taskResult = pq_logic.startTask(
progress,
l10n.taskUpgradingHardware(),
5 * 1000,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.buying,
),
currentCombat: null,
);
return (progress: updatedProgress, queue: queue);
}
// Gold 부족 시 전장 이동
final taskResult = pq_logic.startTask(
progress,
l10n.taskEnteringDebugZone(),
4 * 1000,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.neutral,
),
currentCombat: null,
);
return (progress: updatedProgress, queue: queue);
}
/// Act Boss 재도전 태스크 생성
({ProgressState progress, QueueState queue}) _createActBossRetryTask(
GameState state,
ProgressState progress,
QueueState queue,
) {
final actProgressionService = ActProgressionService(config: config);
final actBoss = actProgressionService.createActBoss(state);
final combatCalculator = CombatCalculator(rng: state.rng);
final durationMillis = combatCalculator.estimateCombatDurationMs(
player: actBoss.playerStats,
monster: actBoss.monsterStats,
);
final taskResult = pq_logic.startTask(
progress,
l10n.taskDebugging(actBoss.monsterStats.name),
durationMillis,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.kill,
monsterBaseName: actBoss.monsterStats.name,
monsterPart: '*',
monsterLevel: actBoss.monsterStats.level,
monsterGrade: MonsterGrade.boss,
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
),
currentCombat: actBoss,
);
return (progress: updatedProgress, queue: queue);
}
/// 일반 몬스터 전투 태스크 생성
({ProgressState progress, QueueState queue}) _createMonsterTask(
GameState state,
ProgressState progress,
QueueState queue,
) {
final level = state.traits.level;
// 퀘스트 몬스터 데이터 확인
final questMonster = state.progress.currentQuestMonster;
final questMonsterData = questMonster?.monsterData;
final questLevel = questMonsterData != null
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ??
0
: null;
// 몬스터 생성
final monsterResult = pq_logic.monsterTask(
config,
state.rng,
level,
questMonsterData,
questLevel,
);
// 몬스터 레벨 조정 (밸런스)
final actMinLevel = ActMonsterLevel.forPlotStage(
state.progress.plotStageCount,
);
final baseLevel = math.max(level, actMinLevel);
final effectiveMonsterLevel = monsterResult.level
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
.toInt();
// 전투 스탯 생성
final playerCombatStats = CombatStats.fromStats(
stats: state.stats,
equipment: state.equipment,
level: level,
monsterLevel: effectiveMonsterLevel,
);
final monsterCombatStats = MonsterCombatStats.fromLevel(
name: monsterResult.displayName,
level: effectiveMonsterLevel,
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
plotStageCount: state.progress.plotStageCount,
);
// 전투 상태 및 지속시간
final combatState = CombatState.start(
playerStats: playerCombatStats,
monsterStats: monsterCombatStats,
);
final combatCalculator = CombatCalculator(rng: state.rng);
final durationMillis = combatCalculator.estimateCombatDurationMs(
player: playerCombatStats,
monster: monsterCombatStats,
);
final taskResult = pq_logic.startTask(
progress,
l10n.taskDebugging(monsterResult.displayName),
durationMillis,
);
// 몬스터 사이즈 결정
final monsterSize = getMonsterSizeForAct(
plotStageCount: state.progress.plotStageCount,
grade: monsterResult.grade,
rng: state.rng,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.kill,
monsterBaseName: monsterResult.baseName,
monsterPart: monsterResult.part,
monsterLevel: effectiveMonsterLevel,
monsterGrade: monsterResult.grade,
monsterSize: monsterSize,
),
currentCombat: combatState,
);
return (progress: updatedProgress, queue: queue);
}
/// Advances quest completion, applies reward, and enqueues next quest task.
GameState completeQuest(GameState state) {
final result = pq_logic.completeQuest(
config,
state.rng,
state.traits.level,
);
var nextState = _applyReward(state, result.reward);
final questCount = nextState.progress.questCount + 1;
// 퀘스트 히스토리 업데이트: 이전 퀘스트 완료 표시, 새 퀘스트 추가
final updatedQuestHistory = [
...nextState.progress.questHistory.map(
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
),
HistoryEntry(caption: result.caption, isComplete: false),
];
// 퀘스트 몬스터 정보 저장 (Exterminate 타입용)
// 원본 fQuest.Caption = monsterData, fQuest.Tag = monsterIndex
final questMonster = result.monsterIndex != null
? QuestMonsterInfo(
monsterData: result.monsterName!,
monsterIndex: result.monsterIndex!,
)
: null;
// Append quest entry to queue (task kind).
final updatedQueue = QueueState(
entries: [
...nextState.queue.entries,
QueueEntry(
kind: QueueKind.task,
durationMillis: 50 + nextState.rng.nextInt(100),
caption: result.caption,
taskType: TaskType.neutral,
),
],
);
// Update quest progress bar with reset position.
final progress = nextState.progress.copyWith(
quest: ProgressBarState(
position: 0,
max: 50 + nextState.rng.nextInt(100),
),
questCount: questCount,
questHistory: updatedQuestHistory,
currentQuestMonster: questMonster,
);
return _recalculateEncumbrance(
nextState.copyWith(progress: progress, queue: updatedQueue),
);
}
/// Advances plot to next act and applies any act-level rewards.
/// Returns gameComplete=true if Final Boss was defeated (game ends).
({GameState state, bool gameComplete}) completeAct(GameState state) {
final actProgressionService = ActProgressionService(config: config);
// Act 보상 먼저 적용
final actRewards = actProgressionService.getActRewards(
state.progress.plotStageCount,
);
var nextState = state;
for (final reward in actRewards) {
nextState = _applyReward(nextState, reward);
}
// Act 완료 처리 (ActProgressionService 위임)
final result = actProgressionService.completeAct(nextState);
return (
state: _recalculateEncumbrance(result.state),
gameComplete: result.gameComplete,
);
}
/// Developer-only cheat hooks for quickly finishing bars.
GameState forceTaskComplete(GameState state) {
final progress = state.progress.copyWith(
task: state.progress.task.copyWith(position: state.progress.task.max),
);
return state.copyWith(progress: progress);
}
GameState forceQuestComplete(GameState state) {
final progress = state.progress.copyWith(
task: state.progress.task.copyWith(position: state.progress.task.max),
quest: state.progress.quest.copyWith(position: state.progress.quest.max),
);
return state.copyWith(progress: progress);
}
GameState forcePlotComplete(GameState state) {
// 다음 Act의 최소 몬스터 레벨까지 레벨업
final nextPlotStage = state.progress.plotStageCount + 1;
final targetLevel = ActMonsterLevel.forPlotStage(nextPlotStage);
var nextState = state;
// 현재 레벨이 목표 레벨보다 낮으면 레벨업 (최대 100레벨)
while (nextState.traits.level < targetLevel &&
nextState.traits.level < 100) {
nextState = _levelUp(nextState);
}
// 모든 장비 슬롯을 목표 레벨에 맞는 장비로 교체 (전투 보상 드랍 공식 사용)
final equipLevel = nextState.traits.level;
for (var slotIndex = 0; slotIndex < Equipment.slotCount; slotIndex++) {
nextState = mutations.winEquipByIndex(nextState, equipLevel, slotIndex);
}
// 태스크 바 완료 처리
var progress = nextState.progress.copyWith(
task: nextState.progress.task.copyWith(
position: nextState.progress.task.max,
),
);
nextState = nextState.copyWith(progress: progress);
// 디버그 모드에서는 completeAct 직접 호출하여 plotStageCount 즉시 업데이트
// 시네마틱은 생략하고 바로 다음 Act로 진입
final actResult = completeAct(nextState);
return actResult.state;
}
GameState _applyReward(GameState state, pq_logic.RewardKind reward) {
final updated = rewards.applyReward(state, reward);
return _recalculateEncumbrance(updated);
}
GameState _levelUp(GameState state) {
// 최대 레벨(100) 안전장치: 이미 100레벨이면 레벨업하지 않음
if (state.traits.level >= 100) {
return state;
}
final nextLevel = state.traits.level + 1;
final rng = state.rng;
// HP/MP 증가량 (PlayerScaling 기반 + 랜덤 변동)
// 기존: CON/3 + 1 + random(0-3) → ~6-9 HP/레벨 (너무 낮음)
// 신규: 18 + CON/5 + random(0-4) → ~20-25 HP/레벨 (생존율 개선)
final hpGain = 18 + state.stats.con ~/ 5 + rng.nextInt(5);
final mpGain = 6 + state.stats.intelligence ~/ 5 + rng.nextInt(3);
var nextState = state.copyWith(
traits: state.traits.copyWith(level: nextLevel),
stats: state.stats.copyWith(
hpMax: state.stats.hpMax + hpGain,
mpMax: state.stats.mpMax + mpGain,
),
);
// Win two stats and a spell, matching the original leveling rules.
nextState = mutations.winStat(nextState);
nextState = mutations.winStat(nextState);
nextState = mutations.winSpell(nextState, nextState.stats.wis, nextLevel);
final expBar = ProgressBarState(
position: 0,
max: ExpConstants.requiredExp(nextLevel),
);
final progress = nextState.progress.copyWith(exp: expBar);
nextState = nextState.copyWith(progress: progress);
return _recalculateEncumbrance(nextState);
}
GameState _recalculateEncumbrance(GameState state) {
// items에는 Gold가 포함되지 않음 (inventory.gold 필드로 관리)
final encumValue = state.inventory.items.fold<int>(
0,
(sum, item) => sum + item.count,
);
final encumMax = 10 + state.stats.str;
final encumBar = state.progress.encumbrance.copyWith(
position: encumValue,
max: encumMax,
);
final progress = state.progress.copyWith(encumbrance: encumBar);
return state.copyWith(progress: progress);
}
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
/// 전리품 획득 결과
///
/// [state] 업데이트된 게임 상태
/// [droppedPotion] 드랍된 물약 (없으면 null)
({GameState state, Potion? droppedPotion}) _winLoot(GameState state) {
final taskInfo = state.progress.currentTask;
final monsterPart = taskInfo.monsterPart ?? '';
final monsterBaseName = taskInfo.monsterBaseName ?? '';
var resultState = state;
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
if (monsterPart == '*') {
resultState = mutations.winItem(resultState);
} else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) {
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
// ProperCase(Split(fTask.Caption,3))), 1);
// 예: "goblin Claw" 형태로 인벤토리 추가
final itemName =
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
// 인벤토리에 추가
final items = [...resultState.inventory.items];
final existing = items.indexWhere((e) => e.name == itemName);
if (existing >= 0) {
items[existing] = items[existing].copyWith(
count: items[existing].count + 1,
);
} else {
items.add(InventoryEntry(name: itemName, count: 1));
}
resultState = resultState.copyWith(
inventory: resultState.inventory.copyWith(items: items),
);
}
// 물약 드랍 시도
final potionService = const PotionService();
final rng = resultState.rng;
final monsterLevel = taskInfo.monsterLevel ?? resultState.traits.level;
final monsterGrade = taskInfo.monsterGrade ?? MonsterGrade.normal;
final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop(
playerLevel: resultState.traits.level,
monsterLevel: monsterLevel,
monsterGrade: monsterGrade,
inventory: resultState.potionInventory,
roll: rng.nextInt(100),
typeRoll: rng.nextInt(100),
);
return (
state: resultState.copyWith(
rng: rng,
potionInventory: updatedPotionInventory,
),
droppedPotion: droppedPotion,
);
}
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
String _properCase(String s) {
if (s.isEmpty) return s;
return s[0].toUpperCase() + s.substring(1);
}
/// 플레이어 사망 처리 (Phase 4)
///
/// 모든 장비 상실 및 사망 정보 기록
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입
GameState _processPlayerDeath(
GameState state, {
required String killerName,
required DeathCause cause,
}) {
// 사망 직전 전투 이벤트 저장 (최대 10개)
final lastCombatEvents =
state.progress.currentCombat?.recentEvents ?? const [];
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
final isBossDeath =
state.progress.finalBossState == FinalBossState.fighting;
// 보스전 사망이 아닐 경우에만 장비 손실
var newEquipment = state.equipment;
var lostCount = 0;
String? lostItemName;
EquipmentSlot? lostItemSlot;
ItemRarity? lostItemRarity;
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
if (!isBossDeath) {
// 레벨 기반 장비 손실 확률 계산
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
// 공식: 20 + (level - 1) * 80 / 9
final level = state.traits.level;
final lossChancePercent = level >= 10
? 100
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
final roll = state.rng.nextInt(100); // 0~99
final shouldLoseEquipment = roll < lossChancePercent;
// ignore: avoid_print
print(
'[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
'shouldLose=$shouldLoseEquipment',
);
if (shouldLoseEquipment) {
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
final equippedNonWeaponSlots = <int>[];
for (var i = 1; i < Equipment.slotCount; i++) {
final item = state.equipment.getItemByIndex(i);
if (item.isNotEmpty) {
equippedNonWeaponSlots.add(i);
}
}
if (equippedNonWeaponSlots.isNotEmpty) {
lostCount = 1;
// 랜덤하게 1개 슬롯 선택
final sacrificeIndex =
equippedNonWeaponSlots[state.rng.nextInt(
equippedNonWeaponSlots.length,
)];
// 제물로 바칠 아이템 정보 저장
lostEquipmentItem = state.equipment.getItemByIndex(sacrificeIndex);
lostItemName = lostEquipmentItem.name;
lostItemSlot = EquipmentSlot.values[sacrificeIndex];
lostItemRarity = lostEquipmentItem.rarity;
// 해당 슬롯을 빈 장비로 교체
newEquipment = newEquipment.setItemByIndex(
sacrificeIndex,
EquipmentItem.empty(lostItemSlot),
);
// ignore: avoid_print
print('[Death] Lost item: $lostItemName (slot: $lostItemSlot)');
}
}
}
// 사망 정보 생성 (전투 로그 포함)
final deathInfo = DeathInfo(
cause: cause,
killerName: killerName,
lostEquipmentCount: lostCount,
lostItemName: lostItemName,
lostItemSlot: lostItemSlot,
lostItemRarity: lostItemRarity,
lostItem: lostEquipmentItem, // 광고 부활 시 복구용
goldAtDeath: state.inventory.gold,
levelAtDeath: state.traits.level,
timestamp: state.skillSystem.elapsedMs,
lastCombatEvents: lastCombatEvents,
);
// 보스전 사망 시 5분 레벨링 모드 진입
final bossLevelingEndTime = isBossDeath
? DateTime.now().millisecondsSinceEpoch +
(5 * 60 * 1000) // 5분
: null;
// 전투 상태 초기화 및 사망 횟수 증가
final progress = state.progress.copyWith(
currentCombat: null,
deathCount: state.progress.deathCount + 1,
bossLevelingEndTime: bossLevelingEndTime,
);
return state.copyWith(
equipment: newEquipment,
progress: progress,
deathInfo: deathInfo,
);
}
}