From 23f15f41d3ad9e8f7bbd7f5090e30f5e6efb3bf9 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 15 Jan 2026 17:05:46 +0900 Subject: [PATCH] =?UTF-8?q?refactor(util):=20pq=5Flogic.dart=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B6=84=ED=95=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pq_random.dart: 랜덤/확률 함수 (61줄) - pq_string.dart: 문자열 유틸리티 (55줄) - pq_item.dart: 아이템/장비 생성 (327줄) - pq_monster.dart: 몬스터 생성 (283줄) - pq_quest.dart: 퀘스트/Act/시네마틱 (283줄) - pq_task.dart: 태스크/큐 (97줄) - pq_stat.dart: 스탯 관련 (64줄) - 원본은 re-export 허브로 유지 (역호환성) --- lib/src/core/util/pq_item.dart | 327 +++++++++ lib/src/core/util/pq_logic.dart | 1120 +---------------------------- lib/src/core/util/pq_monster.dart | 283 ++++++++ lib/src/core/util/pq_quest.dart | 283 ++++++++ lib/src/core/util/pq_random.dart | 61 ++ lib/src/core/util/pq_stat.dart | 64 ++ lib/src/core/util/pq_string.dart | 55 ++ lib/src/core/util/pq_task.dart | 97 +++ 8 files changed, 1188 insertions(+), 1102 deletions(-) create mode 100644 lib/src/core/util/pq_item.dart create mode 100644 lib/src/core/util/pq_monster.dart create mode 100644 lib/src/core/util/pq_quest.dart create mode 100644 lib/src/core/util/pq_random.dart create mode 100644 lib/src/core/util/pq_stat.dart create mode 100644 lib/src/core/util/pq_string.dart create mode 100644 lib/src/core/util/pq_task.dart diff --git a/lib/src/core/util/pq_item.dart b/lib/src/core/util/pq_item.dart new file mode 100644 index 0000000..2d5fff5 --- /dev/null +++ b/lib/src/core/util/pq_item.dart @@ -0,0 +1,327 @@ +import 'dart:math' as math; + +import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/pq_config.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; +import 'package:asciineverdie/src/core/util/pq_random.dart'; + +/// 아이템/장비 생성 관련 함수들 +/// 원본 Main.pas의 아이템 생성 로직을 포팅 + +/// 장비 생성 결과 (구조화된 데이터로 l10n 지원) +class EquipResult { + const EquipResult({ + required this.baseName, + this.modifiers = const [], + this.plusValue = 0, + }); + + /// 기본 장비 이름 (예: "VPN Cloak") + final String baseName; + + /// 수식어 목록 (예: ["Holey", "Deprecated"]) + final List modifiers; + + /// +/- 수치 (예: -1, +2) + final int plusValue; + + /// 영문 전체 이름 생성 (기존 방식) + String get displayName { + var name = baseName; + for (final mod in modifiers) { + name = '$mod $name'; + } + if (plusValue != 0) { + name = '${plusValue > 0 ? '+' : ''}$plusValue $name'; + } + return name; + } +} + +/// 아이템 생성 결과 (구조화된 데이터로 l10n 지원) +class ItemResult { + const ItemResult({this.attrib, this.special, this.itemOf, this.boringItem}); + + /// 아이템 속성 (예: "Golden") + final String? attrib; + + /// 특수 아이템 (예: "Iterator") + final String? special; + + /// "~의" 접미사 (예: "Monitoring") + final String? itemOf; + + /// 단순 아이템 (보링 아이템용) + final String? boringItem; + + /// 영문 전체 이름 생성 (기존 방식) + String get displayName { + if (boringItem != null) return boringItem!; + if (attrib != null && special != null && itemOf != null) { + return '$attrib $special of $itemOf'; + } + if (attrib != null && special != null) { + return '$attrib $special'; + } + return ''; + } +} + +// ============================================================================= +// 아이템 생성 함수들 +// ============================================================================= + +/// 단순 아이템 (Boring Item) +String boringItem(PqConfig config, DeterministicRandom rng) { + return pick(config.boringItems, rng); +} + +/// 흥미로운 아이템 (Interesting Item) +String interestingItem(PqConfig config, DeterministicRandom rng) { + final attr = pick(config.itemAttrib, rng); + final special = pick(config.specials, rng); + return '$attr $special'; +} + +/// 특수 아이템 (Special Item) +String specialItem(PqConfig config, DeterministicRandom rng) { + return '${interestingItem(config, rng)} of ${pick(config.itemOfs, rng)}'; +} + +/// 구조화된 단순 아이템 결과 반환 (l10n 지원) +ItemResult boringItemStructured(PqConfig config, DeterministicRandom rng) { + return ItemResult(boringItem: pick(config.boringItems, rng)); +} + +/// 구조화된 흥미로운 아이템 결과 반환 (l10n 지원) +ItemResult interestingItemStructured(PqConfig config, DeterministicRandom rng) { + return ItemResult( + attrib: pick(config.itemAttrib, rng), + special: pick(config.specials, rng), + ); +} + +/// 구조화된 특수 아이템 결과 반환 (l10n 지원) +ItemResult specialItemStructured(PqConfig config, DeterministicRandom rng) { + return ItemResult( + attrib: pick(config.itemAttrib, rng), + special: pick(config.specials, rng), + itemOf: pick(config.itemOfs, rng), + ); +} + +/// 구조화된 승리 아이템 결과 반환 (l10n 지원) +ItemResult winItemStructured( + PqConfig config, + DeterministicRandom rng, + int inventoryCount, +) { + final threshold = math.max(250, rng.nextInt(999)); + if (inventoryCount > threshold) { + return const ItemResult(); // 빈 결과 + } + return specialItemStructured(config, rng); +} + +/// 승리 아이템 (문자열 반환) +String winItem(PqConfig config, DeterministicRandom rng, int inventoryCount) { + final threshold = math.max(250, rng.nextInt(999)); + if (inventoryCount > threshold) return ''; + return specialItem(config, rng); +} + +// ============================================================================= +// 장비 선택 함수들 +// ============================================================================= + +/// 무기 선택 +String pickWeapon(PqConfig config, DeterministicRandom rng, int level) { + return _lPick(config.weapons, rng, level); +} + +/// 방패 선택 +String pickShield(PqConfig config, DeterministicRandom rng, int level) { + return _lPick(config.shields, rng, level); +} + +/// 방어구 선택 +String pickArmor(PqConfig config, DeterministicRandom rng, int level) { + return _lPick(config.armors, rng, level); +} + +/// 주문 선택 +String pickSpell(PqConfig config, DeterministicRandom rng, int goalLevel) { + return _lPick(config.spells, rng, goalLevel); +} + +/// 원본 Main.pas:776-789 LPick: 6회 시도하여 목표 레벨에 가장 가까운 아이템 선택 +String _lPick(List items, DeterministicRandom rng, int goal) { + if (items.isEmpty) return ''; + var result = pick(items, rng); + var bestLevel = _parseLevel(result); + + for (var i = 0; i < 5; i++) { + final candidate = pick(items, rng); + final candLevel = _parseLevel(candidate); + if ((goal - candLevel).abs() < (goal - bestLevel).abs()) { + result = candidate; + bestLevel = candLevel; + } + } + return result; +} + +/// 아이템 문자열에서 레벨 파싱 +int _parseLevel(String entry) { + final parts = entry.split('|'); + if (parts.length < 2) return 0; + return int.tryParse(parts[1].replaceAll('+', '')) ?? 0; +} + +// ============================================================================= +// 장비 생성 함수들 +// ============================================================================= + +/// 수식어 추가 +String addModifier( + DeterministicRandom rng, + String baseName, + List modifiers, + int plus, +) { + var name = baseName; + var remaining = plus; + var count = 0; + + while (count < 2 && remaining != 0) { + final modifier = pick(modifiers, rng); + final parts = modifier.split('|'); + if (parts.isEmpty) break; + final label = parts[0]; + final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0; + if (name.contains(label)) break; // avoid repeats + if (remaining.abs() < qual.abs()) break; + name = '$label $name'; + remaining -= qual; + count++; + } + + if (remaining != 0) { + name = '${remaining > 0 ? '+' : ''}$remaining $name'; + } + return name; +} + +/// 장비 생성 (원본 Main.pas:791-830 WinEquip) +/// [slotIndex]: 0=Weapon, 1=Shield, 2-10=Armor 계열 +String winEquip( + PqConfig config, + DeterministicRandom rng, + int level, + int slotIndex, +) { + final bool isWeapon = slotIndex == 0; + final List items; + if (slotIndex == 0) { + items = config.weapons; + } else if (slotIndex == 1) { + items = config.shields; + } else { + items = config.armors; + } + final better = isWeapon ? config.offenseAttrib : config.defenseAttrib; + final worse = isWeapon ? config.offenseBad : config.defenseBad; + + final base = _lPick(items, rng, level); + final parts = base.split('|'); + final baseName = parts[0]; + final qual = parts.length > 1 + ? int.tryParse(parts[1].replaceAll('+', '')) ?? 0 + : 0; + + final plus = level - qual; + final modifierList = plus >= 0 ? better : worse; + return addModifier(rng, baseName, modifierList, plus); +} + +/// EquipmentSlot enum을 사용하는 편의 함수 +String winEquipBySlot( + PqConfig config, + DeterministicRandom rng, + int level, + EquipmentSlot slot, +) { + return winEquip(config, rng, level, slot.index); +} + +/// 구조화된 장비 생성 결과 반환 (l10n 지원) +EquipResult winEquipStructured( + PqConfig config, + DeterministicRandom rng, + int level, + int slotIndex, +) { + final bool isWeapon = slotIndex == 0; + final List items; + if (slotIndex == 0) { + items = config.weapons; + } else if (slotIndex == 1) { + items = config.shields; + } else { + items = config.armors; + } + final better = isWeapon ? config.offenseAttrib : config.defenseAttrib; + final worse = isWeapon ? config.offenseBad : config.defenseBad; + + final base = _lPick(items, rng, level); + final parts = base.split('|'); + final baseName = parts[0]; + final qual = parts.length > 1 + ? int.tryParse(parts[1].replaceAll('+', '')) ?? 0 + : 0; + + final plus = level - qual; + final modifierList = plus >= 0 ? better : worse; + return _addModifierStructured(rng, baseName, modifierList, plus); +} + +/// 구조화된 장비 결과 반환 (내부 함수) +EquipResult _addModifierStructured( + DeterministicRandom rng, + String baseName, + List modifiers, + int plus, +) { + final collectedModifiers = []; + var remaining = plus; + var count = 0; + + while (count < 2 && remaining != 0) { + final modifier = pick(modifiers, rng); + final parts = modifier.split('|'); + if (parts.isEmpty) break; + final label = parts[0]; + final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0; + if (collectedModifiers.contains(label)) break; // avoid repeats + if (remaining.abs() < qual.abs()) break; + collectedModifiers.add(label); + remaining -= qual; + count++; + } + + return EquipResult( + baseName: baseName, + modifiers: collectedModifiers, + plusValue: remaining, + ); +} + +/// EquipmentSlot enum을 사용하는 구조화된 버전 +EquipResult winEquipBySlotStructured( + PqConfig config, + DeterministicRandom rng, + int level, + EquipmentSlot slot, +) { + return winEquipStructured(config, rng, level, slot.index); +} diff --git a/lib/src/core/util/pq_logic.dart b/lib/src/core/util/pq_logic.dart index 2719fed..960fbc9 100644 --- a/lib/src/core/util/pq_logic.dart +++ b/lib/src/core/util/pq_logic.dart @@ -1,1109 +1,25 @@ -import 'dart:collection'; -import 'dart:math' as math; +/// Progress Quest 핵심 로직 모듈 +/// +/// 원본 Delphi 소스(Main.pas / NewGuy.pas)의 유틸리티 함수들을 포팅. +/// 이 파일은 분할된 모듈들을 re-export하여 기존 코드 호환성 유지. -import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; -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_grade.dart'; -import 'package:asciineverdie/src/core/model/pq_config.dart'; -import 'package:asciineverdie/src/core/util/deterministic_random.dart'; -import 'package:asciineverdie/src/core/util/roman.dart'; +// 랜덤/확률 함수 +export 'package:asciineverdie/src/core/util/pq_random.dart'; -// Mirrors core utility functions from the original Delphi sources (Main.pas / NewGuy.pas). +// 문자열 유틸리티 +export 'package:asciineverdie/src/core/util/pq_string.dart'; -/// 장비 생성 결과 (구조화된 데이터로 l10n 지원) -class EquipResult { - const EquipResult({ - required this.baseName, - this.modifiers = const [], - this.plusValue = 0, - }); +// 아이템/장비 생성 +export 'package:asciineverdie/src/core/util/pq_item.dart'; - /// 기본 장비 이름 (예: "VPN Cloak") - final String baseName; +// 몬스터 생성 +export 'package:asciineverdie/src/core/util/pq_monster.dart'; - /// 수식어 목록 (예: ["Holey", "Deprecated"]) - final List modifiers; +// 퀘스트/Act/시네마틱 +export 'package:asciineverdie/src/core/util/pq_quest.dart'; - /// +/- 수치 (예: -1, +2) - final int plusValue; +// 태스크/큐/레벨업 +export 'package:asciineverdie/src/core/util/pq_task.dart'; - /// 영문 전체 이름 생성 (기존 방식) - String get displayName { - var name = baseName; - for (final mod in modifiers) { - name = '$mod $name'; - } - if (plusValue != 0) { - name = '${plusValue > 0 ? '+' : ''}$plusValue $name'; - } - return name; - } -} - -/// 아이템 생성 결과 (구조화된 데이터로 l10n 지원) -class ItemResult { - const ItemResult({this.attrib, this.special, this.itemOf, this.boringItem}); - - /// 아이템 속성 (예: "Golden") - final String? attrib; - - /// 특수 아이템 (예: "Iterator") - final String? special; - - /// "~의" 접미사 (예: "Monitoring") - final String? itemOf; - - /// 단순 아이템 (보링 아이템용) - final String? boringItem; - - /// 영문 전체 이름 생성 (기존 방식) - String get displayName { - if (boringItem != null) return boringItem!; - if (attrib != null && special != null && itemOf != null) { - return '$attrib $special of $itemOf'; - } - if (attrib != null && special != null) { - return '$attrib $special'; - } - return ''; - } -} - -int levelUpTimeSeconds(int level) { - // Act 진행과 레벨업 동기화 (10시간 완주 목표) - // Act I 끝(2시간): 레벨 20, Act II 끝(5시간): 레벨 40, etc. - // 레벨 1: ~5분, 레벨 50: ~7분, 레벨 100: ~4분 (후반 가속) - if (level <= 20) { - // Act I: 레벨 1-20, 2시간 (7200초) → 평균 360초/레벨 - return 300 + (level * 6); - } else if (level <= 40) { - // Act II: 레벨 21-40, 3시간 (10800초) → 평균 540초/레벨 - return 400 + ((level - 20) * 10); - } else if (level <= 60) { - // Act III: 레벨 41-60, 3시간 (10800초) → 평균 540초/레벨 - return 400 + ((level - 40) * 10); - } else if (level <= 80) { - // Act IV: 레벨 61-80, 1.5시간 (5400초) → 평균 270초/레벨 - return 200 + ((level - 60) * 5); - } else { - // Act V: 레벨 81-100, 30분 (1800초) → 평균 90초/레벨 - return 60 + ((level - 80) * 3); - } -} - -/// 초 단위 시간을 사람이 읽기 쉬운 형태로 변환 (원본 Main.pas:1265-1271) -/// l10n 지원 -String roughTime(int seconds) { - if (seconds < 120) { - return l10n.roughTimeSeconds(seconds); - } else if (seconds < 60 * 120) { - return l10n.roughTimeMinutes(seconds ~/ 60); - } else if (seconds < 60 * 60 * 48) { - return l10n.roughTimeHours(seconds ~/ 3600); - } else { - return l10n.roughTimeDays(seconds ~/ (3600 * 24)); - } -} - -String pluralize(String s) { - if (_ends(s, 'y')) return '${s.substring(0, s.length - 1)}ies'; - if (_ends(s, 'us')) return '${s.substring(0, s.length - 2)}i'; - if (_ends(s, 'ch') || _ends(s, 'x') || _ends(s, 's')) return '${s}es'; - if (_ends(s, 'f')) return '${s.substring(0, s.length - 1)}ves'; - if (_ends(s, 'man') || _ends(s, 'Man')) { - return '${s.substring(0, s.length - 2)}en'; - } - return '${s}s'; -} - -String indefinite(String s, int qty) { - if (qty == 1) { - const vowels = 'AEIOUÜaeiouü'; - final first = s.isNotEmpty ? s[0] : 'a'; - final article = vowels.contains(first) ? 'an' : 'a'; - return '$article $s'; - } - return '$qty ${pluralize(s)}'; -} - -String definite(String s, int qty) { - if (qty > 1) { - s = pluralize(s); - } - return 'the $s'; -} - -String generateName(DeterministicRandom rng) { - const kParts = [ - 'br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z', - 'a|a|e|e|i|i|o|o|u|u|ae|ie|oo|ou', - 'b|ck|d|g|k|m|n|p|t|v|x|z', - ]; - - var result = ''; - for (var i = 0; i <= 5; i++) { - result += _pick(kParts[i % 3], rng); - } - if (result.isEmpty) return result; - return '${result[0].toUpperCase()}${result.substring(1)}'; -} - -// Random helpers -int randomLow(DeterministicRandom rng, int below) { - return math.min(rng.nextInt(below), rng.nextInt(below)); -} - -String pick(List values, DeterministicRandom rng) { - if (values.isEmpty) return ''; - return values[rng.nextInt(values.length)]; -} - -String pickLow(List values, DeterministicRandom rng) { - if (values.isEmpty) return ''; - return values[randomLow(rng, values.length)]; -} - -// Item name generators (match Main.pas) -String boringItem(PqConfig config, DeterministicRandom rng) { - return pick(config.boringItems, rng); -} - -String interestingItem(PqConfig config, DeterministicRandom rng) { - final attr = pick(config.itemAttrib, rng); - final special = pick(config.specials, rng); - return '$attr $special'; -} - -String specialItem(PqConfig config, DeterministicRandom rng) { - return '${interestingItem(config, rng)} of ${pick(config.itemOfs, rng)}'; -} - -/// 구조화된 아이템 결과 반환 (l10n 지원) -ItemResult boringItemStructured(PqConfig config, DeterministicRandom rng) { - return ItemResult(boringItem: pick(config.boringItems, rng)); -} - -/// 구조화된 아이템 결과 반환 (l10n 지원) -ItemResult interestingItemStructured(PqConfig config, DeterministicRandom rng) { - return ItemResult( - attrib: pick(config.itemAttrib, rng), - special: pick(config.specials, rng), - ); -} - -/// 구조화된 아이템 결과 반환 (l10n 지원) -ItemResult specialItemStructured(PqConfig config, DeterministicRandom rng) { - return ItemResult( - attrib: pick(config.itemAttrib, rng), - special: pick(config.specials, rng), - itemOf: pick(config.itemOfs, rng), - ); -} - -/// 구조화된 아이템 결과 반환 (l10n 지원) -ItemResult winItemStructured( - PqConfig config, - DeterministicRandom rng, - int inventoryCount, -) { - final threshold = math.max(250, rng.nextInt(999)); - if (inventoryCount > threshold) { - return const ItemResult(); // 빈 결과 - } - return specialItemStructured(config, rng); -} - -String pickWeapon(PqConfig config, DeterministicRandom rng, int level) { - return _lPick(config.weapons, rng, level); -} - -String pickShield(PqConfig config, DeterministicRandom rng, int level) { - return _lPick(config.shields, rng, level); -} - -String pickArmor(PqConfig config, DeterministicRandom rng, int level) { - return _lPick(config.armors, rng, level); -} - -String pickSpell(PqConfig config, DeterministicRandom rng, int goalLevel) { - return _lPick(config.spells, rng, goalLevel); -} - -/// 원본 Main.pas:776-789 LPick: 6회 시도하여 목표 레벨에 가장 가까운 아이템 선택 -String _lPick(List items, DeterministicRandom rng, int goal) { - if (items.isEmpty) return ''; - var result = pick(items, rng); - var bestLevel = _parseLevel(result); - - for (var i = 0; i < 5; i++) { - final candidate = pick(items, rng); - final candLevel = _parseLevel(candidate); - if ((goal - candLevel).abs() < (goal - bestLevel).abs()) { - result = candidate; - bestLevel = candLevel; - } - } - return result; -} - -int _parseLevel(String entry) { - final parts = entry.split('|'); - if (parts.length < 2) return 0; - return int.tryParse(parts[1].replaceAll('+', '')) ?? 0; -} - -String addModifier( - DeterministicRandom rng, - String baseName, - List modifiers, - int plus, -) { - var name = baseName; - var remaining = plus; - var count = 0; - - while (count < 2 && remaining != 0) { - final modifier = pick(modifiers, rng); - final parts = modifier.split('|'); - if (parts.isEmpty) break; - final label = parts[0]; - final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0; - if (name.contains(label)) break; // avoid repeats - if (remaining.abs() < qual.abs()) break; - name = '$label $name'; - remaining -= qual; - count++; - } - - if (remaining != 0) { - name = '${remaining > 0 ? '+' : ''}$remaining $name'; - } - return name; -} - -// Character/stat growth -int levelUpTime(int level) => levelUpTimeSeconds(level); - -String winSpell( - PqConfig config, - DeterministicRandom rng, - int wisdom, - int level, -) { - // 원본 Main.pas:770-774: RandomLow로 인덱스 선택 (리스트 앞쪽 선호) - final maxIndex = math.min(wisdom + level, config.spells.length); - if (maxIndex <= 0) return ''; - final index = randomLow(rng, maxIndex); - final entry = config.spells[index]; - final parts = entry.split('|'); - final name = parts[0]; - final currentRank = romanToInt(parts.length > 1 ? parts[1] : 'I'); - final nextRank = math.max(1, currentRank + 1); - return '$name|${intToRoman(nextRank)}'; -} - -String winItem(PqConfig config, DeterministicRandom rng, int inventoryCount) { - // If inventory is already very large, signal caller to duplicate an existing item. - final threshold = math.max(250, rng.nextInt(999)); - if (inventoryCount > threshold) return ''; - return specialItem(config, rng); -} - -int rollStat(DeterministicRandom rng) { - // 3d6 roll. - return 3 + rng.nextInt(6) + rng.nextInt(6) + rng.nextInt(6); -} - -int random64Below(DeterministicRandom rng, int below) { - if (below <= 0) return 0; - final hi = rng.nextUint32(); - final lo = rng.nextUint32(); - final combined = (hi << 32) | lo; - return (combined % below).toInt(); -} - -/// 장비 생성 (원본 Main.pas:791-830 WinEquip) -/// [slotIndex]: 0=Weapon, 1=Shield, 2-10=Armor 계열 -String winEquip( - PqConfig config, - DeterministicRandom rng, - int level, - int slotIndex, -) { - // 원본 로직: - // posn = 0: Weapon → K.Weapons, OffenseAttrib - // posn = 1: Shield → K.Shields, DefenseAttrib - // posn >= 2: Armor → K.Armors, DefenseAttrib - final bool isWeapon = slotIndex == 0; - final List items; - if (slotIndex == 0) { - items = config.weapons; - } else if (slotIndex == 1) { - items = config.shields; - } else { - items = config.armors; - } - final better = isWeapon ? config.offenseAttrib : config.defenseAttrib; - final worse = isWeapon ? config.offenseBad : config.defenseBad; - - final base = _lPick(items, rng, level); - final parts = base.split('|'); - final baseName = parts[0]; - final qual = parts.length > 1 - ? int.tryParse(parts[1].replaceAll('+', '')) ?? 0 - : 0; - - final plus = level - qual; - final modifiers = plus >= 0 ? better : worse; - return addModifier(rng, baseName, modifiers, plus); -} - -/// EquipmentSlot enum을 사용하는 편의 함수 -String winEquipBySlot( - PqConfig config, - DeterministicRandom rng, - int level, - EquipmentSlot slot, -) { - return winEquip(config, rng, level, slot.index); -} - -/// 구조화된 장비 생성 결과 반환 (l10n 지원) -EquipResult winEquipStructured( - PqConfig config, - DeterministicRandom rng, - int level, - int slotIndex, -) { - final bool isWeapon = slotIndex == 0; - final List items; - if (slotIndex == 0) { - items = config.weapons; - } else if (slotIndex == 1) { - items = config.shields; - } else { - items = config.armors; - } - final better = isWeapon ? config.offenseAttrib : config.defenseAttrib; - final worse = isWeapon ? config.offenseBad : config.defenseBad; - - final base = _lPick(items, rng, level); - final parts = base.split('|'); - final baseName = parts[0]; - final qual = parts.length > 1 - ? int.tryParse(parts[1].replaceAll('+', '')) ?? 0 - : 0; - - final plus = level - qual; - final modifierList = plus >= 0 ? better : worse; - return _addModifierStructured(rng, baseName, modifierList, plus); -} - -/// 구조화된 장비 결과 반환 (내부 함수) -EquipResult _addModifierStructured( - DeterministicRandom rng, - String baseName, - List modifiers, - int plus, -) { - final collectedModifiers = []; - var remaining = plus; - var count = 0; - - while (count < 2 && remaining != 0) { - final modifier = pick(modifiers, rng); - final parts = modifier.split('|'); - if (parts.isEmpty) break; - final label = parts[0]; - final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0; - if (collectedModifiers.contains(label)) break; // avoid repeats - if (remaining.abs() < qual.abs()) break; - collectedModifiers.add(label); - remaining -= qual; - count++; - } - - return EquipResult( - baseName: baseName, - modifiers: collectedModifiers, - plusValue: remaining, - ); -} - -/// EquipmentSlot enum을 사용하는 구조화된 버전 -EquipResult winEquipBySlotStructured( - PqConfig config, - DeterministicRandom rng, - int level, - EquipmentSlot slot, -) { - return winEquipStructured(config, rng, level, slot.index); -} - -int winStatIndex(DeterministicRandom rng, List statValues) { - // 원본 Main.pas:870-883 - // 50%: 모든 8개 스탯 중 랜덤 - // 50%: 첫 6개(STR~CHA)만 제곱 가중치로 선택 - if (rng.nextInt(2) == 0) { - // Odds(1,2): 모든 스탯 중 완전 랜덤 선택 - return rng.nextInt(statValues.length); - } - // 원본: for i := 0 to 5 do Inc(t, Square(GetI(Stats,i))); - // 첫 6개(STR, CON, DEX, INT, WIS, CHA)만 제곱 가중치 적용 - const firstSixCount = 6; - var total = 0; - for (var i = 0; i < firstSixCount && i < statValues.length; i++) { - total += statValues[i] * statValues[i]; - } - if (total == 0) return rng.nextInt(firstSixCount); - var pickValue = random64Below(rng, total); - for (var i = 0; i < firstSixCount; i++) { - pickValue -= statValues[i] * statValues[i]; - if (pickValue < 0) return i; - } - return firstSixCount - 1; -} - -Stats winStat(Stats stats, DeterministicRandom rng) { - final values = [ - stats.str, - stats.con, - stats.dex, - stats.intelligence, - stats.wis, - stats.cha, - stats.hpMax, - stats.mpMax, - ]; - final idx = winStatIndex(rng, values); - switch (idx) { - case 0: - return stats.copyWith(str: stats.str + 1); - case 1: - return stats.copyWith(con: stats.con + 1); - case 2: - return stats.copyWith(dex: stats.dex + 1); - case 3: - return stats.copyWith(intelligence: stats.intelligence + 1); - case 4: - return stats.copyWith(wis: stats.wis + 1); - case 5: - return stats.copyWith(cha: stats.cha + 1); - case 6: - return stats.copyWith(hpMax: stats.hpMax + 1); - case 7: - return stats.copyWith(mpMax: stats.mpMax + 1); - default: - return stats; - } -} - -/// 몬스터 등급 결정 (Normal 85%, Elite 12%, Boss 3%) -/// 몬스터 레벨이 플레이어 레벨보다 높으면 상위 등급 확률 증가 -MonsterGrade _determineGrade( - int monsterLevel, - int playerLevel, - DeterministicRandom rng, -) { - // 기본 확률: Normal 85%, Elite 12%, Boss 3% - // 레벨 차이에 따른 보정 - final levelDiff = monsterLevel - playerLevel; - final eliteBonus = (levelDiff * 2).clamp(0, 10); // 최대 +10% - final bossBonus = (levelDiff * 0.5).clamp(0, 3).toInt(); // 최대 +3% - - final roll = rng.nextInt(100); - if (roll < 3 + bossBonus) return MonsterGrade.boss; - if (roll < 15 + eliteBonus) return MonsterGrade.elite; - return MonsterGrade.normal; -} - -MonsterTaskResult monsterTask( - PqConfig config, - DeterministicRandom rng, - int level, - String? questMonster, // optional monster name from quest - int? questLevel, -) { - var targetLevel = level; - - for (var i = level; i > 0; i--) { - if (rng.nextInt(5) < 2) { - targetLevel += rng.nextInt(2) * 2 - 1; // RandSign - } - } - if (targetLevel < 1) targetLevel = 1; - - String monster; - int monsterLevel; - String part; - bool definite = false; - - // 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용 - if (rng.nextInt(25) == 0) { - final raceEn = pick(config.races, rng).split('|').first; - final race = l10n.translateRace(raceEn); - if (rng.nextInt(2) == 0) { - // 'passing Race Class' 형태 - final klassEn = pick(config.klasses, rng).split('|').first; - final klass = l10n.translateKlass(klassEn); - monster = l10n.modifierPassing('$race $klass'); - } else { - // 'Title Name the Race' 형태 (원본은 PickLow(Titles) 사용) - final titleEn = pickLow(config.titles, rng); - final title = l10n.translateTitle(titleEn); - monster = l10n.namedMonsterFormat(generateName(rng), '$title $race'); - definite = true; - } - monsterLevel = targetLevel; - part = '*'; // NPC는 WinItem 호출 - } else if (questMonster != null && rng.nextInt(4) == 0) { - // Use quest monster. - monster = questMonster; - final parts = questMonster.split('|'); - monsterLevel = questLevel ?? targetLevel; - part = parts.length > 2 ? parts[2] : ''; - } else { - // Pick closest level among random samples. - monster = pick(config.monsters, rng); - monsterLevel = _monsterLevel(monster); - for (var i = 0; i < 5; i++) { - final candidate = pick(config.monsters, rng); - final candLevel = _monsterLevel(candidate); - if ((targetLevel - candLevel).abs() < - (targetLevel - monsterLevel).abs()) { - monster = candidate; - monsterLevel = candLevel; - } - } - // 몬스터 데이터에서 부위 정보 추출 (예: "Rat|0|tail") - final monsterParts = monster.split('|'); - part = monsterParts.length > 2 ? monsterParts[2] : ''; - } - - // 기본 몬스터 이름 (부위 정보 제외) - final baseName = monster.split('|').first; - - // Adjust quantity and adjectives based on level delta. - var qty = 1; - final levelDiff = targetLevel - monsterLevel; - // 몬스터 이름 번역 (l10n 지원) - var name = l10n.translateMonster(baseName); - - if (levelDiff > 10) { - qty = - (targetLevel + rng.nextInt(monsterLevel == 0 ? 1 : monsterLevel)) ~/ - (monsterLevel == 0 ? 1 : monsterLevel); - if (qty < 1) qty = 1; - targetLevel ~/= qty; - } - - if (levelDiff <= -10) { - name = l10n.modifierImaginary(name); - } else if (levelDiff < -5) { - final i = 5 - rng.nextInt(10 + levelDiff + 1); - name = _sick(i, _young((monsterLevel - targetLevel) - i, name)); - } else if (levelDiff < 0) { - if (rng.nextInt(2) == 1) { - name = _sick(levelDiff, name); - } else { - name = _young(levelDiff, name); - } - } else if (levelDiff >= 10) { - name = l10n.modifierMessianic(name); - } else if (levelDiff > 5) { - final i = 5 - rng.nextInt(10 - levelDiff + 1); - name = _big(i, _special((levelDiff) - i, name)); - } else if (levelDiff > 0) { - if (rng.nextInt(2) == 1) { - name = _big(levelDiff, name); - } else { - name = _special(levelDiff, name); - } - } - - if (!definite) { - // l10n 지원: 한국어/일본어에서는 관사 불필요 - name = l10n.indefiniteL10n(name, qty); - } - - // 몬스터 등급 결정 (level = 플레이어 레벨) - final grade = _determineGrade(monsterLevel, level, rng); - - return MonsterTaskResult( - displayName: name, - baseName: baseName, - level: monsterLevel * qty, - part: part, - grade: grade, - ); -} - -/// monsterTask의 반환 타입 (원본 fTask.Caption 정보 포함) -class MonsterTaskResult { - const MonsterTaskResult({ - required this.displayName, - required this.baseName, - required this.level, - required this.part, - required this.grade, - }); - - /// 화면에 표시할 몬스터 이름 (형용사 포함, 예: "a sick Goblin") - final String displayName; - - /// 기본 몬스터 이름 (형용사 제외, 예: "Goblin") - final String baseName; - - /// 몬스터 레벨 - final int level; - - /// 전리품 부위 (예: "claw", "tail", "*"는 WinItem 호출) - final String part; - - /// 몬스터 등급 (Normal/Elite/Boss) - final MonsterGrade grade; -} - -enum RewardKind { spell, equip, stat, item } - -class QuestResult { - const QuestResult({ - required this.caption, - required this.reward, - this.monsterName, - this.monsterLevel, - this.monsterIndex, - }); - - final String caption; - final RewardKind reward; - - /// 몬스터 전체 데이터 (예: "Rat|5|tail") - 원본 fQuest.Caption - final String? monsterName; - - /// 몬스터 레벨 (파싱된 값) - final int? monsterLevel; - - /// 몬스터 인덱스 (config.monsters에서의 위치) - 원본 fQuest.Tag - final int? monsterIndex; -} - -QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) { - final rewardRoll = rng.nextInt(4); - final reward = switch (rewardRoll) { - 0 => RewardKind.spell, - 1 => RewardKind.equip, - 2 => RewardKind.stat, - _ => RewardKind.item, - }; - - final questRoll = rng.nextInt(5); - switch (questRoll) { - case 0: - // Exterminate: 4번 시도하여 레벨에 가장 가까운 몬스터 선택 - // 원본 Main.pas:936-954 - var best = ''; - var bestLevel = 0; - var bestIndex = 0; - for (var i = 0; i < 4; i++) { - final monsterIndex = rng.nextInt(config.monsters.length); - final m = config.monsters[monsterIndex]; - final l = _monsterLevel(m); - if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) { - best = m; - bestLevel = l; - bestIndex = monsterIndex; - } - } - final nameEn = best.split('|').first; - final name = l10n.translateMonster(nameEn); - return QuestResult( - caption: l10n.questPatch(l10n.definiteL10n(name, 2)), - reward: reward, - monsterName: best, - monsterLevel: bestLevel, - monsterIndex: bestIndex, - ); - case 1: - // interestingItem: attrib + special 조합 후 번역 - final attr = pick(config.itemAttrib, rng); - final special = pick(config.specials, rng); - final item = l10n.translateInterestingItem(attr, special); - return QuestResult( - caption: l10n.questLocate(l10n.definiteL10n(item, 1)), - reward: reward, - ); - case 2: - final itemEn = boringItem(config, rng); - final item = l10n.translateBoringItem(itemEn); - return QuestResult(caption: l10n.questTransfer(item), reward: reward); - case 3: - final itemEn = boringItem(config, rng); - final item = l10n.translateBoringItem(itemEn); - return QuestResult( - caption: l10n.questDownload(l10n.indefiniteL10n(item, 1)), - reward: reward, - ); - default: - // Stabilize: 2번 시도하여 레벨에 가장 가까운 몬스터 선택 - // 원본 Main.pas:971-984 (fQuest.Caption := '' 처리됨) - var best = ''; - var bestLevel = 0; - for (var i = 0; i < 2; i++) { - final m = pick(config.monsters, rng); - final l = _monsterLevel(m); - if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) { - best = m; - bestLevel = l; - } - } - final nameEn = best.split('|').first; - final name = l10n.translateMonster(nameEn); - // Stabilize는 fQuest.Caption := '' 로 비움 → monsterIndex 미저장 - return QuestResult( - caption: l10n.questStabilize(l10n.definiteL10n(name, 2)), - reward: reward, - ); - } -} - -class ActResult { - const ActResult({ - required this.actTitle, - required this.plotBarMaxSeconds, - required this.rewards, - }); - - final String actTitle; - final int plotBarMaxSeconds; - final List rewards; -} - -/// Act별 Plot Bar 최대값 (초) - 10시간 완주 목표 -const _actPlotBarSeconds = [ - 300, // Prologue: 5분 - 7200, // Act I: 2시간 - 10800, // Act II: 3시간 - 10800, // Act III: 3시간 - 5400, // Act IV: 1.5시간 - 1800, // Act V: 30분 -]; - -ActResult completeAct(int existingActCount) { - final nextActIndex = existingActCount; - final title = l10n.actTitle(intToRoman(nextActIndex)); - final plotBarMax = existingActCount < _actPlotBarSeconds.length - ? _actPlotBarSeconds[existingActCount] - : 3600; - - final rewards = []; - // 프롤로그 완료 시(existingActCount=1)부터 장비 보상 지급 - // 원본: existingActCount > 2 (Act II 이후) - // 수정: existingActCount >= 1 (프롤로그 완료 후) - if (existingActCount >= 1) { - rewards.add(RewardKind.equip); - } - if (existingActCount > 1) { - rewards.add(RewardKind.item); - } - - return ActResult( - actTitle: title, - plotBarMaxSeconds: plotBarMax, - rewards: rewards, - ); -} - -class TaskResult { - const TaskResult({ - required this.caption, - required this.durationMillis, - required this.progress, - }); - - final String caption; - final int durationMillis; - final ProgressState progress; -} - -/// Starts a task: resets task bar and sets caption. -TaskResult startTask( - ProgressState progress, - String caption, - int durationMillis, -) { - final updated = progress.copyWith( - task: ProgressBarState(position: 0, max: durationMillis), - ); - return TaskResult( - caption: '$caption...', - durationMillis: durationMillis, - progress: updated, - ); -} - -class DequeueResult { - const DequeueResult({ - required this.progress, - required this.queue, - required this.caption, - required this.taskType, - required this.kind, - }); - - final ProgressState progress; - final QueueState queue; - final String caption; - final TaskType taskType; - final QueueKind kind; -} - -/// Process the queue when current task is done. Returns null if nothing to do. -DequeueResult? dequeue(ProgressState progress, QueueState queue) { - // Only act when the task bar is finished. - if (progress.task.position < progress.task.max) return null; - if (queue.entries.isEmpty) return null; - - final entries = Queue.from(queue.entries); - if (entries.isEmpty) return null; - - final next = entries.removeFirst(); - final taskResult = startTask(progress, next.caption, next.durationMillis); - return DequeueResult( - progress: taskResult.progress, - queue: QueueState(entries: entries.toList()), - caption: taskResult.caption, - taskType: next.taskType, - kind: next.kind, - ); -} - -int _monsterLevel(String entry) { - final parts = entry.split('|'); - if (parts.length < 2) return 0; - return int.tryParse(parts[1]) ?? 0; -} - -String _sick(int m, String s) { - switch (m) { - case -5: - case 5: - return l10n.modifierDead(s); - case -4: - case 4: - return l10n.modifierComatose(s); - case -3: - case 3: - return l10n.modifierCrippled(s); - case -2: - case 2: - return l10n.modifierSick(s); - case -1: - case 1: - return l10n.modifierUndernourished(s); - default: - return '$m$s'; - } -} - -String _young(int m, String s) { - switch (-m) { - case -5: - case 5: - return l10n.modifierFoetal(s); - case -4: - case 4: - return l10n.modifierBaby(s); - case -3: - case 3: - return l10n.modifierPreadolescent(s); - case -2: - case 2: - return l10n.modifierTeenage(s); - case -1: - case 1: - return l10n.modifierUnderage(s); - default: - return '$m$s'; - } -} - -String _big(int m, String s) { - switch (m) { - case 1: - case -1: - return l10n.modifierGreater(s); - case 2: - case -2: - return l10n.modifierMassive(s); - case 3: - case -3: - return l10n.modifierEnormous(s); - case 4: - case -4: - return l10n.modifierGiant(s); - case 5: - case -5: - return l10n.modifierTitanic(s); - default: - return s; - } -} - -String _special(int m, String s) { - switch (-m) { - case 1: - case -1: - return s.contains(' ') ? l10n.modifierVeteran(s) : l10n.modifierBattle(s); - case 2: - case -2: - return l10n.modifierCursed(s); - case 3: - case -3: - return s.contains(' ') ? l10n.modifierWarrior(s) : l10n.modifierWere(s); - case 4: - case -4: - return l10n.modifierUndead(s); - case 5: - case -5: - return l10n.modifierDemon(s); - default: - return s; - } -} - -bool _ends(String s, String suffix) { - return s.length >= suffix.length && - s.substring(s.length - suffix.length) == suffix; -} - -String _pick(String pipeSeparated, DeterministicRandom rng) { - final parts = pipeSeparated.split('|'); - if (parts.isEmpty) return ''; - final idx = rng.nextInt(parts.length); - return parts[idx]; -} - -// ============================================================================= -// InterplotCinematic 관련 함수들 (Main.pas:456-521) -// ============================================================================= - -/// NPC 이름 생성 (ImpressiveGuy, Main.pas:514-521) -/// 인상적인 타이틀 + 종족 또는 이름 조합 (l10n 지원) -String impressiveGuy(PqConfig config, DeterministicRandom rng) { - final titleEn = pick(config.impressiveTitles, rng); - final title = l10n.translateImpressiveTitle(titleEn); - switch (rng.nextInt(2)) { - case 0: - // "the King of the Elves" / "엘프들의 왕" 형태 - final raceEn = pick(config.races, rng).split('|').first; - final race = l10n.translateRace(raceEn); - return l10n.impressiveGuyPattern1(title, race); - case 1: - // "King Vrognak of Zoxzik" / "Zoxzik의 왕 Vrognak" 형태 - final name1 = generateName(rng); - final name2 = generateName(rng); - return l10n.impressiveGuyPattern2(title, name1, name2); - default: - return title; - } -} - -/// 이름 있는 몬스터 생성 (NamedMonster, Main.pas:498-512) -/// 레벨에 맞는 몬스터 선택 후 GenerateName으로 이름 붙이기 (l10n 지원) -String namedMonster(PqConfig config, DeterministicRandom rng, int level) { - String best = ''; - int bestLevel = 0; - - // 5번 시도해서 레벨에 가장 가까운 몬스터 선택 - for (var i = 0; i < 5; i++) { - final m = pick(config.monsters, rng); - final parts = m.split('|'); - final name = parts.first; - final lev = parts.length > 1 ? (int.tryParse(parts[1]) ?? 0) : 0; - - if (best.isEmpty || (level - lev).abs() < (level - bestLevel).abs()) { - best = name; - bestLevel = lev; - } - } - - // 몬스터 이름 번역 - final translatedMonster = l10n.translateMonster(best); - // "GeneratedName the MonsterType" / "몬스터타입 GeneratedName" 형태 - return l10n.namedMonsterFormat(generateName(rng), translatedMonster); -} - -/// 플롯 간 시네마틱 이벤트 생성 (InterplotCinematic, Main.pas:456-495) -/// 3가지 시나리오 중 하나를 랜덤 선택 (l10n 지원) -List interplotCinematic( - PqConfig config, - DeterministicRandom rng, - int level, - int plotCount, -) { - final entries = []; - - // 헬퍼: 큐 엔트리 추가 (원본의 Q 함수 역할) - void q(QueueKind kind, int seconds, String caption) { - entries.add( - QueueEntry(kind: kind, durationMillis: seconds * 1000, caption: caption), - ); - } - - switch (rng.nextInt(3)) { - case 0: - // 시나리오 1: 안전한 캐시 영역 도착 - q(QueueKind.task, 1, l10n.cinematicCacheZone1()); - q(QueueKind.task, 2, l10n.cinematicCacheZone2()); - q(QueueKind.task, 2, l10n.cinematicCacheZone3()); - q(QueueKind.task, 1, l10n.cinematicCacheZone4()); - break; - - case 1: - // 시나리오 2: 강력한 버그와의 전투 - q(QueueKind.task, 1, l10n.cinematicCombat1()); - final nemesis = namedMonster(config, rng, level + 3); - q(QueueKind.task, 4, l10n.cinematicCombat2(nemesis)); - - var s = rng.nextInt(3); - final combatRounds = rng.nextInt(1 + plotCount); - for (var i = 0; i < combatRounds; i++) { - s += 1 + rng.nextInt(2); - switch (s % 3) { - case 0: - q(QueueKind.task, 2, l10n.cinematicCombatLocked(nemesis)); - break; - case 1: - q(QueueKind.task, 2, l10n.cinematicCombatCorrupts(nemesis)); - break; - case 2: - q(QueueKind.task, 2, l10n.cinematicCombatWorking(nemesis)); - break; - } - } - - q(QueueKind.task, 3, l10n.cinematicCombatVictory(nemesis)); - q(QueueKind.task, 2, l10n.cinematicCombatWakeUp()); - break; - - case 2: - // 시나리오 3: 내부자 위협 발견 - final guy = impressiveGuy(config, rng); - final itemEn = boringItem(config, rng); - final item = l10n.translateBoringItem(itemEn); - q(QueueKind.task, 2, l10n.cinematicBetrayal1(guy)); - q(QueueKind.task, 3, l10n.cinematicBetrayal2(guy)); - q(QueueKind.task, 2, l10n.cinematicBetrayal3(item)); - q(QueueKind.task, 2, l10n.cinematicBetrayal4()); - q(QueueKind.task, 2, l10n.cinematicBetrayal5(guy)); - q(QueueKind.task, 3, l10n.cinematicBetrayal6()); - break; - } - - // 마지막에 plot 추가 - q(QueueKind.plot, 2, l10n.taskCompiling); - - return entries; -} +// 스탯 관련 +export 'package:asciineverdie/src/core/util/pq_stat.dart'; diff --git a/lib/src/core/util/pq_monster.dart b/lib/src/core/util/pq_monster.dart new file mode 100644 index 0000000..dd17e37 --- /dev/null +++ b/lib/src/core/util/pq_monster.dart @@ -0,0 +1,283 @@ +import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/src/core/model/monster_grade.dart'; +import 'package:asciineverdie/src/core/model/pq_config.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; +import 'package:asciineverdie/src/core/util/pq_random.dart'; + +/// 몬스터 생성 관련 함수들 +/// 원본 Main.pas의 몬스터 생성 로직을 포팅 + +/// monsterTask의 반환 타입 (원본 fTask.Caption 정보 포함) +class MonsterTaskResult { + const MonsterTaskResult({ + required this.displayName, + required this.baseName, + required this.level, + required this.part, + required this.grade, + }); + + /// 화면에 표시할 몬스터 이름 (형용사 포함, 예: "a sick Goblin") + final String displayName; + + /// 기본 몬스터 이름 (형용사 제외, 예: "Goblin") + final String baseName; + + /// 몬스터 레벨 + final int level; + + /// 전리품 부위 (예: "claw", "tail", "*"는 WinItem 호출) + final String part; + + /// 몬스터 등급 (Normal/Elite/Boss) + final MonsterGrade grade; +} + +/// 몬스터 등급 결정 (Normal 85%, Elite 12%, Boss 3%) +/// 몬스터 레벨이 플레이어 레벨보다 높으면 상위 등급 확률 증가 +MonsterGrade _determineGrade( + int monsterLevel, + int playerLevel, + DeterministicRandom rng, +) { + final levelDiff = monsterLevel - playerLevel; + final eliteBonus = (levelDiff * 2).clamp(0, 10); + final bossBonus = (levelDiff * 0.5).clamp(0, 3).toInt(); + + final roll = rng.nextInt(100); + if (roll < 3 + bossBonus) return MonsterGrade.boss; + if (roll < 15 + eliteBonus) return MonsterGrade.elite; + return MonsterGrade.normal; +} + +/// 몬스터 문자열에서 레벨 파싱 +int _monsterLevel(String entry) { + final parts = entry.split('|'); + if (parts.length < 2) return 0; + return int.tryParse(parts[1]) ?? 0; +} + +/// 몬스터 태스크 생성 (원본 Main.pas:523-640) +MonsterTaskResult monsterTask( + PqConfig config, + DeterministicRandom rng, + int level, + String? questMonster, + int? questLevel, +) { + var targetLevel = level; + + for (var i = level; i > 0; i--) { + if (rng.nextInt(5) < 2) { + targetLevel += rng.nextInt(2) * 2 - 1; // RandSign + } + } + if (targetLevel < 1) targetLevel = 1; + + String monster; + int monsterLevel; + String part; + bool definite = false; + + // 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용 + if (rng.nextInt(25) == 0) { + final raceEn = pick(config.races, rng).split('|').first; + final race = l10n.translateRace(raceEn); + if (rng.nextInt(2) == 0) { + final klassEn = pick(config.klasses, rng).split('|').first; + final klass = l10n.translateKlass(klassEn); + monster = l10n.modifierPassing('$race $klass'); + } else { + final titleEn = pickLow(config.titles, rng); + final title = l10n.translateTitle(titleEn); + monster = l10n.namedMonsterFormat(generateName(rng), '$title $race'); + definite = true; + } + monsterLevel = targetLevel; + part = '*'; + } else if (questMonster != null && rng.nextInt(4) == 0) { + monster = questMonster; + final parts = questMonster.split('|'); + monsterLevel = questLevel ?? targetLevel; + part = parts.length > 2 ? parts[2] : ''; + } else { + monster = pick(config.monsters, rng); + monsterLevel = _monsterLevel(monster); + for (var i = 0; i < 5; i++) { + final candidate = pick(config.monsters, rng); + final candLevel = _monsterLevel(candidate); + if ((targetLevel - candLevel).abs() < + (targetLevel - monsterLevel).abs()) { + monster = candidate; + monsterLevel = candLevel; + } + } + final monsterParts = monster.split('|'); + part = monsterParts.length > 2 ? monsterParts[2] : ''; + } + + final baseName = monster.split('|').first; + + var qty = 1; + final levelDiff = targetLevel - monsterLevel; + var name = l10n.translateMonster(baseName); + + if (levelDiff > 10) { + qty = + (targetLevel + rng.nextInt(monsterLevel == 0 ? 1 : monsterLevel)) ~/ + (monsterLevel == 0 ? 1 : monsterLevel); + if (qty < 1) qty = 1; + targetLevel ~/= qty; + } + + if (levelDiff <= -10) { + name = l10n.modifierImaginary(name); + } else if (levelDiff < -5) { + final i = 5 - rng.nextInt(10 + levelDiff + 1); + name = _sick(i, _young((monsterLevel - targetLevel) - i, name)); + } else if (levelDiff < 0) { + if (rng.nextInt(2) == 1) { + name = _sick(levelDiff, name); + } else { + name = _young(levelDiff, name); + } + } else if (levelDiff >= 10) { + name = l10n.modifierMessianic(name); + } else if (levelDiff > 5) { + final i = 5 - rng.nextInt(10 - levelDiff + 1); + name = _big(i, _special((levelDiff) - i, name)); + } else if (levelDiff > 0) { + if (rng.nextInt(2) == 1) { + name = _big(levelDiff, name); + } else { + name = _special(levelDiff, name); + } + } + + if (!definite) { + name = l10n.indefiniteL10n(name, qty); + } + + final grade = _determineGrade(monsterLevel, level, rng); + + return MonsterTaskResult( + displayName: name, + baseName: baseName, + level: monsterLevel * qty, + part: part, + grade: grade, + ); +} + +/// 이름 있는 몬스터 생성 (NamedMonster, Main.pas:498-512) +String namedMonster(PqConfig config, DeterministicRandom rng, int level) { + String best = ''; + int bestLevel = 0; + + for (var i = 0; i < 5; i++) { + final m = pick(config.monsters, rng); + final parts = m.split('|'); + final name = parts.first; + final lev = parts.length > 1 ? (int.tryParse(parts[1]) ?? 0) : 0; + + if (best.isEmpty || (level - lev).abs() < (level - bestLevel).abs()) { + best = name; + bestLevel = lev; + } + } + + final translatedMonster = l10n.translateMonster(best); + return l10n.namedMonsterFormat(generateName(rng), translatedMonster); +} + +// ============================================================================= +// 몬스터 수식어 함수들 +// ============================================================================= + +String _sick(int m, String s) { + switch (m) { + case -5: + case 5: + return l10n.modifierDead(s); + case -4: + case 4: + return l10n.modifierComatose(s); + case -3: + case 3: + return l10n.modifierCrippled(s); + case -2: + case 2: + return l10n.modifierSick(s); + case -1: + case 1: + return l10n.modifierUndernourished(s); + default: + return '$m$s'; + } +} + +String _young(int m, String s) { + switch (-m) { + case -5: + case 5: + return l10n.modifierFoetal(s); + case -4: + case 4: + return l10n.modifierBaby(s); + case -3: + case 3: + return l10n.modifierPreadolescent(s); + case -2: + case 2: + return l10n.modifierTeenage(s); + case -1: + case 1: + return l10n.modifierUnderage(s); + default: + return '$m$s'; + } +} + +String _big(int m, String s) { + switch (m) { + case 1: + case -1: + return l10n.modifierGreater(s); + case 2: + case -2: + return l10n.modifierMassive(s); + case 3: + case -3: + return l10n.modifierEnormous(s); + case 4: + case -4: + return l10n.modifierGiant(s); + case 5: + case -5: + return l10n.modifierTitanic(s); + default: + return s; + } +} + +String _special(int m, String s) { + switch (-m) { + case 1: + case -1: + return s.contains(' ') ? l10n.modifierVeteran(s) : l10n.modifierBattle(s); + case 2: + case -2: + return l10n.modifierCursed(s); + case 3: + case -3: + return s.contains(' ') ? l10n.modifierWarrior(s) : l10n.modifierWere(s); + case 4: + case -4: + return l10n.modifierUndead(s); + case 5: + case -5: + return l10n.modifierDemon(s); + default: + return s; + } +} diff --git a/lib/src/core/util/pq_quest.dart b/lib/src/core/util/pq_quest.dart new file mode 100644 index 0000000..9de814a --- /dev/null +++ b/lib/src/core/util/pq_quest.dart @@ -0,0 +1,283 @@ +import 'dart:math' as math; + +import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/pq_config.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; +import 'package:asciineverdie/src/core/util/pq_item.dart'; +import 'package:asciineverdie/src/core/util/pq_monster.dart'; +import 'package:asciineverdie/src/core/util/pq_random.dart'; +import 'package:asciineverdie/src/core/util/roman.dart'; + +/// 퀘스트/Act/시네마틱 관련 함수들 +/// 원본 Main.pas의 퀘스트 로직을 포팅 + +/// 보상 종류 +enum RewardKind { spell, equip, stat, item } + +/// 퀘스트 결과 +class QuestResult { + const QuestResult({ + required this.caption, + required this.reward, + this.monsterName, + this.monsterLevel, + this.monsterIndex, + }); + + final String caption; + final RewardKind reward; + + /// 몬스터 전체 데이터 (예: "Rat|5|tail") - 원본 fQuest.Caption + final String? monsterName; + + /// 몬스터 레벨 (파싱된 값) + final int? monsterLevel; + + /// 몬스터 인덱스 (config.monsters에서의 위치) - 원본 fQuest.Tag + final int? monsterIndex; +} + +/// Act 결과 +class ActResult { + const ActResult({ + required this.actTitle, + required this.plotBarMaxSeconds, + required this.rewards, + }); + + final String actTitle; + final int plotBarMaxSeconds; + final List rewards; +} + +/// 몬스터 문자열에서 레벨 파싱 +int _monsterLevel(String entry) { + final parts = entry.split('|'); + if (parts.length < 2) return 0; + return int.tryParse(parts[1]) ?? 0; +} + +/// 퀘스트 완료 처리 (원본 Main.pas:930-984) +QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) { + final rewardRoll = rng.nextInt(4); + final reward = switch (rewardRoll) { + 0 => RewardKind.spell, + 1 => RewardKind.equip, + 2 => RewardKind.stat, + _ => RewardKind.item, + }; + + final questRoll = rng.nextInt(5); + switch (questRoll) { + case 0: + // Exterminate: 4번 시도하여 레벨에 가장 가까운 몬스터 선택 + var best = ''; + var bestLevel = 0; + var bestIndex = 0; + for (var i = 0; i < 4; i++) { + final monsterIndex = rng.nextInt(config.monsters.length); + final m = config.monsters[monsterIndex]; + final l = _monsterLevel(m); + if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) { + best = m; + bestLevel = l; + bestIndex = monsterIndex; + } + } + final nameEn = best.split('|').first; + final name = l10n.translateMonster(nameEn); + return QuestResult( + caption: l10n.questPatch(l10n.definiteL10n(name, 2)), + reward: reward, + monsterName: best, + monsterLevel: bestLevel, + monsterIndex: bestIndex, + ); + case 1: + // interestingItem: attrib + special 조합 후 번역 + final attr = pick(config.itemAttrib, rng); + final special = pick(config.specials, rng); + final item = l10n.translateInterestingItem(attr, special); + return QuestResult( + caption: l10n.questLocate(l10n.definiteL10n(item, 1)), + reward: reward, + ); + case 2: + final itemEn = boringItem(config, rng); + final item = l10n.translateBoringItem(itemEn); + return QuestResult(caption: l10n.questTransfer(item), reward: reward); + case 3: + final itemEn = boringItem(config, rng); + final item = l10n.translateBoringItem(itemEn); + return QuestResult( + caption: l10n.questDownload(l10n.indefiniteL10n(item, 1)), + reward: reward, + ); + default: + // Stabilize: 2번 시도하여 레벨에 가장 가까운 몬스터 선택 + var best = ''; + var bestLevel = 0; + for (var i = 0; i < 2; i++) { + final m = pick(config.monsters, rng); + final l = _monsterLevel(m); + if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) { + best = m; + bestLevel = l; + } + } + final nameEn = best.split('|').first; + final name = l10n.translateMonster(nameEn); + return QuestResult( + caption: l10n.questStabilize(l10n.definiteL10n(name, 2)), + reward: reward, + ); + } +} + +/// Act별 Plot Bar 최대값 (초) - 10시간 완주 목표 +const _actPlotBarSeconds = [ + 300, // Prologue: 5분 + 7200, // Act I: 2시간 + 10800, // Act II: 3시간 + 10800, // Act III: 3시간 + 5400, // Act IV: 1.5시간 + 1800, // Act V: 30분 +]; + +/// Act 완료 처리 +ActResult completeAct(int existingActCount) { + final nextActIndex = existingActCount; + final title = l10n.actTitle(intToRoman(nextActIndex)); + final plotBarMax = existingActCount < _actPlotBarSeconds.length + ? _actPlotBarSeconds[existingActCount] + : 3600; + + final rewards = []; + if (existingActCount >= 1) { + rewards.add(RewardKind.equip); + } + if (existingActCount > 1) { + rewards.add(RewardKind.item); + } + + return ActResult( + actTitle: title, + plotBarMaxSeconds: plotBarMax, + rewards: rewards, + ); +} + +// ============================================================================= +// 시네마틱 관련 함수들 (Main.pas:456-521) +// ============================================================================= + +/// NPC 이름 생성 (ImpressiveGuy, Main.pas:514-521) +String impressiveGuy(PqConfig config, DeterministicRandom rng) { + final titleEn = pick(config.impressiveTitles, rng); + final title = l10n.translateImpressiveTitle(titleEn); + switch (rng.nextInt(2)) { + case 0: + final raceEn = pick(config.races, rng).split('|').first; + final race = l10n.translateRace(raceEn); + return l10n.impressiveGuyPattern1(title, race); + case 1: + final name1 = generateName(rng); + final name2 = generateName(rng); + return l10n.impressiveGuyPattern2(title, name1, name2); + default: + return title; + } +} + +/// 플롯 간 시네마틱 이벤트 생성 (InterplotCinematic, Main.pas:456-495) +List interplotCinematic( + PqConfig config, + DeterministicRandom rng, + int level, + int plotCount, +) { + final entries = []; + + void q(QueueKind kind, int seconds, String caption) { + entries.add( + QueueEntry(kind: kind, durationMillis: seconds * 1000, caption: caption), + ); + } + + switch (rng.nextInt(3)) { + case 0: + // 시나리오 1: 안전한 캐시 영역 도착 + q(QueueKind.task, 1, l10n.cinematicCacheZone1()); + q(QueueKind.task, 2, l10n.cinematicCacheZone2()); + q(QueueKind.task, 2, l10n.cinematicCacheZone3()); + q(QueueKind.task, 1, l10n.cinematicCacheZone4()); + break; + + case 1: + // 시나리오 2: 강력한 버그와의 전투 + q(QueueKind.task, 1, l10n.cinematicCombat1()); + final nemesis = namedMonster(config, rng, level + 3); + q(QueueKind.task, 4, l10n.cinematicCombat2(nemesis)); + + var s = rng.nextInt(3); + final combatRounds = rng.nextInt(1 + plotCount); + for (var i = 0; i < combatRounds; i++) { + s += 1 + rng.nextInt(2); + switch (s % 3) { + case 0: + q(QueueKind.task, 2, l10n.cinematicCombatLocked(nemesis)); + break; + case 1: + q(QueueKind.task, 2, l10n.cinematicCombatCorrupts(nemesis)); + break; + case 2: + q(QueueKind.task, 2, l10n.cinematicCombatWorking(nemesis)); + break; + } + } + + q(QueueKind.task, 3, l10n.cinematicCombatVictory(nemesis)); + q(QueueKind.task, 2, l10n.cinematicCombatWakeUp()); + break; + + case 2: + // 시나리오 3: 내부자 위협 발견 + final guy = impressiveGuy(config, rng); + final itemEn = boringItem(config, rng); + final item = l10n.translateBoringItem(itemEn); + q(QueueKind.task, 2, l10n.cinematicBetrayal1(guy)); + q(QueueKind.task, 3, l10n.cinematicBetrayal2(guy)); + q(QueueKind.task, 2, l10n.cinematicBetrayal3(item)); + q(QueueKind.task, 2, l10n.cinematicBetrayal4()); + q(QueueKind.task, 2, l10n.cinematicBetrayal5(guy)); + q(QueueKind.task, 3, l10n.cinematicBetrayal6()); + break; + } + + q(QueueKind.plot, 2, l10n.taskCompiling); + + return entries; +} + +// ============================================================================= +// 스펠 관련 함수 +// ============================================================================= + +/// 스펠 획득 (원본 Main.pas:770-774) +String winSpell( + PqConfig config, + DeterministicRandom rng, + int wisdom, + int level, +) { + final maxIndex = math.min(wisdom + level, config.spells.length); + if (maxIndex <= 0) return ''; + final index = randomLow(rng, maxIndex); + final entry = config.spells[index]; + final parts = entry.split('|'); + final name = parts[0]; + final currentRank = romanToInt(parts.length > 1 ? parts[1] : 'I'); + final nextRank = math.max(1, currentRank + 1); + return '$name|${intToRoman(nextRank)}'; +} diff --git a/lib/src/core/util/pq_random.dart b/lib/src/core/util/pq_random.dart new file mode 100644 index 0000000..d813871 --- /dev/null +++ b/lib/src/core/util/pq_random.dart @@ -0,0 +1,61 @@ +import 'dart:math' as math; + +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; + +/// 랜덤/확률 관련 유틸리티 함수들 +/// 원본 Main.pas의 랜덤 헬퍼 함수들을 포팅 + +/// 두 번의 랜덤 중 작은 값 반환 (낮은 인덱스 선호) +int randomLow(DeterministicRandom rng, int below) { + return math.min(rng.nextInt(below), rng.nextInt(below)); +} + +/// 리스트에서 랜덤 선택 +String pick(List values, DeterministicRandom rng) { + if (values.isEmpty) return ''; + return values[rng.nextInt(values.length)]; +} + +/// 리스트에서 낮은 인덱스 선호하여 선택 +String pickLow(List values, DeterministicRandom rng) { + if (values.isEmpty) return ''; + return values[randomLow(rng, values.length)]; +} + +/// 64비트 범위 랜덤 (큰 범위용) +int random64Below(DeterministicRandom rng, int below) { + if (below <= 0) return 0; + final hi = rng.nextUint32(); + final lo = rng.nextUint32(); + final combined = (hi << 32) | lo; + return (combined % below).toInt(); +} + +/// 3d6 스탯 롤 +int rollStat(DeterministicRandom rng) { + return 3 + rng.nextInt(6) + rng.nextInt(6) + rng.nextInt(6); +} + +/// 캐릭터 이름 생성 (원본 NewGuy.pas 기반) +String generateName(DeterministicRandom rng) { + const kParts = [ + 'br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z', + 'a|a|e|e|i|i|o|o|u|u|ae|ie|oo|ou', + 'b|ck|d|g|k|m|n|p|t|v|x|z', + ]; + + var result = ''; + for (var i = 0; i <= 5; i++) { + result += _pickFromPipe(kParts[i % 3], rng); + } + if (result.isEmpty) return result; + return '${result[0].toUpperCase()}${result.substring(1)}'; +} + +/// 파이프(|)로 구분된 문자열에서 랜덤 선택 +String _pickFromPipe(String pipeSeparated, DeterministicRandom rng) { + final parts = pipeSeparated.split('|'); + if (parts.isEmpty) return ''; + final idx = rng.nextInt(parts.length); + return parts[idx]; +} diff --git a/lib/src/core/util/pq_stat.dart b/lib/src/core/util/pq_stat.dart new file mode 100644 index 0000000..3ad65ac --- /dev/null +++ b/lib/src/core/util/pq_stat.dart @@ -0,0 +1,64 @@ +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; +import 'package:asciineverdie/src/core/util/pq_random.dart'; + +/// 스탯 관련 함수들 +/// 원본 Main.pas의 스탯 처리 로직을 포팅 + +/// 스탯 증가 인덱스 결정 (원본 Main.pas:870-883) +int winStatIndex(DeterministicRandom rng, List statValues) { + // 50%: 모든 8개 스탯 중 랜덤 + // 50%: 첫 6개(STR~CHA)만 제곱 가중치로 선택 + if (rng.nextInt(2) == 0) { + return rng.nextInt(statValues.length); + } + + // 첫 6개(STR, CON, DEX, INT, WIS, CHA)만 제곱 가중치 적용 + const firstSixCount = 6; + var total = 0; + for (var i = 0; i < firstSixCount && i < statValues.length; i++) { + total += statValues[i] * statValues[i]; + } + if (total == 0) return rng.nextInt(firstSixCount); + var pickValue = random64Below(rng, total); + for (var i = 0; i < firstSixCount; i++) { + pickValue -= statValues[i] * statValues[i]; + if (pickValue < 0) return i; + } + return firstSixCount - 1; +} + +/// 스탯 증가 적용 +Stats winStat(Stats stats, DeterministicRandom rng) { + final values = [ + stats.str, + stats.con, + stats.dex, + stats.intelligence, + stats.wis, + stats.cha, + stats.hpMax, + stats.mpMax, + ]; + final idx = winStatIndex(rng, values); + switch (idx) { + case 0: + return stats.copyWith(str: stats.str + 1); + case 1: + return stats.copyWith(con: stats.con + 1); + case 2: + return stats.copyWith(dex: stats.dex + 1); + case 3: + return stats.copyWith(intelligence: stats.intelligence + 1); + case 4: + return stats.copyWith(wis: stats.wis + 1); + case 5: + return stats.copyWith(cha: stats.cha + 1); + case 6: + return stats.copyWith(hpMax: stats.hpMax + 1); + case 7: + return stats.copyWith(mpMax: stats.mpMax + 1); + default: + return stats; + } +} diff --git a/lib/src/core/util/pq_string.dart b/lib/src/core/util/pq_string.dart new file mode 100644 index 0000000..351c625 --- /dev/null +++ b/lib/src/core/util/pq_string.dart @@ -0,0 +1,55 @@ +import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; + +/// 문자열 유틸리티 함수들 +/// 원본 Main.pas의 문자열 처리 함수들을 포팅 + +/// 단어를 복수형으로 변환 (영문) +String pluralize(String s) { + if (_ends(s, 'y')) return '${s.substring(0, s.length - 1)}ies'; + if (_ends(s, 'us')) return '${s.substring(0, s.length - 2)}i'; + if (_ends(s, 'ch') || _ends(s, 'x') || _ends(s, 's')) return '${s}es'; + if (_ends(s, 'f')) return '${s.substring(0, s.length - 1)}ves'; + if (_ends(s, 'man') || _ends(s, 'Man')) { + return '${s.substring(0, s.length - 2)}en'; + } + return '${s}s'; +} + +/// 부정관사 + 명사 (a/an + 단수 또는 수량 + 복수) +String indefinite(String s, int qty) { + if (qty == 1) { + const vowels = 'AEIOUÜaeiouü'; + final first = s.isNotEmpty ? s[0] : 'a'; + final article = vowels.contains(first) ? 'an' : 'a'; + return '$article $s'; + } + return '$qty ${pluralize(s)}'; +} + +/// 정관사 + 명사 (the + 명사) +String definite(String s, int qty) { + if (qty > 1) { + s = pluralize(s); + } + return 'the $s'; +} + +/// 초 단위 시간을 사람이 읽기 쉬운 형태로 변환 (원본 Main.pas:1265-1271) +/// l10n 지원 +String roughTime(int seconds) { + if (seconds < 120) { + return l10n.roughTimeSeconds(seconds); + } else if (seconds < 60 * 120) { + return l10n.roughTimeMinutes(seconds ~/ 60); + } else if (seconds < 60 * 60 * 48) { + return l10n.roughTimeHours(seconds ~/ 3600); + } else { + return l10n.roughTimeDays(seconds ~/ (3600 * 24)); + } +} + +/// 문자열이 특정 접미사로 끝나는지 확인 +bool _ends(String s, String suffix) { + return s.length >= suffix.length && + s.substring(s.length - suffix.length) == suffix; +} diff --git a/lib/src/core/util/pq_task.dart b/lib/src/core/util/pq_task.dart new file mode 100644 index 0000000..e72d383 --- /dev/null +++ b/lib/src/core/util/pq_task.dart @@ -0,0 +1,97 @@ +import 'dart:collection'; + +import 'package:asciineverdie/src/core/model/game_state.dart'; + +/// 태스크/큐/진행 관련 함수들 +/// 원본 Main.pas의 태스크 처리 로직을 포팅 + +/// 레벨업에 필요한 시간 (초) +int levelUpTimeSeconds(int level) { + // Act 진행과 레벨업 동기화 (10시간 완주 목표) + if (level <= 20) { + // Act I: 레벨 1-20, 2시간 (7200초) → 평균 360초/레벨 + return 300 + (level * 6); + } else if (level <= 40) { + // Act II: 레벨 21-40, 3시간 (10800초) → 평균 540초/레벨 + return 400 + ((level - 20) * 10); + } else if (level <= 60) { + // Act III: 레벨 41-60, 3시간 (10800초) → 평균 540초/레벨 + return 400 + ((level - 40) * 10); + } else if (level <= 80) { + // Act IV: 레벨 61-80, 1.5시간 (5400초) → 평균 270초/레벨 + return 200 + ((level - 60) * 5); + } else { + // Act V: 레벨 81-100, 30분 (1800초) → 평균 90초/레벨 + return 60 + ((level - 80) * 3); + } +} + +/// levelUpTimeSeconds의 별칭 (호환성 유지) +int levelUpTime(int level) => levelUpTimeSeconds(level); + +/// 태스크 결과 +class TaskResult { + const TaskResult({ + required this.caption, + required this.durationMillis, + required this.progress, + }); + + final String caption; + final int durationMillis; + final ProgressState progress; +} + +/// 태스크 시작: 태스크 바 리셋 및 캡션 설정 +TaskResult startTask( + ProgressState progress, + String caption, + int durationMillis, +) { + final updated = progress.copyWith( + task: ProgressBarState(position: 0, max: durationMillis), + ); + return TaskResult( + caption: '$caption...', + durationMillis: durationMillis, + progress: updated, + ); +} + +/// 큐 처리 결과 +class DequeueResult { + const DequeueResult({ + required this.progress, + required this.queue, + required this.caption, + required this.taskType, + required this.kind, + }); + + final ProgressState progress; + final QueueState queue; + final String caption; + final TaskType taskType; + final QueueKind kind; +} + +/// 큐에서 다음 태스크 꺼내기 +/// 현재 태스크가 완료되면 큐에서 다음 태스크를 가져옴 +DequeueResult? dequeue(ProgressState progress, QueueState queue) { + // 태스크 바가 완료되지 않으면 null 반환 + if (progress.task.position < progress.task.max) return null; + if (queue.entries.isEmpty) return null; + + final entries = Queue.from(queue.entries); + if (entries.isEmpty) return null; + + final next = entries.removeFirst(); + final taskResult = startTask(progress, next.caption, next.durationMillis); + return DequeueResult( + progress: taskResult.progress, + queue: QueueState(entries: entries.toList()), + caption: taskResult.caption, + taskType: next.taskType, + kind: next.kind, + ); +}