import 'dart:math' as math; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; 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/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( 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; // 킬 태스크 중 전투 진행 (CombatTickService 사용) var updatedCombat = progress.currentCombat; var updatedSkillSystem = nextState.skillSystem; var updatedPotionInventory = nextState.potionInventory; if (progress.currentTask.type == TaskType.kill && updatedCombat != null && updatedCombat.isActive) { final combatTickService = CombatTickService(rng: nextState.rng); final combatResult = combatTickService.processTick( state: nextState, combat: updatedCombat, skillSystem: updatedSkillSystem, elapsedMs: 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); } // Boss 승리 처리: 시네마틱 트리거 if (progress.pendingActCompletion) { // Act Boss를 처치했으므로 시네마틱 재생 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, // Boss 처치 완료 ); } else { // 일반 전투 종료 progress = progress.copyWith( currentCombat: null, monstersKilled: progress.monstersKilled + 1, ); } final resetPotionInventory = nextState.potionInventory.resetBattleUsage(); nextState = nextState.copyWith( progress: progress, queue: queue, 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, ); } } // 시장/판매/구매 태스크 완료 시 처리 (MarketService 사용) final marketService = MarketService(rng: nextState.rng); final taskType = progress.currentTask.type; if (taskType == TaskType.buying) { // 장비 구매 완료 (원본 631-634) nextState = marketService.completeBuying(nextState); progress = nextState.progress; } else if (taskType == TaskType.market || taskType == TaskType.sell) { // 시장 도착 또는 판매 완료 (원본 635-649) 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 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) 바가 완료되면 Act Boss 소환 // (개선: Boss 처치 → 시네마틱 → Act 전환 순서) if (gain && progress.plot.max > 0 && progress.plot.position >= progress.plot.max && !progress.pendingActCompletion) { // Act Boss 소환 및 플래그 설정 final actBoss = _createActBoss(nextState); progress = progress.copyWith( plot: progress.plot.copyWith(position: 0), // Plot bar 리셋 currentCombat: actBoss, pendingActCompletion: true, // Boss 처치 대기 플래그 ); } 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; 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, ), currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화 ); return (progress: progress, queue: queue); } // 2. kill/heading/buying 태스크가 아니었으면 heading 또는 buying 태스크 실행 // (원본 670-677줄) - buying 완료 후 무한 루프 방지 if (oldTaskType != TaskType.kill && oldTaskType != TaskType.neutral && oldTaskType != TaskType.buying) { // Gold가 충분하면 장비 구매 (Common 장비 가격 기준) // 실제 구매 가격과 동일한 공식 사용: level * 50 final gold = state.inventory.gold; final equipPrice = state.traits.level * 50; // Common 장비 1개 가격 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, ), currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화 ); 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, ), currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화 ); return (progress: progress, queue: queue); } // 3. Act Boss 리트라이 체크 // pendingActCompletion이 true면 Act Boss 재소환 if (state.progress.pendingActCompletion) { final actBoss = _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, ); progress = taskResult.progress.copyWith( currentTask: TaskInfo( caption: taskResult.caption, type: TaskType.kill, monsterBaseName: actBoss.monsterStats.name, monsterPart: '*', // Boss는 WinItem 드랍 monsterLevel: actBoss.monsterStats.level, monsterGrade: MonsterGrade.boss, ), currentCombat: actBoss, ); return (progress: progress, queue: queue); } // 4. 최종 보스 전투 체크 // finalBossState == fighting이면 Glitch God 스폰 // 단, 레벨링 모드 중이면 일반 몬스터로 레벨업 후 재도전 if (state.progress.finalBossState == FinalBossState.fighting) { if (state.progress.isInBossLevelingMode) { // 레벨링 모드: 일반 몬스터 전투로 대체 (아래 MonsterTask로 진행) } else { // 레벨링 모드 종료 또는 첫 도전: 보스전 시작 // 레벨링 모드가 끝났으면 타이머 초기화 if (state.progress.bossLevelingEndTime != null) { progress = progress.copyWith(clearBossLevelingEndTime: true); } return _startFinalBossFight(state, progress, queue); } } // 5. 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), plotStageCount: state.progress.plotStageCount, ); // 전투 상태 초기화 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: effectiveMonsterLevel, 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; // 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( 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); } /// Act Boss 생성 (Act 완료 시) /// /// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)로 설정하여 /// 플레이어가 이길 수 있는 수준 보장 CombatState _createActBoss(GameState state) { final plotStage = state.progress.plotStageCount; final actNumber = plotStage + 1; // 보스 레벨 = min(플레이어 레벨, Act 최소 레벨) // → 플레이어가 현재 레벨보다 높은 보스를 만나지 않도록 보장 final actMinLevel = ActMonsterLevel.forPlotStage(actNumber); final bossLevel = math.min(state.traits.level, actMinLevel); // Named monster 생성 (pq_logic.namedMonster 활용) final bossName = pq_logic.namedMonster(config, state.rng, bossLevel); final bossStats = MonsterBaseStats.forLevel(bossLevel); // 플레이어 전투 스탯 생성 final playerCombatStats = CombatStats.fromStats( stats: state.stats, equipment: state.equipment, level: state.traits.level, monsterLevel: bossLevel, ); // Boss 몬스터 스탯 생성 (일반 몬스터 대비 강화) final monsterCombatStats = MonsterCombatStats( name: bossName, level: bossLevel, atk: (bossStats.atk * 1.5).round(), // Boss 보정 (1.5배) def: (bossStats.def * 1.5).round(), hpMax: (bossStats.hp * 2.0).round(), // HP는 2.0배 (보스다운 전투 시간) hpCurrent: (bossStats.hp * 2.0).round(), criRate: 0.05, criDamage: 1.5, evasion: 0.0, accuracy: 0.8, attackDelayMs: 1000, expReward: (bossStats.exp * 2.5).round(), // 경험치 보상 증가 ); // 전투 상태 초기화 return CombatState.start( playerStats: playerCombatStats, monsterStats: monsterCombatStats, ); } /// 플레이어 사망 처리 (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; if (!isBossDeath) { // 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제 final equippedNonWeaponSlots = []; for (var i = 1; i < Equipment.slotCount; i++) { if (state.equipment.getItemByIndex(i).isNotEmpty) { equippedNonWeaponSlots.add(i); } } if (equippedNonWeaponSlots.isNotEmpty) { lostCount = 1; // 랜덤하게 1개 슬롯 선택 final sacrificeIndex = equippedNonWeaponSlots[state.rng.nextInt( equippedNonWeaponSlots.length, )]; final slot = EquipmentSlot.values[sacrificeIndex]; // 해당 슬롯을 빈 장비로 교체 newEquipment = newEquipment.setItemByIndex( sacrificeIndex, EquipmentItem.empty(slot), ); } } // 사망 정보 생성 (전투 로그 포함) final deathInfo = DeathInfo( cause: cause, killerName: killerName, lostEquipmentCount: lostCount, 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, ); } }