feat: add hard mode and weekly mutation system

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:59:35 +09:00
parent cf37eef1b1
commit 00581880f2
7 changed files with 193 additions and 39 deletions

View File

@@ -42,3 +42,11 @@ backup:
interval_min: 60 interval_min: 60
# Directory for backup files # Directory for backup files
dir: "./data/backup" 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

View File

@@ -7,11 +7,18 @@ import (
) )
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Game GameConfig `yaml:"game"` Game GameConfig `yaml:"game"`
Combat CombatConfig `yaml:"combat"` Combat CombatConfig `yaml:"combat"`
Dungeon DungeonConfig `yaml:"dungeon"` Dungeon DungeonConfig `yaml:"dungeon"`
Backup BackupConfig `yaml:"backup"` 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 { type ServerConfig struct {
@@ -55,7 +62,8 @@ func defaults() Config {
}, },
Combat: CombatConfig{FleeChance: 0.50, MonsterScaling: 1.15, SoloHPReduction: 0.50}, Combat: CombatConfig{FleeChance: 0.50, MonsterScaling: 1.15, SoloHPReduction: 0.50},
Dungeon: DungeonConfig{MapWidth: 60, MapHeight: 20, MinRooms: 5, MaxRooms: 8}, 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},
} }
} }

View File

@@ -37,6 +37,15 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Combat.MonsterScaling != 1.15 { if cfg.Combat.MonsterScaling != 1.15 {
t.Errorf("expected monster scaling 1.15, got %f", cfg.Combat.MonsterScaling) 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) { func TestLoadFromFile(t *testing.T) {

41
game/mutation.go Normal file
View File

@@ -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]
}

68
game/mutation_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -73,15 +73,17 @@ func (s *GameSession) clearLog() {
} }
type GameSession struct { type GameSession struct {
mu sync.Mutex mu sync.Mutex
cfg *config.Config cfg *config.Config
state GameState state GameState
started bool started bool
actions map[string]PlayerAction // playerName -> action actions map[string]PlayerAction // playerName -> action
actionCh chan playerActionMsg actionCh chan playerActionMsg
combatSignal chan struct{} combatSignal chan struct{}
done chan struct{} done chan struct{}
lastActivity map[string]time.Time // fingerprint -> last activity time lastActivity map[string]time.Time // fingerprint -> last activity time
HardMode bool
ActiveMutation *Mutation
} }
type playerActionMsg struct { type playerActionMsg struct {

View File

@@ -24,14 +24,16 @@ type playerInfo struct {
// LobbyScreen shows available rooms and lets players create/join. // LobbyScreen shows available rooms and lets players create/join.
type LobbyScreen struct { type LobbyScreen struct {
rooms []roomInfo rooms []roomInfo
input string input string
cursor int cursor int
creating bool creating bool
roomName string roomName string
joining bool joining bool
codeInput string codeInput string
online int online int
hardMode bool
hardUnlocked bool
} }
func NewLobbyScreen() *LobbyScreen { func NewLobbyScreen() *LobbyScreen {
@@ -62,6 +64,9 @@ func (s *LobbyScreen) refreshLobby(ctx *Context) {
} }
s.online = len(ctx.Lobby.ListOnline()) s.online = len(ctx.Lobby.ListOnline())
s.cursor = 0 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) { 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 return NewClassSelectScreen(), nil
} }
} }
} else if isKey(key, "h") && s.hardUnlocked {
s.hardMode = !s.hardMode
} else if isKey(key, "q") { } else if isKey(key, "q") {
if ctx.Lobby != nil { if ctx.Lobby != nil {
ctx.Lobby.PlayerOffline(ctx.Fingerprint) 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 { func (s *LobbyScreen) View(ctx *Context) string {
state := lobbyState{ state := lobbyState{
rooms: s.rooms, rooms: s.rooms,
input: s.input, input: s.input,
cursor: s.cursor, cursor: s.cursor,
creating: s.creating, creating: s.creating,
roomName: s.roomName, roomName: s.roomName,
joining: s.joining, joining: s.joining,
codeInput: s.codeInput, codeInput: s.codeInput,
online: s.online, online: s.online,
hardMode: s.hardMode,
hardUnlocked: s.hardUnlocked,
} }
return renderLobby(state, ctx.Width, ctx.Height) return renderLobby(state, ctx.Width, ctx.Height)
} }
type lobbyState struct { type lobbyState struct {
rooms []roomInfo rooms []roomInfo
input string input string
cursor int cursor int
creating bool creating bool
roomName string roomName string
joining bool joining bool
codeInput string codeInput string
online int online int
hardMode bool
hardUnlocked bool
} }
func renderLobby(state lobbyState, width, height int) string { 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)) 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" 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 := "" roomList := ""
for i, r := range state.rooms { for i, r := range state.rooms {