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) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 15:30:21 +09:00
parent e167165bbc
commit 7f29995833
8 changed files with 199 additions and 18 deletions

View File

@@ -82,8 +82,8 @@ func AttemptFlee(fleeChance float64) bool {
} }
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) { func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
if m.IsBoss && turnNumber > 0 && turnNumber%3 == 0 { if (m.IsBoss || m.IsMiniBoss) && turnNumber > 0 && turnNumber%3 == 0 {
return -1, true // AoE every 3 turns for all bosses return -1, true // AoE every 3 turns for all bosses and mini-bosses
} }
if m.TauntTarget { if m.TauntTarget {
for i, p := range players { for i, p := range players {

View File

@@ -111,6 +111,14 @@ func GenerateFloor(floorNum int) *Floor {
// Last room is boss // Last room is boss
rooms[len(rooms)-1].Type = RoomBoss 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 // Carve rooms into tile map
for _, room := range rooms { for _, room := range rooms {
for dy := 0; dy < room.H; dy++ { for dy := 0; dy < room.H; dy++ {

View File

@@ -37,7 +37,49 @@ func TestRoomTypeProbability(t *testing.T) {
} }
combatPct := float64(counts[RoomCombat]) / float64(n) * 100 combatPct := float64(counts[RoomCombat]) / float64(n) * 100
if combatPct < 40 || combatPct > 50 { 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
}
}
} }
} }

View File

@@ -11,10 +11,12 @@ const (
RoomEvent RoomEvent
RoomEmpty RoomEmpty
RoomBoss RoomBoss
RoomSecret
RoomMiniBoss
) )
func (r RoomType) String() string { 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 type Tile int
@@ -47,13 +49,15 @@ type Floor struct {
func RandomRoomType() RoomType { func RandomRoomType() RoomType {
r := rand.Float64() * 100 r := rand.Float64() * 100
switch { switch {
case r < 45: case r < 5:
return RoomSecret
case r < 50:
return RoomCombat return RoomCombat
case r < 60: case r < 65:
return RoomTreasure return RoomTreasure
case r < 70: case r < 75:
return RoomShop return RoomShop
case r < 85: case r < 90:
return RoomEvent return RoomEvent
default: default:
return RoomEmpty return RoomEmpty

View File

@@ -13,6 +13,10 @@ const (
MonsterBoss10 MonsterBoss10
MonsterBoss15 MonsterBoss15
MonsterBoss20 MonsterBoss20
MonsterMiniBoss5
MonsterMiniBoss10
MonsterMiniBoss15
MonsterMiniBoss20
) )
type monsterBase struct { type monsterBase struct {
@@ -31,6 +35,10 @@ var monsterDefs = map[MonsterType]monsterBase{
MonsterBoss10: {"Warden", 250, 22, 12, 10, true}, MonsterBoss10: {"Warden", 250, 22, 12, 10, true},
MonsterBoss15: {"Overlord", 400, 30, 16, 15, true}, MonsterBoss15: {"Overlord", 400, 30, 16, 15, true},
MonsterBoss20: {"Archlich", 600, 40, 20, 20, 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 type BossPattern int
@@ -50,6 +58,7 @@ type Monster struct {
HP, MaxHP int HP, MaxHP int
ATK, DEF int ATK, DEF int
IsBoss bool IsBoss bool
IsMiniBoss bool
IsElite bool IsElite bool
ElitePrefix ElitePrefixType ElitePrefix ElitePrefixType
TauntTarget bool TauntTarget bool
@@ -59,21 +68,24 @@ type Monster struct {
func NewMonster(mt MonsterType, floor int, scaling float64) *Monster { func NewMonster(mt MonsterType, floor int, scaling float64) *Monster {
base := monsterDefs[mt] base := monsterDefs[mt]
isMiniBoss := mt == MonsterMiniBoss5 || mt == MonsterMiniBoss10 ||
mt == MonsterMiniBoss15 || mt == MonsterMiniBoss20
scale := 1.0 scale := 1.0
if !base.IsBoss && floor > base.MinFloor { if !base.IsBoss && !isMiniBoss && floor > base.MinFloor {
scale = math.Pow(scaling, float64(floor-base.MinFloor)) scale = math.Pow(scaling, float64(floor-base.MinFloor))
} }
hp := int(math.Round(float64(base.HP) * scale)) hp := int(math.Round(float64(base.HP) * scale))
atk := int(math.Round(float64(base.ATK) * scale)) atk := int(math.Round(float64(base.ATK) * scale))
def := int(math.Round(float64(base.DEF) * scale)) def := int(math.Round(float64(base.DEF) * scale))
return &Monster{ return &Monster{
Name: base.Name, Name: base.Name,
Type: mt, Type: mt,
HP: hp, HP: hp,
MaxHP: hp, MaxHP: hp,
ATK: atk, ATK: atk,
DEF: def, DEF: def,
IsBoss: base.IsBoss, IsBoss: base.IsBoss,
IsMiniBoss: isMiniBoss,
} }
} }

View File

@@ -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) { func TestMonsterAtMinFloor(t *testing.T) {
// Slime at floor 1 (minFloor=1) should have base stats // Slime at floor 1 (minFloor=1) should have base stats
m := NewMonster(MonsterSlime, 1, 1.15) m := NewMonster(MonsterSlime, 1, 1.15)

View File

@@ -48,6 +48,14 @@ func (s *GameSession) EnterRoom(roomIdx int) {
case dungeon.RoomEvent: case dungeon.RoomEvent:
s.triggerEvent() s.triggerEvent()
room.Cleared = true 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: case dungeon.RoomEmpty:
room.Cleared = true 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
}
}

View File

@@ -409,8 +409,8 @@ func (s *GameSession) resolveMonsterActions() {
} }
} }
} }
if m.IsBoss { if m.IsBoss || m.IsMiniBoss {
// Boss special pattern // Boss/mini-boss special pattern
switch m.Pattern { switch m.Pattern {
case entity.PatternPoison: case entity.PatternPoison:
for _, p := range s.state.Players { for _, p := range s.state.Players {