From 00581880f203acb1f71eeae7eb5e838552398404 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 16:59:35 +0900 Subject: [PATCH] feat: add hard mode and weekly mutation system Co-Authored-By: Claude Opus 4.6 (1M context) --- config.yaml | 8 +++++ config/config.go | 20 +++++++++---- config/config_test.go | 9 ++++++ game/mutation.go | 41 ++++++++++++++++++++++++++ game/mutation_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++ game/session.go | 20 +++++++------ ui/lobby_view.go | 66 ++++++++++++++++++++++++++--------------- 7 files changed, 193 insertions(+), 39 deletions(-) create mode 100644 game/mutation.go create mode 100644 game/mutation_test.go diff --git a/config.yaml b/config.yaml index e03c8e2..adbe7e5 100644 --- a/config.yaml +++ b/config.yaml @@ -42,3 +42,11 @@ backup: interval_min: 60 # Directory for backup files dir: "./data/backup" + +difficulty: + # Monster stat multiplier in hard mode + hard_mode_monster_mult: 1.5 + # Shop price multiplier in hard mode + hard_mode_shop_mult: 2.0 + # Healing effectiveness multiplier in hard mode + hard_mode_heal_mult: 0.5 diff --git a/config/config.go b/config/config.go index d9b3e54..9b9379e 100644 --- a/config/config.go +++ b/config/config.go @@ -7,11 +7,18 @@ import ( ) type Config struct { - Server ServerConfig `yaml:"server"` - Game GameConfig `yaml:"game"` - Combat CombatConfig `yaml:"combat"` - Dungeon DungeonConfig `yaml:"dungeon"` - Backup BackupConfig `yaml:"backup"` + Server ServerConfig `yaml:"server"` + Game GameConfig `yaml:"game"` + Combat CombatConfig `yaml:"combat"` + Dungeon DungeonConfig `yaml:"dungeon"` + Backup BackupConfig `yaml:"backup"` + Difficulty DifficultyConfig `yaml:"difficulty"` +} + +type DifficultyConfig struct { + HardModeMonsterMult float64 `yaml:"hard_mode_monster_mult"` + HardModeShopMult float64 `yaml:"hard_mode_shop_mult"` + HardModeHealMult float64 `yaml:"hard_mode_heal_mult"` } type ServerConfig struct { @@ -55,7 +62,8 @@ func defaults() Config { }, Combat: CombatConfig{FleeChance: 0.50, MonsterScaling: 1.15, SoloHPReduction: 0.50}, Dungeon: DungeonConfig{MapWidth: 60, MapHeight: 20, MinRooms: 5, MaxRooms: 8}, - Backup: BackupConfig{IntervalMin: 60, Dir: "./data/backup"}, + Backup: BackupConfig{IntervalMin: 60, Dir: "./data/backup"}, + Difficulty: DifficultyConfig{HardModeMonsterMult: 1.5, HardModeShopMult: 2.0, HardModeHealMult: 0.5}, } } diff --git a/config/config_test.go b/config/config_test.go index 4468167..52fbeef 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -37,6 +37,15 @@ func TestLoadDefaults(t *testing.T) { if cfg.Combat.MonsterScaling != 1.15 { t.Errorf("expected monster scaling 1.15, got %f", cfg.Combat.MonsterScaling) } + if cfg.Difficulty.HardModeMonsterMult != 1.5 { + t.Errorf("expected hard mode monster mult 1.5, got %f", cfg.Difficulty.HardModeMonsterMult) + } + if cfg.Difficulty.HardModeShopMult != 2.0 { + t.Errorf("expected hard mode shop mult 2.0, got %f", cfg.Difficulty.HardModeShopMult) + } + if cfg.Difficulty.HardModeHealMult != 0.5 { + t.Errorf("expected hard mode heal mult 0.5, got %f", cfg.Difficulty.HardModeHealMult) + } } func TestLoadFromFile(t *testing.T) { diff --git a/game/mutation.go b/game/mutation.go new file mode 100644 index 0000000..9356e35 --- /dev/null +++ b/game/mutation.go @@ -0,0 +1,41 @@ +package game + +import ( + "crypto/sha256" + "encoding/binary" + "fmt" + "time" + + "github.com/tolelom/catacombs/config" +) + +// Mutation represents a weekly gameplay modifier. +type Mutation struct { + ID string + Name string + Description string + Apply func(cfg *config.GameConfig) +} + +// Mutations is the list of all available mutations. +var Mutations = []Mutation{ + {ID: "no_skills", Name: "Skill Lockout", Description: "Class skills are disabled", + Apply: func(cfg *config.GameConfig) { cfg.SkillUses = 0 }}, + {ID: "speed_run", Name: "Speed Run", Description: "Turn timeout halved", + Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = max(cfg.TurnTimeoutSec/2, 2) }}, + {ID: "no_shop", Name: "Shop Closed", Description: "Shops are unavailable", + Apply: func(cfg *config.GameConfig) {}}, + {ID: "glass_cannon", Name: "Glass Cannon", Description: "Double damage, half HP", + Apply: func(cfg *config.GameConfig) {}}, + {ID: "elite_flood", Name: "Elite Flood", Description: "All monsters are elite", + Apply: func(cfg *config.GameConfig) {}}, +} + +// GetWeeklyMutation returns the mutation for the current week, +// determined by a SHA-256 hash of the year and ISO week number. +func GetWeeklyMutation() Mutation { + year, week := time.Now().ISOWeek() + h := sha256.Sum256([]byte(fmt.Sprintf("mutation:%d:%d", year, week))) + idx := int(binary.BigEndian.Uint64(h[:8]) % uint64(len(Mutations))) + return Mutations[idx] +} diff --git a/game/mutation_test.go b/game/mutation_test.go new file mode 100644 index 0000000..c9587a1 --- /dev/null +++ b/game/mutation_test.go @@ -0,0 +1,68 @@ +package game + +import ( + "testing" + + "github.com/tolelom/catacombs/config" +) + +func TestGetWeeklyMutation(t *testing.T) { + m := GetWeeklyMutation() + if m.ID == "" { + t.Error("expected non-empty mutation ID") + } + if m.Name == "" { + t.Error("expected non-empty mutation Name") + } + if m.Apply == nil { + t.Error("expected non-nil Apply function") + } + // Verify it returns one of the known mutations + found := false + for _, known := range Mutations { + if known.ID == m.ID { + found = true + break + } + } + if !found { + t.Errorf("mutation ID %q not found in Mutations list", m.ID) + } +} + +func TestMutationApplyNoSkills(t *testing.T) { + cfg := config.GameConfig{SkillUses: 3} + // Find the no_skills mutation + var m Mutation + for _, mut := range Mutations { + if mut.ID == "no_skills" { + m = mut + break + } + } + if m.ID == "" { + t.Fatal("no_skills mutation not found") + } + m.Apply(&cfg) + if cfg.SkillUses != 0 { + t.Errorf("expected SkillUses=0 after no_skills mutation, got %d", cfg.SkillUses) + } +} + +func TestMutationApplySpeedRun(t *testing.T) { + cfg := config.GameConfig{TurnTimeoutSec: 10} + var m Mutation + for _, mut := range Mutations { + if mut.ID == "speed_run" { + m = mut + break + } + } + if m.ID == "" { + t.Fatal("speed_run mutation not found") + } + m.Apply(&cfg) + if cfg.TurnTimeoutSec != 5 { + t.Errorf("expected TurnTimeoutSec=5 after speed_run mutation, got %d", cfg.TurnTimeoutSec) + } +} diff --git a/game/session.go b/game/session.go index 97d8955..94d6810 100644 --- a/game/session.go +++ b/game/session.go @@ -73,15 +73,17 @@ func (s *GameSession) clearLog() { } type GameSession struct { - mu sync.Mutex - cfg *config.Config - state GameState - started bool - actions map[string]PlayerAction // playerName -> action - actionCh chan playerActionMsg - combatSignal chan struct{} - done chan struct{} - lastActivity map[string]time.Time // fingerprint -> last activity time + mu sync.Mutex + cfg *config.Config + state GameState + started bool + actions map[string]PlayerAction // playerName -> action + actionCh chan playerActionMsg + combatSignal chan struct{} + done chan struct{} + lastActivity map[string]time.Time // fingerprint -> last activity time + HardMode bool + ActiveMutation *Mutation } type playerActionMsg struct { diff --git a/ui/lobby_view.go b/ui/lobby_view.go index f8909eb..0447164 100644 --- a/ui/lobby_view.go +++ b/ui/lobby_view.go @@ -24,14 +24,16 @@ type playerInfo struct { // LobbyScreen shows available rooms and lets players create/join. type LobbyScreen struct { - rooms []roomInfo - input string - cursor int - creating bool - roomName string - joining bool - codeInput string - online int + rooms []roomInfo + input string + cursor int + creating bool + roomName string + joining bool + codeInput string + online int + hardMode bool + hardUnlocked bool } func NewLobbyScreen() *LobbyScreen { @@ -62,6 +64,9 @@ func (s *LobbyScreen) refreshLobby(ctx *Context) { } s.online = len(ctx.Lobby.ListOnline()) s.cursor = 0 + if ctx.Store != nil { + s.hardUnlocked = ctx.Store.IsUnlocked(ctx.Fingerprint, "hard_mode") + } } func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { @@ -115,6 +120,8 @@ func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { return NewClassSelectScreen(), nil } } + } else if isKey(key, "h") && s.hardUnlocked { + s.hardMode = !s.hardMode } else if isKey(key, "q") { if ctx.Lobby != nil { ctx.Lobby.PlayerOffline(ctx.Fingerprint) @@ -127,27 +134,31 @@ func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { func (s *LobbyScreen) View(ctx *Context) string { state := lobbyState{ - rooms: s.rooms, - input: s.input, - cursor: s.cursor, - creating: s.creating, - roomName: s.roomName, - joining: s.joining, - codeInput: s.codeInput, - online: s.online, + rooms: s.rooms, + input: s.input, + cursor: s.cursor, + creating: s.creating, + roomName: s.roomName, + joining: s.joining, + codeInput: s.codeInput, + online: s.online, + hardMode: s.hardMode, + hardUnlocked: s.hardUnlocked, } return renderLobby(state, ctx.Width, ctx.Height) } type lobbyState struct { - rooms []roomInfo - input string - cursor int - creating bool - roomName string - joining bool - codeInput string - online int + rooms []roomInfo + input string + cursor int + creating bool + roomName string + joining bool + codeInput string + online int + hardMode bool + hardUnlocked bool } func renderLobby(state lobbyState, width, height int) string { @@ -161,6 +172,13 @@ func renderLobby(state lobbyState, width, height int) string { header := headerStyle.Render(fmt.Sprintf("── Lobby ── %d online ──", state.online)) menu := "[C] Create Room [J] Join by Code [Up/Down] Select [Enter] Join [Q] Back" + if state.hardUnlocked { + hardStatus := "OFF" + if state.hardMode { + hardStatus = "ON" + } + menu += fmt.Sprintf(" [H] Hard Mode: %s", hardStatus) + } roomList := "" for i, r := range state.rooms {