From b450bf260032cf47dacc36b84af30241f2fcf1e6 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 9 Dec 2025 22:30:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20=EC=9E=A5=EB=B9=84=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EB=B0=8F=20=EA=B2=8C=EC=9E=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=AA=A8=EB=8D=B8=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Equipment 클래스를 11개 슬롯으로 확장 (원본 Main.dfm 충실) - TaskInfo에 몬스터 정보(baseName, part) 추가 - Stats에 현재 HP/MP 필드 추가 - 히스토리 기능 구현 (plotHistory, questHistory) - pq_logic winEquip/winStatIndex 원본 로직 개선 - 퀘스트 몬스터 처리 로직 구현 - SaveData 직렬화 확장 --- lib/src/core/engine/game_mutations.dart | 29 ++- lib/src/core/engine/progress_service.dart | 167 ++++++++------- lib/src/core/engine/reward_service.dart | 7 +- lib/src/core/model/equipment_slot.dart | 30 ++- lib/src/core/model/game_state.dart | 196 +++++++++++++++++- lib/src/core/model/save_data.dart | 73 ++++++- lib/src/core/util/pq_logic.dart | 118 +++++++++-- lib/src/features/game/game_play_screen.dart | 16 +- .../new_character/new_character_screen.dart | 56 ++--- test/core/engine/progress_service_test.dart | 4 +- test/core/util/pq_logic_test.dart | 40 ++-- test/regression/deterministic_game_test.dart | 43 ++-- 12 files changed, 571 insertions(+), 208 deletions(-) diff --git a/lib/src/core/engine/game_mutations.dart b/lib/src/core/engine/game_mutations.dart index b3e4ff7..78ddceb 100644 --- a/lib/src/core/engine/game_mutations.dart +++ b/lib/src/core/engine/game_mutations.dart @@ -9,27 +9,22 @@ class GameMutations { final PqConfig config; - GameState winEquip(GameState state, int level, EquipmentSlot slot) { + /// 장비 획득 (원본 Main.pas:791-830 WinEquip) + /// [slotIndex]: 0-10 (원본 Equips.Items.Count = 11) + GameState winEquipByIndex(GameState state, int level, int slotIndex) { final rng = state.rng; - final name = pq_logic.winEquip(config, rng, level, slot); - final equip = state.equipment; - final updatedEquip = switch (slot) { - EquipmentSlot.weapon => equip.copyWith( - weapon: name, - bestIndex: EquipmentSlot.weapon.index, - ), - EquipmentSlot.shield => equip.copyWith( - shield: name, - bestIndex: EquipmentSlot.shield.index, - ), - EquipmentSlot.armor => equip.copyWith( - armor: name, - bestIndex: EquipmentSlot.armor.index, - ), - }; + final name = pq_logic.winEquip(config, rng, level, slotIndex); + final updatedEquip = state.equipment + .setByIndex(slotIndex, name) + .copyWith(bestIndex: slotIndex); return state.copyWith(rng: rng, equipment: updatedEquip); } + /// EquipmentSlot enum을 사용하는 편의 함수 + GameState winEquip(GameState state, int level, EquipmentSlot slot) { + return winEquipByIndex(state, level, slot.index); + } + GameState winStat(GameState state) { final updatedStats = pq_logic.winStat(state.stats, state.rng); return state.copyWith(rng: state.rng, stats: updatedStats); diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 1dbaeb7..3278ce3 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -2,7 +2,6 @@ import 'dart:math' as math; import 'package:askiineverdie/src/core/engine/game_mutations.dart'; import 'package:askiineverdie/src/core/engine/reward_service.dart'; -import 'package:askiineverdie/src/core/model/equipment_slot.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart'; import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; @@ -49,21 +48,24 @@ class ProgressService { const QueueEntry( kind: QueueKind.task, durationMillis: 6 * 1000, - caption: "Much is revealed about that wise old bastard you'd " + caption: + "Much is revealed about that wise old bastard you'd " 'underestimated', taskType: TaskType.load, ), const QueueEntry( kind: QueueKind.task, durationMillis: 6 * 1000, - caption: 'A shocking series of events leaves you alone and bewildered, ' + caption: + 'A shocking series of events leaves you alone and bewildered, ' 'but resolute', taskType: TaskType.load, ), const QueueEntry( kind: QueueKind.task, durationMillis: 4 * 1000, - caption: 'Drawing upon an unexpected reserve of determination, ' + caption: + 'Drawing upon an unexpected reserve of determination, ' 'you set out on a long and dangerous journey', taskType: TaskType.load, ), @@ -76,17 +78,10 @@ class ProgressService { ]; // 첫 번째 태스크 'Loading' 시작 (원본 752줄) - final taskResult = pq_logic.startTask( - state.progress, - 'Loading', - 2 * 1000, - ); + final taskResult = pq_logic.startTask(state.progress, 'Loading', 2 * 1000); // ExpBar 초기화 (원본 743-746줄) - final expBar = ProgressBarState( - position: 0, - max: pq_logic.levelUpTime(1), - ); + final expBar = ProgressBarState(position: 0, max: pq_logic.levelUpTime(1)); // PlotBar 초기화 (원본 759줄) final plotBar = const ProgressBarState(position: 0, max: 26 * 1000); @@ -97,6 +92,8 @@ class ProgressService { currentTask: const TaskInfo(caption: 'Loading...', type: TaskType.load), plotStageCount: 1, // Prologue questCount: 0, + plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)], + questHistory: const [], ); return _recalculateEncumbrance( @@ -348,12 +345,21 @@ class ProgressService { // 3. MonsterTask 실행 (원본 678-684줄) final level = state.traits.level; - final monster = pq_logic.monsterTask( + + // 원본 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, - null, // questMonster - null, // questLevel + questMonsterData, + questLevel, ); // 태스크 지속시간 계산 (원본 682줄) @@ -364,7 +370,7 @@ class ProgressService { final taskResult = pq_logic.startTask( progress, - 'Executing $monster', + 'Executing ${monsterResult.displayName}', durationMillis, ); @@ -372,6 +378,8 @@ class ProgressService { currentTask: TaskInfo( caption: taskResult.caption, type: TaskType.kill, + monsterBaseName: monsterResult.baseName, + monsterPart: monsterResult.part, ), ); @@ -389,6 +397,23 @@ class ProgressService { 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: [ @@ -409,6 +434,8 @@ class ProgressService { max: 50 + nextState.rng.nextInt(100), ), questCount: questCount, + questHistory: updatedQuestHistory, + currentQuestMonster: questMonster, ); return _recalculateEncumbrance( @@ -425,9 +452,19 @@ class ProgressService { } 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); @@ -455,6 +492,20 @@ class ProgressService { 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: [ @@ -471,6 +522,8 @@ class ProgressService { final progress = state.progress.copyWith( quest: questBar, questCount: 1, + questHistory: questHistory, + currentQuestMonster: questMonster, ); return state.copyWith(progress: progress, queue: updatedQueue); @@ -550,28 +603,25 @@ class ProgressService { /// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630) GameState _winLoot(GameState state) { - final taskCaption = state.progress.currentTask.caption; + final taskInfo = state.progress.currentTask; + final monsterPart = taskInfo.monsterPart ?? ''; + final monsterBaseName = taskInfo.monsterBaseName ?? ''; + + // 부위가 '*'이면 WinItem 호출 (특수 아이템) + if (monsterPart == '*') { + return mutations.winItem(state); + } + + // 부위가 비어있으면 전리품 없음 + if (monsterPart.isEmpty || monsterBaseName.isEmpty) { + return state; + } - // 몬스터 이름에서 전리품 아이템 생성 // 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' + // ProperCase(Split(fTask.Caption,3))), 1); - // 예: "Executing a Goblin..." -> "goblin ear" 등의 아이템 - - // 태스크 캡션에서 몬스터 이름 추출 ("Executing ..." 형태) - String monsterName = taskCaption; - if (monsterName.startsWith('Executing ')) { - monsterName = monsterName.substring('Executing '.length); - } - if (monsterName.endsWith('...')) { - monsterName = monsterName.substring(0, monsterName.length - 3); - } - - // 몬스터 부위 선택 (원본에서는 몬스터별로 다르지만, 간단히 랜덤 선택) - final parts = ['Skin', 'Tooth', 'Claw', 'Ear', 'Eye', 'Tail', 'Scale']; - final part = pq_logic.pick(parts, state.rng); - - // 아이템 이름 생성 (예: "Goblin Ear") - final itemName = '${_extractBaseName(monsterName)} $part'; + // 예: "goblin Claw" 형태로 인벤토리 추가 + final itemName = + '${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}'; // 인벤토리에 추가 final items = [...state.inventory.items]; @@ -584,32 +634,13 @@ class ProgressService { items.add(InventoryEntry(name: itemName, count: 1)); } - return state.copyWith( - inventory: state.inventory.copyWith(items: items), - ); + return state.copyWith(inventory: state.inventory.copyWith(items: items)); } - /// 몬스터 이름에서 기본 이름 추출 (형용사 제거) - String _extractBaseName(String name) { - // "a Goblin", "an Orc", "2 Goblins" 등에서 기본 이름 추출 - final words = name.split(' '); - if (words.isEmpty) return name; - - // 관사나 숫자 제거 - var startIndex = 0; - if (words[0] == 'a' || words[0] == 'an' || words[0] == 'the') { - startIndex = 1; - } else if (int.tryParse(words[0]) != null) { - startIndex = 1; - } - - if (startIndex >= words.length) return name; - - // 마지막 단어가 몬스터 이름 (형용사들 건너뛰기) - final baseName = words.last; - // 첫 글자 대문자로 - if (baseName.isEmpty) return name; - return baseName[0].toUpperCase() + baseName.substring(1).toLowerCase(); + /// 첫 글자만 대문자로 변환 (원본 ProperCase) + String _properCase(String s) { + if (s.isEmpty) return s; + return s[0].toUpperCase() + s.substring(1); } /// 인벤토리에서 Gold 수량 반환 @@ -635,11 +666,9 @@ class ProgressService { ); // 장비 획득 (WinEquip) - nextState = mutations.winEquip( - nextState, - level, - EquipmentSlot.values[nextState.rng.nextInt(EquipmentSlot.values.length)], - ); + // 원본 Main.pas:797 - posn := Random(Equips.Items.Count); (11개 슬롯) + final slotIndex = nextState.rng.nextInt(Equipment.slotCount); + nextState = mutations.winEquipByIndex(nextState, level, slotIndex); return nextState; } @@ -662,7 +691,8 @@ class ProgressService { // " of " 포함 시 보너스 (원본 639-640) if (item.name.contains(' of ')) { - price = price * + price = + price * (1 + pq_logic.randomLow(state.rng, 10)) * (1 + pq_logic.randomLow(state.rng, level)); } @@ -687,10 +717,7 @@ class ProgressService { 1 * 1000, ); final progress = taskResult.progress.copyWith( - currentTask: TaskInfo( - caption: taskResult.caption, - type: TaskType.sell, - ), + currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.sell), ); return ( state: state.copyWith( diff --git a/lib/src/core/engine/reward_service.dart b/lib/src/core/engine/reward_service.dart index 71e0db4..04a2d4c 100644 --- a/lib/src/core/engine/reward_service.dart +++ b/lib/src/core/engine/reward_service.dart @@ -1,5 +1,4 @@ import 'package:askiineverdie/src/core/engine/game_mutations.dart'; -import 'package:askiineverdie/src/core/model/equipment_slot.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/util/pq_logic.dart'; @@ -14,9 +13,9 @@ class RewardService { case RewardKind.spell: return mutations.winSpell(state, state.stats.wis, state.traits.level); case RewardKind.equip: - final slot = EquipmentSlot - .values[state.rng.nextInt(EquipmentSlot.values.length)]; - return mutations.winEquip(state, state.traits.level, slot); + // 원본 Main.pas:797 - Random(Equips.Items.Count) (11개 슬롯) + final slotIndex = state.rng.nextInt(Equipment.slotCount); + return mutations.winEquipByIndex(state, state.traits.level, slotIndex); case RewardKind.stat: return mutations.winStat(state); case RewardKind.item: diff --git a/lib/src/core/model/equipment_slot.dart b/lib/src/core/model/equipment_slot.dart index 8ae1500..dc06daf 100644 --- a/lib/src/core/model/equipment_slot.dart +++ b/lib/src/core/model/equipment_slot.dart @@ -1 +1,29 @@ -enum EquipmentSlot { weapon, shield, armor } +/// 장비 슬롯 (원본 Main.dfm Equips ListView 순서) +/// 0=Weapon, 1=Shield, 2-10=Armor 계열 +enum EquipmentSlot { + weapon, // 0: 무기 → K.Weapons + shield, // 1: 방패 → K.Shields + helm, // 2: 투구 → K.Armors + hauberk, // 3: 사슬갑옷 → K.Armors + brassairts, // 4: 상완갑 → K.Armors + vambraces, // 5: 전완갑 → K.Armors + gauntlets, // 6: 건틀릿 → K.Armors + gambeson, // 7: 갬비슨(누비옷) → K.Armors + cuisses, // 8: 허벅지갑 → K.Armors + greaves, // 9: 정강이갑 → K.Armors + sollerets, // 10: 철제신발 → K.Armors +} + +extension EquipmentSlotX on EquipmentSlot { + /// 무기 슬롯 여부 + bool get isWeapon => this == EquipmentSlot.weapon; + + /// 방패 슬롯 여부 + bool get isShield => this == EquipmentSlot.shield; + + /// 방어구(armor) 슬롯 여부 (2-10) + bool get isArmor => index >= 2; + + /// 슬롯 인덱스 (0-10) + int get slotIndex => index; +} diff --git a/lib/src/core/model/game_state.dart b/lib/src/core/model/game_state.dart index 545682a..d014e67 100644 --- a/lib/src/core/model/game_state.dart +++ b/lib/src/core/model/game_state.dart @@ -90,16 +90,37 @@ enum TaskType { } class TaskInfo { - const TaskInfo({required this.caption, required this.type}); + const TaskInfo({ + required this.caption, + required this.type, + this.monsterBaseName, + this.monsterPart, + }); final String caption; final TaskType type; + /// 킬 태스크의 몬스터 기본 이름 (형용사 제외, 예: "Goblin") + final String? monsterBaseName; + + /// 킬 태스크의 전리품 부위 (예: "claw", "tail", "*"는 WinItem) + final String? monsterPart; + factory TaskInfo.empty() => const TaskInfo(caption: '', type: TaskType.neutral); - TaskInfo copyWith({String? caption, TaskType? type}) { - return TaskInfo(caption: caption ?? this.caption, type: type ?? this.type); + TaskInfo copyWith({ + String? caption, + TaskType? type, + String? monsterBaseName, + String? monsterPart, + }) { + return TaskInfo( + caption: caption ?? this.caption, + type: type ?? this.type, + monsterBaseName: monsterBaseName ?? this.monsterBaseName, + monsterPart: monsterPart ?? this.monsterPart, + ); } } @@ -158,6 +179,8 @@ class Stats { required this.cha, required this.hpMax, required this.mpMax, + this.hpCurrent, + this.mpCurrent, }); final int str; @@ -169,6 +192,18 @@ class Stats { final int hpMax; final int mpMax; + /// 현재 HP (null이면 hpMax와 동일) + final int? hpCurrent; + + /// 현재 MP (null이면 mpMax와 동일) + final int? mpCurrent; + + /// 실제 현재 HP 값 + int get hp => hpCurrent ?? hpMax; + + /// 실제 현재 MP 값 + int get mp => mpCurrent ?? mpMax; + factory Stats.empty() => const Stats( str: 0, con: 0, @@ -189,6 +224,8 @@ class Stats { int? cha, int? hpMax, int? mpMax, + int? hpCurrent, + int? mpCurrent, }) { return Stats( str: str ?? this.str, @@ -199,6 +236,8 @@ class Stats { cha: cha ?? this.cha, hpMax: hpMax ?? this.hpMax, mpMax: mpMax ?? this.mpMax, + hpCurrent: hpCurrent ?? this.hpCurrent, + mpCurrent: mpCurrent ?? this.mpCurrent, ); } } @@ -227,38 +266,118 @@ class Inventory { } } +/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯) class Equipment { const Equipment({ required this.weapon, required this.shield, - required this.armor, + required this.helm, + required this.hauberk, + required this.brassairts, + required this.vambraces, + required this.gauntlets, + required this.gambeson, + required this.cuisses, + required this.greaves, + required this.sollerets, required this.bestIndex, }); - final String weapon; - final String shield; - final String armor; + final String weapon; // 0: 무기 + final String shield; // 1: 방패 + final String helm; // 2: 투구 + final String hauberk; // 3: 사슬갑옷 + final String brassairts; // 4: 상완갑 + final String vambraces; // 5: 전완갑 + final String gauntlets; // 6: 건틀릿 + final String gambeson; // 7: 갬비슨 + final String cuisses; // 8: 허벅지갑 + final String greaves; // 9: 정강이갑 + final String sollerets; // 10: 철제신발 - /// Tracks best slot index (mirror of Equips.Tag in original code; 0=weapon,1=shield,2=armor). + /// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10) final int bestIndex; + /// 슬롯 개수 + static const slotCount = 11; + factory Equipment.empty() => const Equipment( weapon: 'Sharp Stick', shield: '', - armor: '', + helm: '', + hauberk: '', + brassairts: '', + vambraces: '', + gauntlets: '', + gambeson: '', + cuisses: '', + greaves: '', + sollerets: '', bestIndex: 0, ); + /// 인덱스로 슬롯 값 가져오기 + String getByIndex(int index) { + return switch (index) { + 0 => weapon, + 1 => shield, + 2 => helm, + 3 => hauberk, + 4 => brassairts, + 5 => vambraces, + 6 => gauntlets, + 7 => gambeson, + 8 => cuisses, + 9 => greaves, + 10 => sollerets, + _ => '', + }; + } + + /// 인덱스로 슬롯 값 설정한 새 Equipment 반환 + Equipment setByIndex(int index, String value) { + return switch (index) { + 0 => copyWith(weapon: value), + 1 => copyWith(shield: value), + 2 => copyWith(helm: value), + 3 => copyWith(hauberk: value), + 4 => copyWith(brassairts: value), + 5 => copyWith(vambraces: value), + 6 => copyWith(gauntlets: value), + 7 => copyWith(gambeson: value), + 8 => copyWith(cuisses: value), + 9 => copyWith(greaves: value), + 10 => copyWith(sollerets: value), + _ => this, + }; + } + Equipment copyWith({ String? weapon, String? shield, - String? armor, + String? helm, + String? hauberk, + String? brassairts, + String? vambraces, + String? gauntlets, + String? gambeson, + String? cuisses, + String? greaves, + String? sollerets, int? bestIndex, }) { return Equipment( weapon: weapon ?? this.weapon, shield: shield ?? this.shield, - armor: armor ?? this.armor, + helm: helm ?? this.helm, + hauberk: hauberk ?? this.hauberk, + brassairts: brassairts ?? this.brassairts, + vambraces: vambraces ?? this.vambraces, + gauntlets: gauntlets ?? this.gauntlets, + gambeson: gambeson ?? this.gambeson, + cuisses: cuisses ?? this.cuisses, + greaves: greaves ?? this.greaves, + sollerets: sollerets ?? this.sollerets, bestIndex: bestIndex ?? this.bestIndex, ); } @@ -304,6 +423,40 @@ class ProgressBarState { } } +/// 히스토리 엔트리 (Plot/Quest 진행 기록) +class HistoryEntry { + const HistoryEntry({required this.caption, required this.isComplete}); + + /// 표시 텍스트 (예: "Prologue", "Act I", "Exterminate the Goblins") + final String caption; + + /// 완료 여부 (원본 StateIndex: 0=진행중, 1=완료) + final bool isComplete; + + HistoryEntry copyWith({String? caption, bool? isComplete}) { + return HistoryEntry( + caption: caption ?? this.caption, + isComplete: isComplete ?? this.isComplete, + ); + } +} + +/// 현재 퀘스트 몬스터 정보 (원본 fQuest) +class QuestMonsterInfo { + const QuestMonsterInfo({ + required this.monsterData, + required this.monsterIndex, + }); + + /// 몬스터 데이터 문자열 (예: "Goblin|3|ear") + final String monsterData; + + /// 몬스터 인덱스 (Config.monsters에서의 인덱스) + final int monsterIndex; + + static const empty = QuestMonsterInfo(monsterData: '', monsterIndex: -1); +} + class ProgressState { const ProgressState({ required this.task, @@ -314,6 +467,9 @@ class ProgressState { required this.currentTask, required this.plotStageCount, required this.questCount, + this.plotHistory = const [], + this.questHistory = const [], + this.currentQuestMonster, }); final ProgressBarState task; @@ -325,6 +481,15 @@ class ProgressState { final int plotStageCount; final int questCount; + /// 플롯 히스토리 (Prologue, Act I, Act II, ...) + final List plotHistory; + + /// 퀘스트 히스토리 (완료된/진행중인 퀘스트 목록) + final List questHistory; + + /// 현재 퀘스트 몬스터 정보 (Exterminate 타입용) + final QuestMonsterInfo? currentQuestMonster; + factory ProgressState.empty() => ProgressState( task: ProgressBarState.empty(), quest: ProgressBarState.empty(), @@ -334,6 +499,9 @@ class ProgressState { currentTask: TaskInfo.empty(), plotStageCount: 1, // Prologue questCount: 0, + plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)], + questHistory: const [], + currentQuestMonster: null, ); ProgressState copyWith({ @@ -345,6 +513,9 @@ class ProgressState { TaskInfo? currentTask, int? plotStageCount, int? questCount, + List? plotHistory, + List? questHistory, + QuestMonsterInfo? currentQuestMonster, }) { return ProgressState( task: task ?? this.task, @@ -355,6 +526,9 @@ class ProgressState { currentTask: currentTask ?? this.currentTask, plotStageCount: plotStageCount ?? this.plotStageCount, questCount: questCount ?? this.questCount, + plotHistory: plotHistory ?? this.plotHistory, + questHistory: questHistory ?? this.questHistory, + currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster, ); } } diff --git a/lib/src/core/model/save_data.dart b/lib/src/core/model/save_data.dart index ec7e152..aa843f7 100644 --- a/lib/src/core/model/save_data.dart +++ b/lib/src/core/model/save_data.dart @@ -63,6 +63,8 @@ class GameSave { 'cha': stats.cha, 'hpMax': stats.hpMax, 'mpMax': stats.mpMax, + 'hpCurrent': stats.hpCurrent, + 'mpCurrent': stats.mpCurrent, }, 'inventory': { 'gold': inventory.gold, @@ -73,7 +75,15 @@ class GameSave { 'equipment': { 'weapon': equipment.weapon, 'shield': equipment.shield, - 'armor': equipment.armor, + 'helm': equipment.helm, + 'hauberk': equipment.hauberk, + 'brassairts': equipment.brassairts, + 'vambraces': equipment.vambraces, + 'gauntlets': equipment.gauntlets, + 'gambeson': equipment.gambeson, + 'cuisses': equipment.cuisses, + 'greaves': equipment.greaves, + 'sollerets': equipment.sollerets, 'bestIndex': equipment.bestIndex, }, 'spells': spellBook.spells @@ -88,9 +98,23 @@ class GameSave { 'taskInfo': { 'caption': progress.currentTask.caption, 'type': progress.currentTask.type.name, + 'monsterBaseName': progress.currentTask.monsterBaseName, + 'monsterPart': progress.currentTask.monsterPart, }, 'plotStages': progress.plotStageCount, 'questCount': progress.questCount, + 'plotHistory': progress.plotHistory + .map((e) => {'caption': e.caption, 'complete': e.isComplete}) + .toList(), + 'questHistory': progress.questHistory + .map((e) => {'caption': e.caption, 'complete': e.isComplete}) + .toList(), + 'questMonster': progress.currentQuestMonster != null + ? { + 'data': progress.currentQuestMonster!.monsterData, + 'index': progress.currentQuestMonster!.monsterIndex, + } + : null, }, 'queue': queue.entries .map( @@ -134,6 +158,8 @@ class GameSave { cha: statsJson['cha'] as int? ?? 0, hpMax: statsJson['hpMax'] as int? ?? 0, mpMax: statsJson['mpMax'] as int? ?? 0, + hpCurrent: statsJson['hpCurrent'] as int?, + mpCurrent: statsJson['mpCurrent'] as int?, ), inventory: Inventory( gold: inventoryJson['gold'] as int? ?? 0, @@ -149,7 +175,15 @@ class GameSave { equipment: Equipment( weapon: equipmentJson['weapon'] as String? ?? 'Sharp Stick', shield: equipmentJson['shield'] as String? ?? '', - armor: equipmentJson['armor'] as String? ?? '', + helm: equipmentJson['helm'] as String? ?? '', + hauberk: equipmentJson['hauberk'] as String? ?? '', + brassairts: equipmentJson['brassairts'] as String? ?? '', + vambraces: equipmentJson['vambraces'] as String? ?? '', + gauntlets: equipmentJson['gauntlets'] as String? ?? '', + gambeson: equipmentJson['gambeson'] as String? ?? '', + cuisses: equipmentJson['cuisses'] as String? ?? '', + greaves: equipmentJson['greaves'] as String? ?? '', + sollerets: equipmentJson['sollerets'] as String? ?? '', bestIndex: equipmentJson['bestIndex'] as int? ?? 0, ), spellBook: SpellBook( @@ -178,6 +212,15 @@ class GameSave { ), plotStageCount: progressJson['plotStages'] as int? ?? 1, questCount: progressJson['questCount'] as int? ?? 0, + plotHistory: _historyListFromJson( + progressJson['plotHistory'] as List?, + ), + questHistory: _historyListFromJson( + progressJson['questHistory'] as List?, + ), + currentQuestMonster: _questMonsterFromJson( + progressJson['questMonster'] as Map?, + ), ), queue: QueueState( entries: Queue.from( @@ -233,5 +276,29 @@ TaskInfo _taskInfoFromJson(Map json) { (t) => t.name == typeName, orElse: () => TaskType.neutral, ); - return TaskInfo(caption: json['caption'] as String? ?? '', type: type); + return TaskInfo( + caption: json['caption'] as String? ?? '', + type: type, + monsterBaseName: json['monsterBaseName'] as String?, + monsterPart: json['monsterPart'] as String?, + ); +} + +List _historyListFromJson(List? json) { + if (json == null || json.isEmpty) return []; + return json.map((e) { + final m = e as Map; + return HistoryEntry( + caption: m['caption'] as String? ?? '', + isComplete: m['complete'] as bool? ?? false, + ); + }).toList(); +} + +QuestMonsterInfo? _questMonsterFromJson(Map? json) { + if (json == null) return null; + return QuestMonsterInfo( + monsterData: json['data'] as String? ?? '', + monsterIndex: json['index'] as int? ?? -1, + ); } diff --git a/lib/src/core/util/pq_logic.dart b/lib/src/core/util/pq_logic.dart index c3375c5..9374b9e 100644 --- a/lib/src/core/util/pq_logic.dart +++ b/lib/src/core/util/pq_logic.dart @@ -140,7 +140,6 @@ int _parseLevel(String entry) { return int.tryParse(parts[1].replaceAll('+', '')) ?? 0; } - String addModifier( DeterministicRandom rng, String baseName, @@ -211,19 +210,27 @@ int random64Below(DeterministicRandom rng, int below) { 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, - EquipmentSlot slot, + int slotIndex, ) { - // Decide item set and modifiers based on slot. - final bool isWeapon = slot == EquipmentSlot.weapon; - final items = switch (slot) { - EquipmentSlot.weapon => config.weapons, - EquipmentSlot.shield => config.shields, - EquipmentSlot.armor => config.armors, - }; + // 원본 로직: + // 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; @@ -239,21 +246,38 @@ String winEquip( 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); +} + int winStatIndex(DeterministicRandom rng, List statValues) { - // 원본 Main.pas:870-883: 50% 확률로 완전 랜덤, 50% 확률로 제곱 가중치 + // 원본 Main.pas:870-883 + // 50%: 모든 8개 스탯 중 랜덤 + // 50%: 첫 6개(STR~CHA)만 제곱 가중치로 선택 if (rng.nextInt(2) == 0) { - // Odds(1,2): 완전 랜덤 선택 + // Odds(1,2): 모든 스탯 중 완전 랜덤 선택 return rng.nextInt(statValues.length); } - // 제곱 가중치로 높은 스탯 선호 - final total = statValues.fold(0, (sum, v) => sum + v * v); - if (total == 0) 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 < statValues.length; i++) { + for (var i = 0; i < firstSixCount; i++) { pickValue -= statValues[i] * statValues[i]; if (pickValue < 0) return i; } - return statValues.length - 1; + return firstSixCount - 1; } Stats winStat(Stats stats, DeterministicRandom rng) { @@ -290,7 +314,7 @@ Stats winStat(Stats stats, DeterministicRandom rng) { } } -String monsterTask( +MonsterTaskResult monsterTask( PqConfig config, DeterministicRandom rng, int level, @@ -308,6 +332,7 @@ String monsterTask( String monster; int monsterLevel; + String part; bool definite = false; // 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용 @@ -324,11 +349,13 @@ String monsterTask( definite = true; } monsterLevel = targetLevel; - monster = '$monster|$monsterLevel|*'; + 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); @@ -342,12 +369,18 @@ String monsterTask( 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; - var name = monster.split('|').first; + var name = baseName; if (levelDiff > 10) { qty = @@ -385,7 +418,34 @@ String monsterTask( name = indefinite(name, qty); } - return name; + return MonsterTaskResult( + displayName: name, + baseName: baseName, + level: monsterLevel * qty, + part: part, + ); +} + +/// monsterTask의 반환 타입 (원본 fTask.Caption 정보 포함) +class MonsterTaskResult { + const MonsterTaskResult({ + required this.displayName, + required this.baseName, + required this.level, + required this.part, + }); + + /// 화면에 표시할 몬스터 이름 (형용사 포함, 예: "a sick Goblin") + final String displayName; + + /// 기본 몬스터 이름 (형용사 제외, 예: "Goblin") + final String baseName; + + /// 몬스터 레벨 + final int level; + + /// 전리품 부위 (예: "claw", "tail", "*"는 WinItem 호출) + final String part; } enum RewardKind { spell, equip, stat, item } @@ -396,12 +456,17 @@ class QuestResult { 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) { @@ -416,14 +481,19 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) { 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 m = pick(config.monsters, rng); + 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 name = best.split('|').first; @@ -432,6 +502,7 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) { reward: reward, monsterName: best, monsterLevel: bestLevel, + monsterIndex: bestIndex, ); case 1: final item = interestingItem(config, rng); @@ -446,6 +517,8 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) { reward: reward, ); default: + // Placate: 2번 시도하여 레벨에 가장 가까운 몬스터 선택 + // 원본 Main.pas:971-984 (fQuest.Caption := '' 처리됨) var best = ''; var bestLevel = 0; for (var i = 0; i < 2; i++) { @@ -457,11 +530,10 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) { } } final name = best.split('|').first; + // Placate는 fQuest.Caption := '' 로 비움 → monsterIndex 미저장 return QuestResult( caption: 'Placate ${definite(name, 2)}', reward: reward, - monsterName: best, - monsterLevel: bestLevel, ); } } diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index f6aa208..65c18fc 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -301,7 +301,9 @@ class _GamePlayScreenState extends State child: Text( '${speed}x', style: TextStyle( - fontWeight: speed > 1 ? FontWeight.bold : FontWeight.normal, + fontWeight: speed > 1 + ? FontWeight.bold + : FontWeight.normal, color: speed > 1 ? Theme.of(context).colorScheme.primary : null, @@ -468,11 +470,19 @@ class _GamePlayScreenState extends State } Widget _buildEquipmentList(GameState state) { - // 원본에는 11개 슬롯이 있지만, 현재 모델은 3개만 구현 + // 원본 Main.dfm Equips ListView - 11개 슬롯 final equipment = [ ('Weapon', state.equipment.weapon), ('Shield', state.equipment.shield), - ('Armor', state.equipment.armor), + ('Helm', state.equipment.helm), + ('Hauberk', state.equipment.hauberk), + ('Brassairts', state.equipment.brassairts), + ('Vambraces', state.equipment.vambraces), + ('Gauntlets', state.equipment.gauntlets), + ('Gambeson', state.equipment.gambeson), + ('Cuisses', state.equipment.cuisses), + ('Greaves', state.equipment.greaves), + ('Sollerets', state.equipment.sollerets), ]; return ListView.builder( diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart index 0977e7e..1e13128 100644 --- a/lib/src/features/new_character/new_character_screen.dart +++ b/lib/src/features/new_character/new_character_screen.dart @@ -38,7 +38,8 @@ class _NewCharacterScreenState extends State { int _wis = 0; int _cha = 0; - // 롤 이력 (Unroll 기능용) + // 롤 이력 (Unroll 기능용) - 원본 OldRolls TListBox + static const int _maxRollHistory = 20; // 최대 저장 개수 final List _rollHistory = []; // 현재 RNG 시드 (Re-Roll 전 저장) @@ -93,6 +94,11 @@ class _NewCharacterScreenState extends State { // 현재 시드를 이력에 저장 _rollHistory.insert(0, _currentSeed); + // 최대 개수 초과 시 가장 오래된 항목 제거 + if (_rollHistory.length > _maxRollHistory) { + _rollHistory.removeLast(); + } + // 새 시드로 굴림 _currentSeed = math.Random().nextInt(0x7FFFFFFF); _rollStats(); @@ -132,6 +138,7 @@ class _NewCharacterScreenState extends State { } /// Sold! 버튼 클릭 - 캐릭터 생성 완료 + /// 원본 Main.pas:1371-1388 RollCharacter 로직 void _onSold() { final name = _nameController.text.trim(); if (name.isEmpty) { @@ -144,22 +151,23 @@ class _NewCharacterScreenState extends State { // 게임에 사용할 새 RNG 생성 final gameSeed = math.Random().nextInt(0x7FFFFFFF); - // 종족/직업의 보너스 스탯 파싱 - final raceEntry = _config.races[_selectedRaceIndex]; - final klassEntry = _config.klasses[_selectedKlassIndex]; - final raceBonus = _parseStatBonus(raceEntry); - final klassBonus = _parseStatBonus(klassEntry); + // 원본 Main.pas:1380-1381 - 기본 롤 값(CON.Tag, INT.Tag)만 사용 + // 종족/직업 보너스는 스탯에 적용되지 않음 (UI 힌트용) + // Put(Stats,'HP Max',Random(8) + CON.Tag div 6); + // Put(Stats,'MP Max',Random(8) + INT.Tag div 6); + final hpMax = math.Random().nextInt(8) + _con ~/ 6; + final mpMax = math.Random().nextInt(8) + _int ~/ 6; - // 최종 스탯 계산 (기본 + 종족 보너스 + 직업 보너스) + // 원본 Main.pas:1375-1379 - 기본 롤 값 그대로 저장 (보너스 없음) final finalStats = Stats( - str: _str + (raceBonus['STR'] ?? 0) + (klassBonus['STR'] ?? 0), - con: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0), - dex: _dex + (raceBonus['DEX'] ?? 0) + (klassBonus['DEX'] ?? 0), - intelligence: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0), - wis: _wis + (raceBonus['WIS'] ?? 0) + (klassBonus['WIS'] ?? 0), - cha: _cha + (raceBonus['CHA'] ?? 0) + (klassBonus['CHA'] ?? 0), - hpMax: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0), - mpMax: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0), + str: _str, + con: _con, + dex: _dex, + intelligence: _int, + wis: _wis, + cha: _cha, + hpMax: hpMax, + mpMax: mpMax, ); final traits = Traits( @@ -186,24 +194,6 @@ class _NewCharacterScreenState extends State { widget.onCharacterCreated?.call(initialState); } - /// 종족/직업 보너스 파싱 (예: "Half Orc|STR+2,INT-1") - Map _parseStatBonus(String entry) { - final parts = entry.split('|'); - if (parts.length < 2) return {}; - - final bonuses = {}; - final bonusPart = parts[1]; - - // STR+2,INT-1 형식 파싱 - final regex = RegExp(r'([A-Z]+)([+-]\d+)'); - for (final match in regex.allMatches(bonusPart)) { - final stat = match.group(1)!; - final value = int.parse(match.group(2)!); - bonuses[stat] = value; - } - return bonuses; - } - @override Widget build(BuildContext context) { return Scaffold( diff --git a/test/core/engine/progress_service_test.dart b/test/core/engine/progress_service_test.dart index ae0cafe..67ca878 100644 --- a/test/core/engine/progress_service_test.dart +++ b/test/core/engine/progress_service_test.dart @@ -35,9 +35,7 @@ void main() { ), inventory: const Inventory( gold: 5, - items: [ - InventoryEntry(name: 'Rock', count: 3), - ], + items: [InventoryEntry(name: 'Rock', count: 3)], ), progress: const ProgressState( task: ProgressBarState(position: 0, max: 80), diff --git a/test/core/util/pq_logic_test.dart b/test/core/util/pq_logic_test.dart index 6c0db6a..54c1de2 100644 --- a/test/core/util/pq_logic_test.dart +++ b/test/core/util/pq_logic_test.dart @@ -1,4 +1,3 @@ -import 'package:askiineverdie/src/core/model/equipment_slot.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart'; import 'package:askiineverdie/src/core/util/deterministic_random.dart'; @@ -65,7 +64,7 @@ void main() { config, DeterministicRandom(12), 5, - EquipmentSlot.weapon, + 0, // weapon slot ), 'Baselard', ); @@ -74,7 +73,7 @@ void main() { config, DeterministicRandom(15), 2, - EquipmentSlot.armor, + 2, // helm slot (armor category) ), '-2 Canvas', ); @@ -86,18 +85,35 @@ void main() { }); test('monsterTask picks level-appropriate monsters with modifiers', () { - expect( - pq_logic.monsterTask(config, DeterministicRandom(99), 5, null, null), - 'an underage Rakshasa', + final result1 = pq_logic.monsterTask( + config, + DeterministicRandom(99), + 5, + null, + null, ); - expect( - pq_logic.monsterTask(config, DeterministicRandom(7), 10, null, null), - 'a greater Sphinx', + expect(result1.displayName, 'an underage Rakshasa'); + expect(result1.baseName, 'Rakshasa'); + expect(result1.part, isNotEmpty); + + final result2 = pq_logic.monsterTask( + config, + DeterministicRandom(7), + 10, + null, + null, ); - expect( - pq_logic.monsterTask(config, DeterministicRandom(5), 6, 'Goblin|3', 3), - 'a Barbed Devil', + expect(result2.displayName, 'a greater Sphinx'); + expect(result2.baseName, 'Sphinx'); + + final result3 = pq_logic.monsterTask( + config, + DeterministicRandom(5), + 6, + 'Goblin|3|ear', + 3, ); + expect(result3.displayName, 'a Barbed Devil'); }); test('completeQuest and completeAct return deterministic results', () { diff --git a/test/regression/deterministic_game_test.dart b/test/regression/deterministic_game_test.dart index 03d3f88..80cc9d4 100644 --- a/test/regression/deterministic_game_test.dart +++ b/test/regression/deterministic_game_test.dart @@ -7,7 +7,6 @@ library; import 'package:askiineverdie/src/core/engine/game_mutations.dart'; import 'package:askiineverdie/src/core/engine/progress_service.dart'; import 'package:askiineverdie/src/core/engine/reward_service.dart'; -import 'package:askiineverdie/src/core/model/equipment_slot.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart'; import 'package:askiineverdie/src/core/util/deterministic_random.dart'; @@ -41,71 +40,59 @@ void main() { test('monsterTask produces consistent monster names', () { // 시드 42, 레벨 5에서의 몬스터 이름 expect( - pq_logic.monsterTask( - config, - DeterministicRandom(testSeed), - 5, - null, - null, - ), + pq_logic + .monsterTask(config, DeterministicRandom(testSeed), 5, null, null) + .displayName, 'an underage Su-monster', ); // 시드 42, 레벨 10에서의 몬스터 이름 expect( - pq_logic.monsterTask( - config, - DeterministicRandom(testSeed), - 10, - null, - null, - ), + pq_logic + .monsterTask(config, DeterministicRandom(testSeed), 10, null, null) + .displayName, 'a cursed Troll', ); // 시드 42, 레벨 1에서의 몬스터 이름 expect( - pq_logic.monsterTask( - config, - DeterministicRandom(testSeed), - 1, - null, - null, - ), + pq_logic + .monsterTask(config, DeterministicRandom(testSeed), 1, null, null) + .displayName, 'a greater Crayfish', ); }); test('winEquip produces consistent equipment', () { - // 시드 42에서 무기 획득 + // 시드 42에서 무기 획득 (슬롯 0) expect( pq_logic.winEquip( config, DeterministicRandom(testSeed), 5, - EquipmentSlot.weapon, + 0, // weapon slot ), 'Longiron', ); - // 시드 42에서 방어구 획득 + // 시드 42에서 방어구 획득 (슬롯 2 = helm, armor 카테고리) expect( pq_logic.winEquip( config, DeterministicRandom(testSeed), 5, - EquipmentSlot.armor, + 2, // helm slot (armor category) ), '-1 Holey Mildewed Bearskin', ); - // 시드 42에서 방패 획득 + // 시드 42에서 방패 획득 (슬롯 1) expect( pq_logic.winEquip( config, DeterministicRandom(testSeed), 5, - EquipmentSlot.shield, + 1, // shield slot ), 'Round Shield', );