From 8ef3d9dd1368df0b4a4370f57f10a72310a1e7f1 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 14:36:19 +0900 Subject: [PATCH] feat: add skill tree system with 2 branches per class Co-Authored-By: Claude Opus 4.6 (1M context) --- entity/player.go | 3 + entity/skill_tree.go | 196 ++++++++++++++++++++++++++++++++++++++ entity/skill_tree_test.go | 148 ++++++++++++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 entity/skill_tree.go create mode 100644 entity/skill_tree_test.go diff --git a/entity/player.go b/entity/player.go index 9965588..5f7df6c 100644 --- a/entity/player.go +++ b/entity/player.go @@ -55,6 +55,7 @@ type Player struct { Dead bool Fled bool SkillUses int // remaining skill uses this combat + Skills *PlayerSkills } func NewPlayer(name string, class Class) *Player { @@ -118,6 +119,7 @@ func (p *Player) EffectiveATK() int { atk += r.Value } } + atk += p.Skills.GetATKBonus(p.Class) return atk } @@ -133,6 +135,7 @@ func (p *Player) EffectiveDEF() int { def += r.Value } } + def += p.Skills.GetDEFBonus(p.Class) return def } diff --git a/entity/skill_tree.go b/entity/skill_tree.go new file mode 100644 index 0000000..a930b09 --- /dev/null +++ b/entity/skill_tree.go @@ -0,0 +1,196 @@ +package entity + +import "errors" + +// SkillEffect represents the type of bonus a skill node provides. +type SkillEffect int + +const ( + EffectATKBoost SkillEffect = iota + EffectDEFBoost + EffectMaxHPBoost + EffectSkillPower + EffectCritChance + EffectHealBoost +) + +// SkillNode is a single node in a skill branch. +type SkillNode struct { + Name string + Effect SkillEffect + Value int +} + +// SkillBranch is a named sequence of 3 skill nodes. +type SkillBranch struct { + Name string + Nodes [3]SkillNode +} + +// PlayerSkills tracks a player's skill tree state for the current run. +type PlayerSkills struct { + BranchIndex int // -1 = not chosen, 0 or 1 + Points int // total points earned (1 per floor clear) + Allocated int // points spent in chosen branch (max 3) +} + +// NewPlayerSkills returns an initialized PlayerSkills with no branch chosen. +func NewPlayerSkills() *PlayerSkills { + return &PlayerSkills{BranchIndex: -1} +} + +// branchDefs holds 2 branches per class. +var branchDefs = map[Class][2]SkillBranch{ + ClassWarrior: { + { + Name: "Tank", + Nodes: [3]SkillNode{ + {"Iron Skin", EffectDEFBoost, 3}, + {"Fortitude", EffectMaxHPBoost, 20}, + {"Bastion", EffectDEFBoost, 5}, + }, + }, + { + Name: "Berserker", + Nodes: [3]SkillNode{ + {"Fury", EffectATKBoost, 4}, + {"Wrath", EffectSkillPower, 20}, + {"Rampage", EffectATKBoost, 6}, + }, + }, + }, + ClassMage: { + { + Name: "Elementalist", + Nodes: [3]SkillNode{ + {"Arcane Focus", EffectSkillPower, 15}, + {"Elemental Fury", EffectATKBoost, 5}, + {"Overload", EffectSkillPower, 25}, + }, + }, + { + Name: "Chronomancer", + Nodes: [3]SkillNode{ + {"Temporal Shield", EffectDEFBoost, 3}, + {"Time Warp", EffectATKBoost, 3}, + {"Stasis", EffectMaxHPBoost, 15}, + }, + }, + }, + ClassHealer: { + { + Name: "Guardian", + Nodes: [3]SkillNode{ + {"Blessing", EffectHealBoost, 20}, + {"Divine Armor", EffectDEFBoost, 4}, + {"Miracle", EffectHealBoost, 30}, + }, + }, + { + Name: "Priest", + Nodes: [3]SkillNode{ + {"Smite", EffectATKBoost, 5}, + {"Holy Power", EffectSkillPower, 20}, + {"Judgment", EffectATKBoost, 7}, + }, + }, + }, + ClassRogue: { + { + Name: "Assassin", + Nodes: [3]SkillNode{ + {"Backstab", EffectATKBoost, 5}, + {"Precision", EffectCritChance, 15}, + {"Execute", EffectATKBoost, 8}, + }, + }, + { + Name: "Alchemist", + Nodes: [3]SkillNode{ + {"Tonic", EffectHealBoost, 15}, + {"Brew", EffectSkillPower, 20}, + {"Elixir", EffectMaxHPBoost, 25}, + }, + }, + }, +} + +// GetBranches returns the 2 skill branches for the given class. +func GetBranches(class Class) [2]SkillBranch { + return branchDefs[class] +} + +// Allocate spends one skill point into the given branch. Returns an error if +// the player tries to switch branches after first allocation or has already +// allocated the maximum of 3 points. +func (ps *PlayerSkills) Allocate(branchIdx int, class Class) error { + if ps == nil { + return errors.New("skills not initialized") + } + if branchIdx < 0 || branchIdx > 1 { + return errors.New("invalid branch index") + } + if ps.Allocated >= 3 { + return errors.New("branch fully allocated") + } + if ps.Points <= ps.Allocated { + return errors.New("no available skill points") + } + if ps.BranchIndex != -1 && ps.BranchIndex != branchIdx { + return errors.New("cannot switch branch after first allocation") + } + ps.BranchIndex = branchIdx + ps.Allocated++ + return nil +} + +// allocatedNodes returns the slice of nodes the player has unlocked. +func (ps *PlayerSkills) allocatedNodes(class Class) []SkillNode { + if ps == nil || ps.BranchIndex < 0 || ps.Allocated == 0 { + return nil + } + branches := GetBranches(class) + branch := branches[ps.BranchIndex] + return branch.Nodes[:ps.Allocated] +} + +// sumEffect sums values of nodes matching the given effect. +func (ps *PlayerSkills) sumEffect(class Class, effect SkillEffect) int { + total := 0 + for _, node := range ps.allocatedNodes(class) { + if node.Effect == effect { + total += node.Value + } + } + return total +} + +// GetATKBonus returns the total ATK bonus from allocated skill nodes. +func (ps *PlayerSkills) GetATKBonus(class Class) int { + return ps.sumEffect(class, EffectATKBoost) +} + +// GetDEFBonus returns the total DEF bonus from allocated skill nodes. +func (ps *PlayerSkills) GetDEFBonus(class Class) int { + return ps.sumEffect(class, EffectDEFBoost) +} + +// GetMaxHPBonus returns the total MaxHP bonus from allocated skill nodes. +func (ps *PlayerSkills) GetMaxHPBonus(class Class) int { + return ps.sumEffect(class, EffectMaxHPBoost) +} + +// GetSkillPower returns the total SkillPower bonus from allocated skill nodes. +func (ps *PlayerSkills) GetSkillPower(class Class) int { + return ps.sumEffect(class, EffectSkillPower) +} + +// GetCritChance returns the total CritChance bonus from allocated skill nodes. +func (ps *PlayerSkills) GetCritChance(class Class) int { + return ps.sumEffect(class, EffectCritChance) +} + +// GetHealBoost returns the total HealBoost bonus from allocated skill nodes. +func (ps *PlayerSkills) GetHealBoost(class Class) int { + return ps.sumEffect(class, EffectHealBoost) +} diff --git a/entity/skill_tree_test.go b/entity/skill_tree_test.go new file mode 100644 index 0000000..41ff9a4 --- /dev/null +++ b/entity/skill_tree_test.go @@ -0,0 +1,148 @@ +package entity + +import "testing" + +func TestGetBranches(t *testing.T) { + classes := []Class{ClassWarrior, ClassMage, ClassHealer, ClassRogue} + for _, c := range classes { + branches := GetBranches(c) + if len(branches) != 2 { + t.Errorf("expected 2 branches for %s, got %d", c, len(branches)) + } + for i, b := range branches { + if b.Name == "" { + t.Errorf("branch %d for %s has empty name", i, c) + } + for j, node := range b.Nodes { + if node.Name == "" { + t.Errorf("node %d in branch %d for %s has empty name", j, i, c) + } + if node.Value <= 0 { + t.Errorf("node %d in branch %d for %s has non-positive value %d", j, i, c, node.Value) + } + } + } + } +} + +func TestAllocateSkillPoint(t *testing.T) { + ps := NewPlayerSkills() + ps.Points = 1 + + err := ps.Allocate(0, ClassWarrior) + if err != nil { + t.Fatalf("unexpected error on first allocation: %v", err) + } + if ps.BranchIndex != 0 { + t.Errorf("expected BranchIndex 0, got %d", ps.BranchIndex) + } + if ps.Allocated != 1 { + t.Errorf("expected Allocated 1, got %d", ps.Allocated) + } +} + +func TestCannotSwitchBranch(t *testing.T) { + ps := NewPlayerSkills() + ps.Points = 2 + + err := ps.Allocate(0, ClassWarrior) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = ps.Allocate(1, ClassWarrior) + if err == nil { + t.Fatal("expected error when switching branch, got nil") + } +} + +func TestCannotAllocateWithoutPoints(t *testing.T) { + ps := NewPlayerSkills() + ps.Points = 0 + + err := ps.Allocate(0, ClassWarrior) + if err == nil { + t.Fatal("expected error when no points available, got nil") + } +} + +func TestFullyAllocated(t *testing.T) { + ps := NewPlayerSkills() + ps.Points = 4 + + for i := 0; i < 3; i++ { + err := ps.Allocate(0, ClassWarrior) + if err != nil { + t.Fatalf("unexpected error on allocation %d: %v", i+1, err) + } + } + + err := ps.Allocate(0, ClassWarrior) + if err == nil { + t.Fatal("expected error when fully allocated, got nil") + } +} + +func TestSkillBonuses(t *testing.T) { + // Warrior Tank branch: DEF+3, MaxHP+20, DEF+5 + ps := NewPlayerSkills() + ps.Points = 3 + for i := 0; i < 3; i++ { + if err := ps.Allocate(0, ClassWarrior); err != nil { + t.Fatalf("allocate error: %v", err) + } + } + + if got := ps.GetDEFBonus(ClassWarrior); got != 8 { + t.Errorf("expected DEF bonus 8 (3+5), got %d", got) + } + if got := ps.GetMaxHPBonus(ClassWarrior); got != 20 { + t.Errorf("expected MaxHP bonus 20, got %d", got) + } + if got := ps.GetATKBonus(ClassWarrior); got != 0 { + t.Errorf("expected ATK bonus 0 for Tank, got %d", got) + } + + // Warrior Berserker branch: ATK+4, SkillPower+20, ATK+6 + ps2 := NewPlayerSkills() + ps2.Points = 3 + for i := 0; i < 3; i++ { + if err := ps2.Allocate(1, ClassWarrior); err != nil { + t.Fatalf("allocate error: %v", err) + } + } + if got := ps2.GetATKBonus(ClassWarrior); got != 10 { + t.Errorf("expected ATK bonus 10 (4+6), got %d", got) + } + if got := ps2.GetSkillPower(ClassWarrior); got != 20 { + t.Errorf("expected SkillPower bonus 20, got %d", got) + } +} + +func TestNilPlayerSkillsBonuses(t *testing.T) { + var ps *PlayerSkills + if got := ps.GetATKBonus(ClassWarrior); got != 0 { + t.Errorf("expected 0 ATK bonus from nil skills, got %d", got) + } + if got := ps.GetDEFBonus(ClassWarrior); got != 0 { + t.Errorf("expected 0 DEF bonus from nil skills, got %d", got) + } +} + +func TestPartialAllocation(t *testing.T) { + // Rogue Assassin: ATK+5, CritChance+15, ATK+8 + // Allocate only 2 points: should get ATK+5 and CritChance+15 + ps := NewPlayerSkills() + ps.Points = 2 + for i := 0; i < 2; i++ { + if err := ps.Allocate(0, ClassRogue); err != nil { + t.Fatalf("allocate error: %v", err) + } + } + if got := ps.GetATKBonus(ClassRogue); got != 5 { + t.Errorf("expected ATK bonus 5, got %d", got) + } + if got := ps.GetCritChance(ClassRogue); got != 15 { + t.Errorf("expected CritChance bonus 15, got %d", got) + } +}