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:
@@ -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 {
|
||||||
|
|||||||
@@ -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++ {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user