From 7f29995833428cfaf86f75bcc7496362574fddb9 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 15:30:21 +0900 Subject: [PATCH] feat: add secret rooms and mini-bosses on floors 4/9/14/19 Add RoomSecret (5% chance) and RoomMiniBoss room types. Add 4 mini-boss monsters at 60% of boss stats (Guardian's Herald, Warden's Shadow, Overlord's Lieutenant, Archlich's Harbinger) with IsMiniBoss flag and boss pattern logic. Secret rooms grant double treasure. Mini-boss rooms are placed on floors 4/9/14/19 at room index 1. Co-Authored-By: Claude Opus 4.6 (1M context) --- combat/combat.go | 4 +- dungeon/generator.go | 8 ++++ dungeon/generator_test.go | 44 ++++++++++++++++++++- dungeon/room.go | 14 ++++--- entity/monster.go | 28 ++++++++++---- entity/monster_test.go | 34 ++++++++++++++++ game/event.go | 81 +++++++++++++++++++++++++++++++++++++++ game/turn.go | 4 +- 8 files changed, 199 insertions(+), 18 deletions(-) diff --git a/combat/combat.go b/combat/combat.go index cd0ae8b..33169be 100644 --- a/combat/combat.go +++ b/combat/combat.go @@ -82,8 +82,8 @@ func AttemptFlee(fleeChance float64) bool { } func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) { - if m.IsBoss && turnNumber > 0 && turnNumber%3 == 0 { - return -1, true // AoE every 3 turns for all bosses + if (m.IsBoss || m.IsMiniBoss) && turnNumber > 0 && turnNumber%3 == 0 { + return -1, true // AoE every 3 turns for all bosses and mini-bosses } if m.TauntTarget { for i, p := range players { diff --git a/dungeon/generator.go b/dungeon/generator.go index f42fda7..7e774dc 100644 --- a/dungeon/generator.go +++ b/dungeon/generator.go @@ -111,6 +111,14 @@ func GenerateFloor(floorNum int) *Floor { // Last room is boss rooms[len(rooms)-1].Type = RoomBoss + // On floors 4, 9, 14, 19: assign one room as mini-boss + if floorNum == 4 || floorNum == 9 || floorNum == 14 || floorNum == 19 { + // Pick the second room (index 1), or any non-first non-last room + if len(rooms) > 2 { + rooms[1].Type = RoomMiniBoss + } + } + // Carve rooms into tile map for _, room := range rooms { for dy := 0; dy < room.H; dy++ { diff --git a/dungeon/generator_test.go b/dungeon/generator_test.go index 377643c..1010e9d 100644 --- a/dungeon/generator_test.go +++ b/dungeon/generator_test.go @@ -37,7 +37,49 @@ func TestRoomTypeProbability(t *testing.T) { } combatPct := float64(counts[RoomCombat]) / float64(n) * 100 if combatPct < 40 || combatPct > 50 { - t.Errorf("Combat room probability: got %.1f%%, want ~45%%", combatPct) + t.Errorf("Combat room probability: got %.1f%%, want ~45%% (range 5-50)", combatPct) + } +} + +func TestSecretRoomInRandomType(t *testing.T) { + counts := make(map[RoomType]int) + n := 10000 + for i := 0; i < n; i++ { + counts[RandomRoomType()]++ + } + secretPct := float64(counts[RoomSecret]) / float64(n) * 100 + if secretPct < 2 || secretPct > 8 { + t.Errorf("Secret room probability: got %.1f%%, want ~5%%", secretPct) + } + // Verify RoomMiniBoss is never returned by RandomRoomType + if counts[RoomMiniBoss] > 0 { + t.Errorf("MiniBoss rooms should not be generated randomly, got %d", counts[RoomMiniBoss]) + } +} + +func TestMiniBossRoomPlacement(t *testing.T) { + for _, floorNum := range []int{4, 9, 14, 19} { + floor := GenerateFloor(floorNum) + found := false + for _, r := range floor.Rooms { + if r.Type == RoomMiniBoss { + found = true + break + } + } + if !found { + t.Errorf("Floor %d should have a mini-boss room", floorNum) + } + } + // Non-miniboss floors should not have mini-boss rooms + for _, floorNum := range []int{1, 3, 5, 10} { + floor := GenerateFloor(floorNum) + for _, r := range floor.Rooms { + if r.Type == RoomMiniBoss { + t.Errorf("Floor %d should not have a mini-boss room", floorNum) + break + } + } } } diff --git a/dungeon/room.go b/dungeon/room.go index b5a496d..1c59491 100644 --- a/dungeon/room.go +++ b/dungeon/room.go @@ -11,10 +11,12 @@ const ( RoomEvent RoomEmpty RoomBoss + RoomSecret + RoomMiniBoss ) func (r RoomType) String() string { - return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r] + return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss", "Secret", "MiniBoss"}[r] } type Tile int @@ -47,13 +49,15 @@ type Floor struct { func RandomRoomType() RoomType { r := rand.Float64() * 100 switch { - case r < 45: + case r < 5: + return RoomSecret + case r < 50: return RoomCombat - case r < 60: + case r < 65: return RoomTreasure - case r < 70: + case r < 75: return RoomShop - case r < 85: + case r < 90: return RoomEvent default: return RoomEmpty diff --git a/entity/monster.go b/entity/monster.go index 91cfe81..1693963 100644 --- a/entity/monster.go +++ b/entity/monster.go @@ -13,6 +13,10 @@ const ( MonsterBoss10 MonsterBoss15 MonsterBoss20 + MonsterMiniBoss5 + MonsterMiniBoss10 + MonsterMiniBoss15 + MonsterMiniBoss20 ) type monsterBase struct { @@ -31,6 +35,10 @@ var monsterDefs = map[MonsterType]monsterBase{ MonsterBoss10: {"Warden", 250, 22, 12, 10, true}, MonsterBoss15: {"Overlord", 400, 30, 16, 15, true}, MonsterBoss20: {"Archlich", 600, 40, 20, 20, true}, + MonsterMiniBoss5: {"Guardian's Herald", 90, 9, 5, 4, false}, + MonsterMiniBoss10: {"Warden's Shadow", 150, 13, 7, 9, false}, + MonsterMiniBoss15: {"Overlord's Lieutenant", 240, 18, 10, 14, false}, + MonsterMiniBoss20: {"Archlich's Harbinger", 360, 24, 12, 19, false}, } type BossPattern int @@ -50,6 +58,7 @@ type Monster struct { HP, MaxHP int ATK, DEF int IsBoss bool + IsMiniBoss bool IsElite bool ElitePrefix ElitePrefixType TauntTarget bool @@ -59,21 +68,24 @@ type Monster struct { func NewMonster(mt MonsterType, floor int, scaling float64) *Monster { base := monsterDefs[mt] + isMiniBoss := mt == MonsterMiniBoss5 || mt == MonsterMiniBoss10 || + mt == MonsterMiniBoss15 || mt == MonsterMiniBoss20 scale := 1.0 - if !base.IsBoss && floor > base.MinFloor { + if !base.IsBoss && !isMiniBoss && floor > base.MinFloor { scale = math.Pow(scaling, float64(floor-base.MinFloor)) } hp := int(math.Round(float64(base.HP) * scale)) atk := int(math.Round(float64(base.ATK) * scale)) def := int(math.Round(float64(base.DEF) * scale)) return &Monster{ - Name: base.Name, - Type: mt, - HP: hp, - MaxHP: hp, - ATK: atk, - DEF: def, - IsBoss: base.IsBoss, + Name: base.Name, + Type: mt, + HP: hp, + MaxHP: hp, + ATK: atk, + DEF: def, + IsBoss: base.IsBoss, + IsMiniBoss: isMiniBoss, } } diff --git a/entity/monster_test.go b/entity/monster_test.go index ea49b0c..47f58bf 100644 --- a/entity/monster_test.go +++ b/entity/monster_test.go @@ -49,6 +49,40 @@ func TestTickTaunt(t *testing.T) { } } +func TestMiniBossStats(t *testing.T) { + tests := []struct { + mt MonsterType + name string + wantHP, wantATK, wantDEF int + }{ + {MonsterMiniBoss5, "Guardian's Herald", 90, 9, 5}, + {MonsterMiniBoss10, "Warden's Shadow", 150, 13, 7}, + {MonsterMiniBoss15, "Overlord's Lieutenant", 240, 18, 10}, + {MonsterMiniBoss20, "Archlich's Harbinger", 360, 24, 12}, + } + for _, tc := range tests { + m := NewMonster(tc.mt, tc.wantHP, 1.15) // floor doesn't matter, no scaling + if m.Name != tc.name { + t.Errorf("%v: name got %q, want %q", tc.mt, m.Name, tc.name) + } + if m.HP != tc.wantHP { + t.Errorf("%v: HP got %d, want %d", tc.mt, m.HP, tc.wantHP) + } + if m.ATK != tc.wantATK { + t.Errorf("%v: ATK got %d, want %d", tc.mt, m.ATK, tc.wantATK) + } + if m.DEF != tc.wantDEF { + t.Errorf("%v: DEF got %d, want %d", tc.mt, m.DEF, tc.wantDEF) + } + if !m.IsMiniBoss { + t.Errorf("%v: IsMiniBoss should be true", tc.mt) + } + if m.IsBoss { + t.Errorf("%v: IsBoss should be false for mini-bosses", tc.mt) + } + } +} + func TestMonsterAtMinFloor(t *testing.T) { // Slime at floor 1 (minFloor=1) should have base stats m := NewMonster(MonsterSlime, 1, 1.15) diff --git a/game/event.go b/game/event.go index 5e6b776..8fb08f7 100644 --- a/game/event.go +++ b/game/event.go @@ -48,6 +48,14 @@ func (s *GameSession) EnterRoom(roomIdx int) { case dungeon.RoomEvent: s.triggerEvent() room.Cleared = true + case dungeon.RoomSecret: + s.grantSecretTreasure() + room.Cleared = true + case dungeon.RoomMiniBoss: + s.spawnMiniBoss() + s.state.Phase = PhaseCombat + s.state.CombatTurn = 0 + s.signalCombat() case dungeon.RoomEmpty: room.Cleared = true } @@ -272,3 +280,76 @@ func (s *GameSession) triggerEvent() { } } } + +func (s *GameSession) grantSecretTreasure() { + s.addLog("You discovered a secret room filled with treasure!") + floor := s.state.FloorNum + // Double treasure: grant two items per player + for _, p := range s.state.Players { + for i := 0; i < 2; i++ { + if len(p.Inventory) >= s.cfg.Game.InventoryLimit { + s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name)) + break + } + if rand.Float64() < 0.5 { + bonus := 3 + rand.Intn(6) + floor/3 + item := entity.Item{ + Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus, + } + p.Inventory = append(p.Inventory, item) + s.addLog(fmt.Sprintf("%s found %s (ATK+%d)", p.Name, item.Name, item.Bonus)) + } else { + bonus := 2 + rand.Intn(4) + floor/4 + item := entity.Item{ + Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus, + } + p.Inventory = append(p.Inventory, item) + s.addLog(fmt.Sprintf("%s found %s (DEF+%d)", p.Name, item.Name, item.Bonus)) + } + } + } +} + +func (s *GameSession) spawnMiniBoss() { + var mt entity.MonsterType + floor := s.state.FloorNum + switch { + case floor <= 4: + mt = entity.MonsterMiniBoss5 + case floor <= 9: + mt = entity.MonsterMiniBoss10 + case floor <= 14: + mt = entity.MonsterMiniBoss15 + default: + mt = entity.MonsterMiniBoss20 + } + miniBoss := entity.NewMonster(mt, floor, s.cfg.Combat.MonsterScaling) + + // Use same pattern as the subsequent boss + switch mt { + case entity.MonsterMiniBoss5: + miniBoss.Pattern = entity.PatternPoison + case entity.MonsterMiniBoss10: + miniBoss.Pattern = entity.PatternBurn + case entity.MonsterMiniBoss15: + miniBoss.Pattern = entity.PatternFreeze + case entity.MonsterMiniBoss20: + miniBoss.Pattern = entity.PatternHeal + } + + if s.state.SoloMode { + miniBoss.HP = int(float64(miniBoss.HP) * s.cfg.Combat.SoloHPReduction) + if miniBoss.HP < 1 { + miniBoss.HP = 1 + } + miniBoss.MaxHP = miniBoss.HP + miniBoss.DEF = int(float64(miniBoss.DEF) * s.cfg.Combat.SoloHPReduction) + } + s.state.Monsters = []*entity.Monster{miniBoss} + s.addLog(fmt.Sprintf("A mini-boss appears: %s!", miniBoss.Name)) + + // Reset skill uses for all players at combat start + for _, p := range s.state.Players { + p.SkillUses = s.cfg.Game.SkillUses + } +} diff --git a/game/turn.go b/game/turn.go index 4269ca5..bfbf6f4 100644 --- a/game/turn.go +++ b/game/turn.go @@ -409,8 +409,8 @@ func (s *GameSession) resolveMonsterActions() { } } } - if m.IsBoss { - // Boss special pattern + if m.IsBoss || m.IsMiniBoss { + // Boss/mini-boss special pattern switch m.Pattern { case entity.PatternPoison: for _, p := range s.state.Players {