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
# 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

View File

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

View File

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

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 {
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 {

View File

@@ -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 {