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:
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
41
game/mutation.go
Normal 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
68
game/mutation_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user