import 'dart:math' as math; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; import 'package:asciineverdie/data/skill_data.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/engine/game_mutations.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/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/model/skill.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( 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) { // 10000ms 제한: 100x 배속 (50ms * 100 = 5000ms) + 여유 공간 // 디버그 터보 모드(100x) 지원을 위해 확장 final int clamped = elapsedMillis.clamp(0, 10000).toInt(); var progress = state.progress; var queue = state.queue; var nextState = state; var leveledUp = false; var questDone = false; var actDone = false; var gameComplete = false; // 스킬 시스템 시간 업데이트 (Phase 3) final skillService = SkillService(rng: state.rng); var skillSystem = skillService.updateElapsedTime( state.skillSystem, clamped, ); // 만료된 버프 정리 skillSystem = skillService.cleanupExpiredBuffs(skillSystem); // 비전투 시 MP 회복 final isInCombat = progress.currentTask.type == TaskType.kill && progress.currentCombat != null && progress.currentCombat!.isActive; if (!isInCombat && nextState.stats.mp < nextState.stats.mpMax) { final mpRegen = skillService.calculateMpRegen( elapsedMs: clamped, 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), ); } } nextState = nextState.copyWith(skillSystem: skillSystem); // Advance task bar if still running. if (progress.task.position < progress.task.max) { final uncapped = progress.task.position + clamped; final int newTaskPos = uncapped > progress.task.max ? progress.task.max : uncapped; // 킬 태스크 중 전투 진행 (스킬 자동 사용, DOT, 물약 포함) var updatedCombat = progress.currentCombat; var updatedSkillSystem = nextState.skillSystem; var updatedPotionInventory = nextState.potionInventory; if (progress.currentTask.type == TaskType.kill && updatedCombat != null && updatedCombat.isActive) { final combatResult = _processCombatTickWithSkills( nextState, updatedCombat, updatedSkillSystem, clamped, ); updatedCombat = combatResult.combat; updatedSkillSystem = combatResult.skillSystem; if (combatResult.potionInventory != null) { updatedPotionInventory = combatResult.potionInventory!; } // Phase 4: 플레이어 사망 체크 if (!updatedCombat.playerStats.isAlive) { final monsterName = updatedCombat.monsterStats.name; nextState = _processPlayerDeath( nextState, killerName: monsterName, cause: DeathCause.monster, ); return ProgressTickResult(state: nextState, playerDied: true); } } progress = progress.copyWith( task: progress.task.copyWith(position: newTaskPos), currentCombat: updatedCombat, ); nextState = _recalculateEncumbrance( nextState.copyWith( progress: progress, skillSystem: updatedSkillSystem, potionInventory: updatedPotionInventory, ), ); return ProgressTickResult(state: nextState); } final gain = progress.currentTask.type == TaskType.kill; final incrementSeconds = progress.task.max ~/ 1000; // 몬스터 경험치 미리 저장 (currentCombat이 null되기 전) final int monsterExpReward = progress.currentCombat?.monsterStats.expReward ?? 0; // 킬 태스크 완료 시 전투 결과 반영 및 전리품 획득 if (gain) { // 전투 결과에 따라 플레이어 HP 업데이트 + 전투 후 회복 final combat = progress.currentCombat; if (combat != null && combat.isActive) { // 전투 중 남은 HP final remainingHp = combat.playerStats.hpCurrent; final maxHp = combat.playerStats.hpMax; // 전투 승리 시 HP 회복 (50% + CON/2) // 아이들 게임 특성상 전투 사이 HP가 회복되어야 지속 플레이 가능 final conBonus = nextState.stats.con ~/ 2; final healAmount = (maxHp * 0.5).round() + conBonus; final newHp = (remainingHp + healAmount).clamp(0, maxHp); nextState = nextState.copyWith( stats: nextState.stats.copyWith(hpCurrent: newHp), ); } // 전리품 획득 (원본 Main.pas:625-630) 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); } // 전투 상태 초기화, 몬스터 처치 수 증가 및 물약 사용 기록 초기화 progress = progress.copyWith( currentCombat: null, monstersKilled: progress.monstersKilled + 1, ); final resetPotionInventory = nextState.potionInventory.resetBattleUsage(); nextState = nextState.copyWith( progress: progress, potionInventory: resetPotionInventory, ); // 최종 보스 처치 체크 if (progress.finalBossState == FinalBossState.fighting) { // 글리치 갓 처치 완료 - 게임 클리어 progress = progress.copyWith( finalBossState: FinalBossState.defeated, ); nextState = nextState.copyWith(progress: progress); // completeAct를 호출하여 게임 완료 처리 final actResult = completeAct(nextState); nextState = actResult.state; return ProgressTickResult( state: nextState, leveledUp: false, completedQuest: false, completedAct: true, gameComplete: true, ); } } // 시장/판매/구매 태스크 완료 시 처리 (원본 Main.pas:631-649) final taskType = progress.currentTask.type; if (taskType == TaskType.buying) { // 장비 구매 완료 (원본 631-634) nextState = _completeBuying(nextState); progress = nextState.progress; } else if (taskType == TaskType.market || taskType == TaskType.sell) { // 시장 도착 또는 판매 완료 (원본 635-649) final sellResult = _processSell(nextState); nextState = sellResult.state; progress = nextState.progress; queue = nextState.queue; // 판매 중이면 다른 로직 건너뛰기 if (sellResult.continuesSelling) { nextState = _recalculateEncumbrance( nextState.copyWith(progress: progress, queue: queue), ); return ProgressTickResult( state: nextState, leveledUp: false, completedQuest: false, completedAct: false, ); } } // Gain XP / level up (몬스터 경험치 기반) // 최대 레벨(100) 제한: 100레벨에서는 더 이상 레벨업하지 않음 if (gain && nextState.traits.level < 100 && monsterExpReward > 0) { final newExpPos = progress.exp.position + monsterExpReward; // 레벨업 체크 (경험치가 필요량 이상일 때) 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), ); } } // Advance quest bar after Act I. 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, ), ); } } // 플롯(plot) 바가 완료되면 InterplotCinematic 트리거 // (원본 Main.pas:1301-1304) if (gain && progress.plot.max > 0 && progress.plot.position >= progress.plot.max) { // InterplotCinematic을 호출하여 시네마틱 이벤트 큐에 추가 final cinematicEntries = pq_logic.interplotCinematic( config, nextState.rng, nextState.traits.level, nextState.progress.plotStageCount, ); queue = QueueState(entries: [...queue.entries, ...cinematicEntries]); // 플롯 바를 0으로 리셋하지 않음 - completeAct에서 처리됨 } else if (progress.currentTask.type != TaskType.load && progress.plot.max > 0) { final uncappedPlot = progress.plot.position + incrementSeconds; final int newPlotPos = uncappedPlot > progress.plot.max ? progress.plot.max : uncappedPlot; progress = progress.copyWith( plot: progress.plot.copyWith(position: newPlotPos), ); } // Dequeue next scripted task if available. 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; // plot 타입이 dequeue 되면 completeAct 실행 (원본 Main.pas 로직) 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 { // 큐가 비어있으면 새 태스크 생성 (원본 Dequeue 667-684줄) nextState = nextState.copyWith(progress: progress, queue: queue); final newTaskResult = _generateNextTask(nextState); progress = newTaskResult.progress; queue = newTaskResult.queue; } nextState = _recalculateEncumbrance( nextState.copyWith(progress: progress, queue: queue), ); return ProgressTickResult( state: nextState, leveledUp: leveledUp, completedQuest: questDone, completedAct: 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가 가득 찼으면 시장으로 이동 (원본 667-669줄) if (progress.encumbrance.position >= progress.encumbrance.max && progress.encumbrance.max > 0) { final taskResult = pq_logic.startTask( progress, l10n.taskHeadingToMarket(), 4 * 1000, ); progress = taskResult.progress.copyWith( currentTask: TaskInfo( caption: taskResult.caption, type: TaskType.market, ), ); return (progress: progress, queue: queue); } // 2. kill 태스크가 아니었고 heading도 아니면 heading 또는 buying 태스크 실행 // (원본 670-677줄) if (oldTaskType != TaskType.kill && oldTaskType != TaskType.neutral) { // Gold가 충분하면 장비 구매 (원본 671-673줄) final gold = _getGold(state); final equipPrice = _equipPrice(state.traits.level); if (gold > equipPrice) { final taskResult = pq_logic.startTask( progress, l10n.taskUpgradingHardware(), 5 * 1000, ); progress = taskResult.progress.copyWith( currentTask: TaskInfo( caption: taskResult.caption, type: TaskType.buying, ), ); return (progress: progress, queue: queue); } // Gold가 부족하면 전장으로 이동 (원본 674-676줄) final taskResult = pq_logic.startTask( progress, l10n.taskEnteringDebugZone(), 4 * 1000, ); progress = taskResult.progress.copyWith( currentTask: TaskInfo( caption: taskResult.caption, type: TaskType.neutral, ), ); return (progress: progress, queue: queue); } // 3. 최종 보스 전투 체크 // finalBossState == fighting이면 Glitch God 스폰 if (state.progress.finalBossState == FinalBossState.fighting) { return _startFinalBossFight(state, progress, queue); } // 4. MonsterTask 실행 (원본 678-684줄) final level = state.traits.level; // 원본 Main.pas:548-551: 25% 확률로 Quest Monster 사용 // fQuest.Caption이 비어있지 않으면 해당 몬스터 데이터 전달 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, ); // 전투용 몬스터 레벨 조정 (밸런스) // Act별 최소 레벨과 플레이어 레벨 중 큰 값을 기준으로 ±3 범위 제한 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(); // 전투 스탯 생성 (Phase 12: 몬스터 레벨 기반 페널티 적용) 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), ); // 전투 상태 초기화 final combatState = CombatState.start( playerStats: playerCombatStats, monsterStats: monsterCombatStats, ); // 태스크 지속시간 계산 (CombatCalculator 기반) 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, ); progress = taskResult.progress.copyWith( currentTask: TaskInfo( caption: taskResult.caption, type: TaskType.kill, monsterBaseName: monsterResult.baseName, monsterPart: monsterResult.part, monsterLevel: monsterResult.level, monsterGrade: monsterResult.grade, ), currentCombat: combatState, ); return (progress: progress, queue: queue); } /// 최종 보스(Glitch God) 전투 시작 /// /// Act V 플롯 완료 후 호출되며, 글리치 갓과의 전투를 설정합니다. ({ProgressState progress, QueueState queue}) _startFinalBossFight( GameState state, ProgressState progress, QueueState queue, ) { final level = state.traits.level; // Glitch God 생성 (레벨 100 최종 보스) final glitchGod = MonsterCombatStats.glitchGod(); // 플레이어 전투 스탯 생성 (Phase 12: 보스 레벨 기반 페널티 적용) final playerCombatStats = CombatStats.fromStats( stats: state.stats, equipment: state.equipment, level: level, monsterLevel: glitchGod.level, ); // 전투 상태 초기화 final combatState = CombatState.start( playerStats: playerCombatStats, monsterStats: glitchGod, ); // 전투 시간 추정 (보스 전투는 더 길게) final combatCalculator = CombatCalculator(rng: state.rng); final baseDuration = combatCalculator.estimateCombatDurationMs( player: playerCombatStats, monster: glitchGod, ); // 최종 보스는 최소 10초, 최대 60초 final durationMillis = baseDuration.clamp(10000, 60000); final taskResult = pq_logic.startTask( progress, l10n.taskFinalBoss(glitchGod.name), durationMillis, ); final updatedProgress = taskResult.progress.copyWith( currentTask: TaskInfo( caption: taskResult.caption, type: TaskType.kill, monsterBaseName: 'Glitch God', monsterPart: '*', // 특수 전리품 monsterLevel: glitchGod.level, monsterGrade: MonsterGrade.boss, // 최종 보스는 항상 boss 등급 ), 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) { // Act V 완료 시 (plotStageCount == 6) → 최종 보스 전투 시작 // plotStageCount: 1=Prologue, 2=Act I, 3=Act II, 4=Act III, 5=Act IV, 6=Act V if (state.progress.plotStageCount >= 6) { // 이미 최종 보스가 처치되었으면 게임 클리어 if (state.progress.finalBossState == FinalBossState.defeated) { final updatedPlotHistory = [ ...state.progress.plotHistory.map( (e) => e.isComplete ? e : e.copyWith(isComplete: true), ), const HistoryEntry(caption: '*** THE END ***', isComplete: true), ]; final updatedProgress = state.progress.copyWith( plotHistory: updatedPlotHistory, ); return ( state: state.copyWith(progress: updatedProgress), gameComplete: true, ); } // 최종 보스가 아직 등장하지 않았으면 보스 전투 시작 if (state.progress.finalBossState == FinalBossState.notSpawned) { final updatedProgress = state.progress.copyWith( finalBossState: FinalBossState.fighting, ); // 게임은 아직 끝나지 않음 - 보스 전투 진행 return ( state: state.copyWith(progress: updatedProgress), gameComplete: false, ); } // 보스 전투 중이면 계속 진행 (게임 종료 안 함) return (state: state, gameComplete: false); } final actResult = pq_logic.completeAct(state.progress.plotStageCount); var nextState = state; for (final reward in actResult.rewards) { nextState = _applyReward(nextState, reward); } final plotStages = nextState.progress.plotStageCount + 1; // 플롯 히스토리 업데이트: 이전 플롯 완료 표시, 새 플롯 추가 final updatedPlotHistory = [ ...nextState.progress.plotHistory.map( (e) => e.isComplete ? e : e.copyWith(isComplete: true), ), HistoryEntry(caption: actResult.actTitle, isComplete: false), ]; var updatedProgress = nextState.progress.copyWith( plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds), plotStageCount: plotStages, plotHistory: updatedPlotHistory, ); nextState = nextState.copyWith(progress: updatedProgress); // Act I 완료 후(Prologue -> Act I) 첫 퀘스트 시작 (원본 Main.pas 로직) // plotStages == 2는 Prologue(1) -> Act I(2) 전환을 의미 if (plotStages == 2) { nextState = _startFirstQuest(nextState); } return (state: _recalculateEncumbrance(nextState), gameComplete: false); } /// 첫 퀘스트 시작 (Act I 시작 시) GameState _startFirstQuest(GameState state) { final result = pq_logic.completeQuest( config, state.rng, state.traits.level, ); // 퀘스트 바 초기화 final questBar = ProgressBarState( position: 0, max: 50 + state.rng.nextInt(100), ); // 첫 퀘스트 히스토리 추가 final questHistory = [ 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; // 첫 퀘스트 추가 final updatedQueue = QueueState( entries: [ ...state.queue.entries, QueueEntry( kind: QueueKind.task, durationMillis: 50 + state.rng.nextInt(100), caption: result.caption, taskType: TaskType.neutral, ), ], ); final progress = state.progress.copyWith( quest: questBar, questCount: 1, questHistory: questHistory, currentQuestMonster: questMonster, ); return state.copyWith(progress: progress, queue: updatedQueue); } /// 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; final hpGain = state.stats.con ~/ 3 + 1 + rng.nextInt(4); final mpGain = state.stats.intelligence ~/ 3 + 1 + rng.nextInt(4); 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( 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); } /// 인벤토리에서 Gold 수량 반환 int _getGold(GameState state) { return state.inventory.gold; } /// 장비 가격 계산 (원본 Main.pas:612-616) /// Result := 5 * Level^2 + 10 * Level + 20 int _equipPrice(int level) { return 5 * level * level + 10 * level + 20; } /// 장비 구매 완료 처리 (원본 Main.pas:631-634) GameState _completeBuying(GameState state) { final level = state.traits.level; final price = _equipPrice(level); // Gold 차감 (inventory.gold 필드 사용) final newGold = math.max(0, state.inventory.gold - price); var nextState = state.copyWith( inventory: state.inventory.copyWith(gold: newGold), ); // 장비 획득 (WinEquip) // 원본 Main.pas:797 - posn := Random(Equips.Items.Count); (11개 슬롯) final slotIndex = nextState.rng.nextInt(Equipment.slotCount); nextState = mutations.winEquipByIndex(nextState, level, slotIndex); // 물약 자동 구매 (남은 골드의 20% 사용) final potionService = const PotionService(); final purchaseResult = potionService.autoPurchasePotions( playerLevel: level, inventory: nextState.potionInventory, gold: nextState.inventory.gold, spendRatio: 0.20, ); if (purchaseResult.success && purchaseResult.newInventory != null) { nextState = nextState.copyWith( inventory: nextState.inventory.copyWith(gold: purchaseResult.newGold), potionInventory: purchaseResult.newInventory, ); } return nextState; } /// 판매 처리 결과 ({GameState state, bool continuesSelling}) _processSell(GameState state) { final taskType = state.progress.currentTask.type; var items = [...state.inventory.items]; var goldAmount = state.inventory.gold; // sell 태스크 완료 시 아이템 판매 (원본 Main.pas:636-643) if (taskType == TaskType.sell) { // 첫 번째 아이템 찾기 (items에는 Gold가 없음) if (items.isNotEmpty) { final item = items.first; final level = state.traits.level; // 가격 계산: 수량 * 레벨 var price = item.count * level; // " of " 포함 시 보너스 (원본 639-640) if (item.name.contains(' of ')) { price = price * (1 + pq_logic.randomLow(state.rng, 10)) * (1 + pq_logic.randomLow(state.rng, level)); } // 아이템 삭제 items.removeAt(0); // Gold 추가 (inventory.gold 필드 사용) goldAmount += price; } } // 판매할 아이템이 남아있는지 확인 final hasItemsToSell = items.isNotEmpty; if (hasItemsToSell) { // 다음 아이템 판매 태스크 시작 final nextItem = items.first; final translatedName = l10n.translateItemNameL10n(nextItem.name); final itemDesc = l10n.indefiniteL10n(translatedName, nextItem.count); final taskResult = pq_logic.startTask( state.progress, l10n.taskSelling(itemDesc), 1 * 1000, ); final progress = taskResult.progress.copyWith( currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.sell), ); return ( state: state.copyWith( inventory: state.inventory.copyWith(gold: goldAmount, items: items), progress: progress, ), continuesSelling: true, ); } // 판매 완료 - 인벤토리 업데이트만 하고 다음 태스크로 return ( state: state.copyWith( inventory: state.inventory.copyWith(gold: goldAmount, items: items), ), continuesSelling: false, ); } /// 전투 틱 처리 (스킬 자동 사용, DOT, 물약 포함) /// /// [state] 현재 게임 상태 /// [combat] 현재 전투 상태 /// [skillSystem] 스킬 시스템 상태 /// [elapsedMs] 경과 시간 (밀리초) /// Returns: 업데이트된 전투 상태, 스킬 시스템 상태, 물약 인벤토리 ({ CombatState combat, SkillSystemState skillSystem, PotionInventory? potionInventory, }) _processCombatTickWithSkills( GameState state, CombatState combat, SkillSystemState skillSystem, int elapsedMs, ) { if (!combat.isActive || combat.isCombatOver) { return (combat: combat, skillSystem: skillSystem, potionInventory: null); } final calculator = CombatCalculator(rng: state.rng); final skillService = SkillService(rng: state.rng); final potionService = const PotionService(); var playerStats = combat.playerStats; var monsterStats = combat.monsterStats; var playerAccumulator = combat.playerAttackAccumulatorMs + elapsedMs; var monsterAccumulator = combat.monsterAttackAccumulatorMs + elapsedMs; var totalDamageDealt = combat.totalDamageDealt; var totalDamageTaken = combat.totalDamageTaken; var turnsElapsed = combat.turnsElapsed; var updatedSkillSystem = skillSystem; var activeDoTs = [...combat.activeDoTs]; var usedPotionTypes = {...combat.usedPotionTypes}; var activeDebuffs = [...combat.activeDebuffs]; PotionInventory? updatedPotionInventory; // 새 전투 이벤트 수집 final newEvents = []; final timestamp = updatedSkillSystem.elapsedMs; // ========================================================================= // 만료된 디버프 정리 // ========================================================================= activeDebuffs = activeDebuffs .where((debuff) => !debuff.isExpired(timestamp)) .toList(); // ========================================================================= // DOT 틱 처리 // ========================================================================= var dotDamageThisTick = 0; final updatedDoTs = []; for (final dot in activeDoTs) { final (updatedDot, ticksTriggered) = dot.tick(elapsedMs); if (ticksTriggered > 0) { final damage = dot.damagePerTick * ticksTriggered; dotDamageThisTick += damage; // DOT 데미지 이벤트 생성 (skillId → name 변환) final dotSkillName = SkillData.getSkillById(dot.skillId)?.name ?? dot.skillId; newEvents.add( CombatEvent.dotTick( timestamp: timestamp, skillName: dotSkillName, damage: damage, targetName: monsterStats.name, ), ); } // 만료되지 않은 DOT만 유지 if (updatedDot.isActive) { updatedDoTs.add(updatedDot); } } // DOT 데미지 적용 if (dotDamageThisTick > 0 && monsterStats.isAlive) { final newMonsterHp = (monsterStats.hpCurrent - dotDamageThisTick).clamp( 0, monsterStats.hpMax, ); monsterStats = monsterStats.copyWith(hpCurrent: newMonsterHp); totalDamageDealt += dotDamageThisTick; } activeDoTs = updatedDoTs; // ========================================================================= // 긴급 물약 자동 사용 (HP < 30%) // ========================================================================= final hpRatio = playerStats.hpCurrent / playerStats.hpMax; if (hpRatio <= PotionService.emergencyHpThreshold) { final emergencyPotion = potionService.selectEmergencyHpPotion( currentHp: playerStats.hpCurrent, maxHp: playerStats.hpMax, inventory: state.potionInventory, playerLevel: state.traits.level, ); if (emergencyPotion != null && !usedPotionTypes.contains(PotionType.hp)) { final result = potionService.usePotion( potionId: emergencyPotion.id, inventory: state.potionInventory, currentHp: playerStats.hpCurrent, maxHp: playerStats.hpMax, currentMp: playerStats.mpCurrent, maxMp: playerStats.mpMax, ); if (result.success) { playerStats = playerStats.copyWith(hpCurrent: result.newHp); usedPotionTypes = {...usedPotionTypes, PotionType.hp}; updatedPotionInventory = result.newInventory; newEvents.add( CombatEvent.playerPotion( timestamp: timestamp, potionName: emergencyPotion.name, healAmount: result.healedAmount, isHp: true, ), ); } } } // 플레이어 공격 체크 if (playerAccumulator >= playerStats.attackDelayMs) { // SkillBook에서 사용 가능한 스킬 ID 목록 조회 var availableSkillIds = skillService.getAvailableSkillIdsFromSkillBook( state.skillBook, ); // SkillBook에 스킬이 없으면 기본 스킬 사용 if (availableSkillIds.isEmpty) { availableSkillIds = SkillData.defaultSkillIds; } final selectedSkill = skillService.selectAutoSkill( player: playerStats, monster: monsterStats, skillSystem: updatedSkillSystem, availableSkillIds: availableSkillIds, activeDoTs: activeDoTs, activeDebuffs: activeDebuffs, ); if (selectedSkill != null && selectedSkill.isAttack) { // 스킬 랭크 조회 (SkillBook 기반) final skillRank = skillService.getSkillRankFromSkillBook( state.skillBook, selectedSkill.id, ); // 랭크 스케일링 적용된 공격 스킬 사용 final skillResult = skillService.useAttackSkillWithRank( skill: selectedSkill, player: playerStats, monster: monsterStats, skillSystem: updatedSkillSystem, rank: skillRank, ); playerStats = skillResult.updatedPlayer; monsterStats = skillResult.updatedMonster; totalDamageDealt += skillResult.result.damage; updatedSkillSystem = skillResult.updatedSkillSystem; // 스킬 공격 이벤트 생성 newEvents.add( CombatEvent.playerSkill( timestamp: timestamp, skillName: selectedSkill.name, damage: skillResult.result.damage, targetName: monsterStats.name, attackDelayMs: playerStats.attackDelayMs, ), ); } else if (selectedSkill != null && selectedSkill.isDot) { // DOT 스킬 사용 final skillResult = skillService.useDotSkill( skill: selectedSkill, player: playerStats, skillSystem: updatedSkillSystem, playerInt: state.stats.intelligence, playerWis: state.stats.wis, ); playerStats = skillResult.updatedPlayer; updatedSkillSystem = skillResult.updatedSkillSystem; // DOT 효과 추가 if (skillResult.dotEffect != null) { activeDoTs.add(skillResult.dotEffect!); } // DOT 스킬 사용 이벤트 생성 newEvents.add( CombatEvent.playerSkill( timestamp: timestamp, skillName: selectedSkill.name, damage: skillResult.result.damage, targetName: monsterStats.name, attackDelayMs: playerStats.attackDelayMs, ), ); } else if (selectedSkill != null && selectedSkill.isHeal) { // 회복 스킬 사용 final skillResult = skillService.useHealSkill( skill: selectedSkill, player: playerStats, skillSystem: updatedSkillSystem, ); playerStats = skillResult.updatedPlayer; updatedSkillSystem = skillResult.updatedSkillSystem; // 회복 이벤트 생성 newEvents.add( CombatEvent.playerHeal( timestamp: timestamp, healAmount: skillResult.result.healedAmount, skillName: selectedSkill.name, ), ); } else if (selectedSkill != null && selectedSkill.isBuff) { // 버프 스킬 사용 final skillResult = skillService.useBuffSkill( skill: selectedSkill, player: playerStats, skillSystem: updatedSkillSystem, ); playerStats = skillResult.updatedPlayer; updatedSkillSystem = skillResult.updatedSkillSystem; // 버프 이벤트 생성 newEvents.add( CombatEvent.playerBuff( timestamp: timestamp, skillName: selectedSkill.name, ), ); } else if (selectedSkill != null && selectedSkill.isDebuff) { // 디버프 스킬 사용 final skillResult = skillService.useDebuffSkill( skill: selectedSkill, player: playerStats, skillSystem: updatedSkillSystem, currentDebuffs: activeDebuffs, ); playerStats = skillResult.updatedPlayer; updatedSkillSystem = skillResult.updatedSkillSystem; // 디버프 효과 추가 (기존 같은 디버프 제거 후) if (skillResult.debuffEffect != null) { activeDebuffs = activeDebuffs .where((d) => d.effect.id != skillResult.debuffEffect!.effect.id) .toList() ..add(skillResult.debuffEffect!); } // 디버프 이벤트 생성 newEvents.add( CombatEvent.playerDebuff( timestamp: timestamp, skillName: selectedSkill.name, targetName: monsterStats.name, ), ); } else { // 일반 공격 final attackResult = calculator.playerAttackMonster( attacker: playerStats, defender: monsterStats, ); monsterStats = attackResult.updatedDefender; totalDamageDealt += attackResult.result.damage; // 일반 공격 이벤트 생성 final result = attackResult.result; if (result.isEvaded) { newEvents.add( CombatEvent.monsterEvade( timestamp: timestamp, targetName: monsterStats.name, ), ); } else { newEvents.add( CombatEvent.playerAttack( timestamp: timestamp, damage: result.damage, targetName: monsterStats.name, isCritical: result.isCritical, attackDelayMs: playerStats.attackDelayMs, ), ); } } playerAccumulator -= playerStats.attackDelayMs; turnsElapsed++; } // 몬스터가 살아있으면 반격 if (monsterStats.isAlive && monsterAccumulator >= monsterStats.attackDelayMs) { // 디버프 효과 적용된 몬스터 스탯 계산 var debuffedMonster = monsterStats; if (activeDebuffs.isNotEmpty) { double atkMod = 0; for (final debuff in activeDebuffs) { if (!debuff.isExpired(timestamp)) { atkMod += debuff.effect.atkModifier; // 음수 값 } } // ATK 감소 적용 (최소 10% ATK 유지) final newAtk = (monsterStats.atk * (1 + atkMod)).round().clamp( monsterStats.atk ~/ 10, monsterStats.atk, ); debuffedMonster = monsterStats.copyWith(atk: newAtk); } final attackResult = calculator.monsterAttackPlayer( attacker: debuffedMonster, defender: playerStats, ); playerStats = attackResult.updatedDefender; totalDamageTaken += attackResult.result.damage; monsterAccumulator -= monsterStats.attackDelayMs; // 몬스터 공격 이벤트 생성 final result = attackResult.result; if (result.isEvaded) { newEvents.add( CombatEvent.playerEvade( timestamp: timestamp, attackerName: monsterStats.name, ), ); } else if (result.isBlocked) { newEvents.add( CombatEvent.playerBlock( timestamp: timestamp, reducedDamage: result.damage, attackerName: monsterStats.name, ), ); } else if (result.isParried) { newEvents.add( CombatEvent.playerParry( timestamp: timestamp, reducedDamage: result.damage, attackerName: monsterStats.name, ), ); } else { newEvents.add( CombatEvent.monsterAttack( timestamp: timestamp, damage: result.damage, attackerName: monsterStats.name, attackDelayMs: monsterStats.attackDelayMs, ), ); } } // 전투 종료 체크 final isActive = playerStats.isAlive && monsterStats.isAlive; // 기존 이벤트와 합쳐서 최대 10개 유지 final combinedEvents = [...combat.recentEvents, ...newEvents]; final recentEvents = combinedEvents.length > 10 ? combinedEvents.sublist(combinedEvents.length - 10) : combinedEvents; return ( combat: combat.copyWith( playerStats: playerStats, monsterStats: monsterStats, playerAttackAccumulatorMs: playerAccumulator, monsterAttackAccumulatorMs: monsterAccumulator, totalDamageDealt: totalDamageDealt, totalDamageTaken: totalDamageTaken, turnsElapsed: turnsElapsed, isActive: isActive, recentEvents: recentEvents, activeDoTs: activeDoTs, usedPotionTypes: usedPotionTypes, activeDebuffs: activeDebuffs, ), skillSystem: updatedSkillSystem, potionInventory: updatedPotionInventory, ); } /// 플레이어 사망 처리 (Phase 4) /// /// 모든 장비 상실 및 사망 정보 기록 GameState _processPlayerDeath( GameState state, { required String killerName, required DeathCause cause, }) { // 상실할 장비 개수 계산 final lostCount = state.equipment.equippedItems.length; // 사망 직전 전투 이벤트 저장 (최대 10개) final lastCombatEvents = state.progress.currentCombat?.recentEvents ?? const []; // 빈 장비 생성 (기본 무기만 유지) final emptyEquipment = Equipment( items: [ EquipmentItem.defaultWeapon(), EquipmentItem.empty(EquipmentSlot.shield), EquipmentItem.empty(EquipmentSlot.helm), EquipmentItem.empty(EquipmentSlot.hauberk), EquipmentItem.empty(EquipmentSlot.brassairts), EquipmentItem.empty(EquipmentSlot.vambraces), EquipmentItem.empty(EquipmentSlot.gauntlets), EquipmentItem.empty(EquipmentSlot.gambeson), EquipmentItem.empty(EquipmentSlot.cuisses), EquipmentItem.empty(EquipmentSlot.greaves), EquipmentItem.empty(EquipmentSlot.sollerets), ], bestIndex: 0, ); // 사망 정보 생성 (전투 로그 포함) final deathInfo = DeathInfo( cause: cause, killerName: killerName, lostEquipmentCount: lostCount, goldAtDeath: state.inventory.gold, levelAtDeath: state.traits.level, timestamp: state.skillSystem.elapsedMs, lastCombatEvents: lastCombatEvents, ); // 전투 상태 초기화 및 사망 횟수 증가 final progress = state.progress.copyWith( currentCombat: null, deathCount: state.progress.deathCount + 1, ); return state.copyWith( equipment: emptyEquipment, progress: progress, deathInfo: deathInfo, ); } }