added lern and skill improvement cost editing

This commit is contained in:
2026-04-16 22:16:11 +02:00
parent c9a8b5771a
commit c684123ffc
10 changed files with 1783 additions and 221 deletions
+8
View File
@@ -1,3 +1,11 @@
Ich möchte eine oder zwei Seiten zur Einstellung und Verwaltung von Lernkosten für Charakterklassen und Fertigkeitskategorien als auch von lernkosten pro Schwierigkeit und TE pro Schwierigkeit und Stufe erstellen. Beispiele für Originallisten findest Du in Lern_und_Trainingslisten.md
Die nötigen Datenstrukturen im Backend sind vorhanden. Erstelle wenn nötig passende API Endpunkte inclusive der dazu gehörigen Tests.
Erstelle vor allem die views und Componenten für das Frontend.
Plane gründlich prüfe das Ergebnis sogfältig.
Du darfst Subagents starten wenn das notwendig oder sinnvoll ist
# --------------
move the model in this file to the appropiate package.
register everything via the init function
Take care that no circle references are created during this process.
@@ -0,0 +1,311 @@
package maintenance
import (
"bamort/bmrt/models"
"bamort/database"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// --- Character Classes ---
func GetCharacterClasses(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
var classes []models.CharacterClass
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
Order("code ASC").
Find(&classes).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve character classes")
return
}
c.JSON(http.StatusOK, gin.H{"character_classes": classes})
}
// --- Skill Categories ---
func GetSkillCategories(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
var categories []models.SkillCategory
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
Order("name ASC").
Find(&categories).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill categories")
return
}
c.JSON(http.StatusOK, gin.H{"skill_categories": categories})
}
// --- Skill Difficulties ---
func GetSkillDifficulties(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
var difficulties []models.SkillDifficulty
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
Order("id ASC").
Find(&difficulties).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill difficulties")
return
}
c.JSON(http.StatusOK, gin.H{"skill_difficulties": difficulties})
}
// --- Spell Schools ---
func GetSpellSchools(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
var schools []models.SpellSchool
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
Order("name ASC").
Find(&schools).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve spell schools")
return
}
c.JSON(http.StatusOK, gin.H{"spell_schools": schools})
}
// --- Class Category EP Costs (EP per TE) ---
func GetClassCategoryEPCosts(c *gin.Context) {
var costs []models.ClassCategoryEPCost
if err := database.DB.
Preload("CharacterClass").
Preload("SkillCategory").
Order("character_class ASC, skill_category ASC").
Find(&costs).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve class category EP costs")
return
}
c.JSON(http.StatusOK, gin.H{"costs": costs})
}
type classCategoryEPCostUpdateRequest struct {
EPPerTE *int `json:"ep_per_te"`
}
func UpdateClassCategoryEPCost(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid ID")
return
}
var cost models.ClassCategoryEPCost
if err := database.DB.First(&cost, uint(id)).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Class category EP cost not found")
return
}
var payload classCategoryEPCostUpdateRequest
if err := c.ShouldBindJSON(&payload); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if payload.EPPerTE != nil {
cost.EPPerTE = *payload.EPPerTE
}
if err := database.DB.Save(&cost).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update class category EP cost")
return
}
// Reload with preloads
if err := database.DB.Preload("CharacterClass").Preload("SkillCategory").First(&cost, cost.ID).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to reload updated cost")
return
}
c.JSON(http.StatusOK, cost)
}
// --- Class Spell School EP Costs (EP per LE) ---
func GetClassSpellSchoolEPCosts(c *gin.Context) {
var costs []models.ClassSpellSchoolEPCost
if err := database.DB.
Preload("CharacterClass").
Preload("SpellSchool").
Order("character_class ASC, spell_school ASC").
Find(&costs).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve class spell school EP costs")
return
}
c.JSON(http.StatusOK, gin.H{"costs": costs})
}
type classSpellSchoolEPCostUpdateRequest struct {
EPPerLE *int `json:"ep_per_le"`
}
func UpdateClassSpellSchoolEPCost(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid ID")
return
}
var cost models.ClassSpellSchoolEPCost
if err := database.DB.First(&cost, uint(id)).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Class spell school EP cost not found")
return
}
var payload classSpellSchoolEPCostUpdateRequest
if err := c.ShouldBindJSON(&payload); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if payload.EPPerLE != nil {
cost.EPPerLE = *payload.EPPerLE
}
if err := database.DB.Save(&cost).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update class spell school EP cost")
return
}
// Reload with preloads
if err := database.DB.Preload("CharacterClass").Preload("SpellSchool").First(&cost, cost.ID).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to reload updated cost")
return
}
c.JSON(http.StatusOK, cost)
}
// --- Spell Level LE Costs ---
func GetSpellLevelLECosts(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
var costs []models.SpellLevelLECost
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
Order("level ASC").
Find(&costs).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve spell level LE costs")
return
}
c.JSON(http.StatusOK, gin.H{"costs": costs})
}
type spellLevelLECostUpdateRequest struct {
LERequired *int `json:"le_required"`
}
func UpdateSpellLevelLECost(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid ID")
return
}
var cost models.SpellLevelLECost
if err := database.DB.First(&cost, uint(id)).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Spell level LE cost not found")
return
}
var payload spellLevelLECostUpdateRequest
if err := c.ShouldBindJSON(&payload); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if payload.LERequired != nil {
cost.LERequired = *payload.LERequired
}
if err := database.DB.Save(&cost).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update spell level LE cost")
return
}
c.JSON(http.StatusOK, cost)
}
// --- Skill Category Difficulties (Lernen) ---
func GetSkillCategoryDifficulties(c *gin.Context) {
var items []models.SkillCategoryDifficulty
if err := database.DB.
Preload("Skill").
Preload("SkillCategory").
Preload("SkillDifficulty").
Order("skill_category ASC, skill_difficulty ASC, learn_cost ASC").
Find(&items).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill category difficulties")
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
type skillCategoryDifficultyUpdateRequest struct {
LearnCost *int `json:"learn_cost"`
}
func UpdateSkillCategoryDifficulty(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid ID")
return
}
var item models.SkillCategoryDifficulty
if err := database.DB.First(&item, uint(id)).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Skill category difficulty not found")
return
}
var payload skillCategoryDifficultyUpdateRequest
if err := c.ShouldBindJSON(&payload); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if payload.LearnCost != nil {
item.LearnCost = *payload.LearnCost
}
if err := database.DB.Save(&item).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update skill category difficulty")
return
}
if err := database.DB.Preload("Skill").Preload("SkillCategory").Preload("SkillDifficulty").First(&item, item.ID).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to reload updated item")
return
}
c.JSON(http.StatusOK, item)
}
@@ -0,0 +1,520 @@
package maintenance
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"bamort/bmrt/models"
"bamort/database"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- Lookup helpers for pre-seeded test data ---
func findCharacterClass(t *testing.T, code string) models.CharacterClass {
t.Helper()
var cc models.CharacterClass
require.NoError(t, database.DB.Where("code = ?", code).First(&cc).Error,
"expected pre-seeded character class %q", code)
return cc
}
func findSkillCategory(t *testing.T, name string) models.SkillCategory {
t.Helper()
var sc models.SkillCategory
require.NoError(t, database.DB.Where("name = ?", name).First(&sc).Error,
"expected pre-seeded skill category %q", name)
return sc
}
func findSkillDifficulty(t *testing.T, name string) models.SkillDifficulty {
t.Helper()
var sd models.SkillDifficulty
require.NoError(t, database.DB.Where("name = ?", name).First(&sd).Error,
"expected pre-seeded skill difficulty %q", name)
return sd
}
func findSpellSchool(t *testing.T, name string) models.SpellSchool {
t.Helper()
var ss models.SpellSchool
require.NoError(t, database.DB.Where("name = ?", name).First(&ss).Error,
"expected pre-seeded spell school %q", name)
return ss
}
func findClassCategoryEPCost(t *testing.T, classCode, categoryName string) models.ClassCategoryEPCost {
t.Helper()
var cost models.ClassCategoryEPCost
require.NoError(t, database.DB.
Where("character_class = ? AND skill_category = ?", classCode, categoryName).
First(&cost).Error,
"expected pre-seeded EP cost for class %q / category %q", classCode, categoryName)
return cost
}
func findClassSpellSchoolEPCost(t *testing.T, classCode, schoolName string) models.ClassSpellSchoolEPCost {
t.Helper()
var cost models.ClassSpellSchoolEPCost
require.NoError(t, database.DB.
Where("character_class = ? AND spell_school = ?", classCode, schoolName).
First(&cost).Error,
"expected pre-seeded EP cost for class %q / school %q", classCode, schoolName)
return cost
}
func findSpellLevelLECost(t *testing.T, level int) models.SpellLevelLECost {
t.Helper()
var cost models.SpellLevelLECost
require.NoError(t, database.DB.Where("level = ?", level).First(&cost).Error,
"expected pre-seeded spell level LE cost for level %d", level)
return cost
}
// --- Character Classes ---
func TestGetCharacterClasses(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
seeded := findCharacterClass(t, "Hx")
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/character-classes", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
CharacterClasses []models.CharacterClass `json:"character_classes"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
assert.GreaterOrEqual(t, len(payload.CharacterClasses), 15, "expected at least 15 pre-seeded classes")
var found bool
for _, cc := range payload.CharacterClasses {
if cc.ID == seeded.ID {
found = true
assert.Equal(t, "Hx", cc.Code)
assert.Equal(t, "Hexer", cc.Name)
}
}
assert.True(t, found, "expected Hexer (Hx) in response")
}
func TestGetCharacterClassesUnauthorized(t *testing.T) {
_, router, _ := setupMaintenanceTest(t)
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/character-classes", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusUnauthorized, resp.Code)
}
// --- Skill Categories ---
func TestGetSkillCategories(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
seeded := findSkillCategory(t, "Alltag")
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-categories", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
SkillCategories []models.SkillCategory `json:"skill_categories"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
assert.GreaterOrEqual(t, len(payload.SkillCategories), 9, "expected at least 9 pre-seeded skill categories")
var found bool
for _, sc := range payload.SkillCategories {
if sc.ID == seeded.ID {
found = true
assert.Equal(t, "Alltag", sc.Name)
}
}
assert.True(t, found, "expected Alltag in response")
}
// --- Skill Difficulties ---
func TestGetSkillDifficulties(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
seeded := findSkillDifficulty(t, "leicht")
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-difficulties", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
SkillDifficulties []models.SkillDifficulty `json:"skill_difficulties"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
assert.GreaterOrEqual(t, len(payload.SkillDifficulties), 4, "expected at least 4 pre-seeded difficulties")
var found bool
for _, sd := range payload.SkillDifficulties {
if sd.ID == seeded.ID {
found = true
assert.Equal(t, "leicht", sd.Name)
}
}
assert.True(t, found, "expected leicht in response")
}
// --- Spell Schools ---
func TestGetSpellSchools(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
seeded := findSpellSchool(t, "Beherrschen")
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/spell-schools", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
SpellSchools []models.SpellSchool `json:"spell_schools"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
assert.GreaterOrEqual(t, len(payload.SpellSchools), 10, "expected at least 10 pre-seeded spell schools")
var found bool
for _, ss := range payload.SpellSchools {
if ss.ID == seeded.ID {
found = true
assert.Equal(t, "Beherrschen", ss.Name)
}
}
assert.True(t, found, "expected Beherrschen in response")
}
// --- Class Category EP Costs (EP per TE) ---
func TestGetClassCategoryEPCosts(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
// Krieger / Kampf = 10 EP per TE (pre-seeded)
seeded := findClassCategoryEPCost(t, "Kr", "Kampf")
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/class-category-ep-costs", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
Costs []models.ClassCategoryEPCost `json:"costs"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
assert.GreaterOrEqual(t, len(payload.Costs), 135, "expected at least 135 pre-seeded class/category EP costs")
var found bool
for _, c := range payload.Costs {
if c.ID == seeded.ID {
found = true
assert.Equal(t, 10, c.EPPerTE)
}
}
assert.True(t, found, "expected Krieger/Kampf EP cost in response")
}
func TestUpdateClassCategoryEPCost(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
// Magier / Wissen = 10 EP per TE (pre-seeded)
seeded := findClassCategoryEPCost(t, "Ma", "Wissen")
originalEP := seeded.EPPerTE
body := map[string]interface{}{"ep_per_te": 99}
bodyBytes, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/class-category-ep-costs/%d", seeded.ID), bytes.NewBuffer(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var updated models.ClassCategoryEPCost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
assert.Equal(t, seeded.ID, updated.ID)
assert.Equal(t, 99, updated.EPPerTE)
assert.NotEqual(t, originalEP, updated.EPPerTE, "EP value should have changed")
}
func TestUpdateClassCategoryEPCostNotFound(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
body := map[string]interface{}{"ep_per_te": 30}
bodyBytes, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, "/api/maintenance/class-category-ep-costs/99999", bytes.NewBuffer(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusNotFound, resp.Code)
}
// --- Class Spell School EP Costs (EP per LE) ---
func TestGetClassSpellSchoolEPCosts(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
// Druide / Erkennen = 120 EP per LE (pre-seeded)
seeded := findClassSpellSchoolEPCost(t, "Dr", "Erkennen")
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/class-spell-school-ep-costs", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
Costs []models.ClassSpellSchoolEPCost `json:"costs"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
assert.Greater(t, len(payload.Costs), 0, "expected pre-seeded class/spell school EP costs")
var found bool
for _, c := range payload.Costs {
if c.ID == seeded.ID {
found = true
assert.Equal(t, 120, c.EPPerLE)
}
}
assert.True(t, found, "expected Druide/Erkennen EP cost in response")
}
func TestUpdateClassSpellSchoolEPCost(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
// Hexer / Bewegen = 90 EP per LE (pre-seeded)
seeded := findClassSpellSchoolEPCost(t, "Hx", "Bewegen")
originalEP := seeded.EPPerLE
body := map[string]interface{}{"ep_per_le": 77}
bodyBytes, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/class-spell-school-ep-costs/%d", seeded.ID), bytes.NewBuffer(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var updated models.ClassSpellSchoolEPCost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
assert.Equal(t, seeded.ID, updated.ID)
assert.Equal(t, 77, updated.EPPerLE)
assert.NotEqual(t, originalEP, updated.EPPerLE, "EP value should have changed")
}
func TestUpdateClassSpellSchoolEPCostNotFound(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
body := map[string]interface{}{"ep_per_le": 60}
bodyBytes, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, "/api/maintenance/class-spell-school-ep-costs/99999", bytes.NewBuffer(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusNotFound, resp.Code)
}
// --- Spell Level LE Costs ---
func TestGetSpellLevelLECosts(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
// Level 3 = 2 LE required (pre-seeded)
seeded := findSpellLevelLECost(t, 3)
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/spell-level-le-costs", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
Costs []models.SpellLevelLECost `json:"costs"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
assert.GreaterOrEqual(t, len(payload.Costs), 12, "expected 12 pre-seeded spell level LE costs")
var found bool
for _, c := range payload.Costs {
if c.ID == seeded.ID {
found = true
assert.Equal(t, 3, c.Level)
assert.Equal(t, 2, c.LERequired)
}
}
assert.True(t, found, "expected level 3 spell LE cost in response")
}
func TestUpdateSpellLevelLECost(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
// Level 5 = 5 LE required (pre-seeded)
seeded := findSpellLevelLECost(t, 5)
originalLE := seeded.LERequired
body := map[string]interface{}{"le_required": 42}
bodyBytes, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/spell-level-le-costs/%d", seeded.ID), bytes.NewBuffer(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var updated models.SpellLevelLECost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
assert.Equal(t, seeded.ID, updated.ID)
assert.Equal(t, 42, updated.LERequired)
assert.NotEqual(t, originalLE, updated.LERequired, "LE value should have changed")
}
func TestUpdateSpellLevelLECostNotFound(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
body := map[string]interface{}{"le_required": 6}
bodyBytes, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, "/api/maintenance/spell-level-le-costs/99999", bytes.NewBuffer(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusNotFound, resp.Code)
}
// --- Skill Category Difficulties ---
func TestGetSkillCategoryDifficulties(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-category-difficulties", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
Items []models.SkillCategoryDifficulty `json:"items"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
assert.GreaterOrEqual(t, len(payload.Items), 10, "expected pre-seeded skill category difficulties")
// Verify preloads are populated: find Bootfahren/Alltag/leicht (learn_cost=1)
var found bool
for _, item := range payload.Items {
if item.Skill.Name == "Bootfahren" && item.SkillCategory.Name == "Alltag" {
found = true
assert.Equal(t, "leicht", item.SkillDifficulty.Name)
assert.Equal(t, 1, item.LearnCost)
}
}
assert.True(t, found, "expected Bootfahren/Alltag/leicht in response")
}
func TestGetSkillCategoryDifficultiesUnauthorized(t *testing.T) {
_, router, _ := setupMaintenanceTest(t)
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-category-difficulties", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusUnauthorized, resp.Code)
}
func TestUpdateSkillCategoryDifficulty(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
// Find Bootfahren in Alltag/leicht (learn_cost=1 in test DB)
var item models.SkillCategoryDifficulty
require.NoError(t, database.DB.
Joins("Skill").Joins("SkillCategory").Joins("SkillDifficulty").
Where("Skill__name = ? AND SkillCategory__name = ? AND SkillDifficulty__name = ?",
"Bootfahren", "Alltag", "leicht").
First(&item).Error,
"expected pre-seeded Bootfahren/Alltag/leicht entry")
originalLC := item.LearnCost
body := map[string]interface{}{"learn_cost": 3}
bodyBytes, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut,
fmt.Sprintf("/api/maintenance/skill-category-difficulties/%d", item.ID),
bytes.NewBuffer(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var updated models.SkillCategoryDifficulty
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
assert.Equal(t, item.ID, updated.ID)
assert.Equal(t, 3, updated.LearnCost)
assert.NotEqual(t, originalLC, updated.LearnCost)
}
func TestUpdateSkillCategoryDifficultyNotFound(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
body := map[string]interface{}{"learn_cost": 5}
bodyBytes, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, "/api/maintenance/skill-category-difficulties/99999",
bytes.NewBuffer(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusNotFound, resp.Code)
}
+12
View File
@@ -20,6 +20,18 @@ func RegisterRoutes(r *gin.RouterGroup) {
charGrp.PUT("/gsm-misc/:id", UpdateMisc)
charGrp.GET("/skill-improvement-cost2", GetSkillImprovementCost2)
charGrp.PUT("/skill-improvement-cost2/:id", UpdateSkillImprovementCost2)
charGrp.GET("/character-classes", GetCharacterClasses)
charGrp.GET("/skill-categories", GetSkillCategories)
charGrp.GET("/skill-difficulties", GetSkillDifficulties)
charGrp.GET("/spell-schools", GetSpellSchools)
charGrp.GET("/class-category-ep-costs", GetClassCategoryEPCosts)
charGrp.PUT("/class-category-ep-costs/:id", UpdateClassCategoryEPCost)
charGrp.GET("/class-spell-school-ep-costs", GetClassSpellSchoolEPCosts)
charGrp.PUT("/class-spell-school-ep-costs/:id", UpdateClassSpellSchoolEPCost)
charGrp.GET("/spell-level-le-costs", GetSpellLevelLECosts)
charGrp.PUT("/spell-level-le-costs/:id", UpdateSpellLevelLECost)
charGrp.GET("/skill-category-difficulties", GetSkillCategoryDifficulties)
charGrp.PUT("/skill-category-difficulties/:id", UpdateSkillCategoryDifficulty)
charGrp.GET("/setupcheck", SetupCheck)
charGrp.GET("/setupcheck-dev", SetupCheckDev)
charGrp.GET("/mktestdata", MakeTestdataFromLive)
+67
View File
@@ -0,0 +1,67 @@
# EP-Kosten für 1 Trainingseinheit (TE)
1 Lerneinheit (LE) kostet das Dreifache an EP (+ 6 EP für Elfen)
| Klasse | Alltag | Freil. | Halbw. | Kampf | Körper | Sozial | Unterw. | Waffen | Wissen |
|--------|--------|--------|--------|-------|--------|--------|---------|--------|--------|
| As | 20 | 20 | 20 | 30 | 10 | 20 | 10 | 20 | 20 |
| Bb | 20 | 10 | 30 | 10 | 10 | 30 | 30 | 20 | 40 |
| Gl | 20 | 30 | 10 | 20 | 30 | 10 | 30 | 20 | 20 |
| Hä | 10 | 20 | 20 | 20 | 20 | 10 | 40 | 20 | 20 |
| Kr | 20 | 30 | 30 | 10 | 20 | 20 | 30 | 10 | 40 |
| Sp | 20 | 30 | 10 | 40 | 10 | 10 | 10 | 20 | 30 |
| Wa | 20 | 10 | 20 | 20 | 10 | 30 | 30 | 20 | 20 |
| Ba | 20 | 20 | 20 | 30 | 20 | 10 | 40 | 20 | 20 |
| Or | 20 | 20 | 40 | 20 | 10 | 20 | 40 | 20 | 30 |
| Dr | 20 | 10 | 40 | 40 | 20 | 30 | 40 | 40 | 10 |
| Hx | 20 | 20 | 30 | 40 | 30 | 20 | 30 | 40 | 20 |
| Ma | 20 | 30 | 40 | 40 | 30 | 30 | 40 | 40 | 10 |
| PB | 20 | 30 | 30 | 40 | 30 | 10 | 40 | 40 | 20 |
| PS | 20 | 30 | 40 | 30 | 30 | 30 | 40 | 30 | 20 |
| Sc | 20 | 10 | 40 | 40 | 20 | 20 | 40 | 40 | 20 |
Geldkosten: 20 GS je TE, 200 GS je LE
# EP-Kosten für 1 Lerneinheit (LE) für Zauber (+ 6 EP für Elfen).
| Klasse | Beherr. | Beweg. | Erkenn. | Erschaff. | Formen | Veränd. | Zerstören | Wunder | Dweom. | Lied |
|--------|---------|--------|---------|-----------|--------|---------|-----------|--------|---------|------|
| Dr | 90 | 60 | 120 | 90 | 60 | 90 | 120 | - | 30 | - |
| Hx | 30 | 90 | 90 | 90 | 60 | 30 | 60 | - | 90 | - |
| Ma* | 60 | 60 | 60 | 60 | 60 | 60 | 60 | - | - | - |
| PB | 60 | 90 | 60 | 90 | 120 | 120 | 90 | 30 | - | - |
| PS | 90 | 90 | 90 | 60 | 120 | 120 | 60 | 30 | - | - |
| Sc | 60 | 90 | 60 | 120 | 120 | 60 | 60 | 60 | 60 | - |
| Ba | - | - | - | - | - | - | - | - | - | 30 |
| Or | - | - | - | - | - | - | - | 30 | - | - |
Geldkosten: 100 GS je LE
Lernen von Spruchrollen: 1/3 EP je LE bei Erfolg, pauschal 20 GS je Lernversuch
*: Ma erhalten LE für Sprüche aus ihrem Spezialgebiet für 30 EP
# Lern- und Trainingslisten
Die Zahl an Lern- und Trainingseinheiten für ein und dieselbe Fertigkeit stimmen in allen Gruppen überein. Das Steigern von Reiten+14 auf Reiten+15 kostet zum Beispiel 5 TE unabhängig davon, ob man Reiten als Alltagsoder als Kampffertigkeit verbessert. Für das Lernen von Gassenwissen braucht man immer 2 LE, gleich ob man es als Halbwelt-, Unterwelt- oder soziale Fertigkeit erwirbt.
Die Gruppenzugehörigkeit entscheidet nur darüber, wie viele EP der
Abenteurer abhängig von seinem Typ für LE und TE bezahlen muss.
## Alltag
### Lernen
| Schwierigkeit | LE | Fertigkeiten |
|---------------|----|--------------|
| leicht | 1 LE | Bootfahren+12 (Gs), Glücksspiel+12 (Gs), Klettern+12 (St), Musizieren+12 (Gs), Reiten+12 (Gw), Schwimmen+12 (Gw), Seilkunst+12 (Gs), Wagenlenken+12 (Gs) |
| normal | 1 LE | Schreiben+8 (In), Sprache+8 (In) |
| schwer | 2 LE | Erste Hilfe+8 (Gs), Etikette+8 (In) |
| sehr schwer | 10 LE | Gerätekunde+8 (In), Geschäftssinn+8 (In) |
### Verbessern (TE)
| Schwierigkeit | +9 | +10 | +11 | +12 | +13 | +14 | +15 | +16 | +17 | +18 |
|---------------|----|----|----|----|----|----|----|----|----|----|
| leicht | - | - | - | - | 1 | 2 | 5 | 10 | 10 | 20 |
| normal | 1 | 1 | 1 | 1 | 2 | 2 | 5 | 10 | 10 | 20 |
| schwer | 2 | 2 | 5 | 5 | 10 | 10 | 20 | 20 | 50 | 50 |
| s. schwer | 5 | 5 | 10 | 10 | 20 | 20 | 50 | 50 | 100 | 100 |
+3 -1
View File
@@ -39,6 +39,7 @@ import GameSystemView from "./maintenance/GameSystemView.vue";
import LitSourceView from "./maintenance/LitSourceView.vue";
import MiscLookupView from "./maintenance/MiscLookupView.vue";
import SkillImprovementCostView from "./maintenance/SkillImprovementCostView.vue";
import LearningCostView from "./maintenance/LearningCostView.vue";
export default {
@@ -55,6 +56,7 @@ export default {
LitSourceView,
MiscLookupView,
SkillImprovementCostView,
LearningCostView,
},
data() {
return {
@@ -82,7 +84,7 @@ export default {
{ id: 7, name: "litsource", component: "LitSourceView" },
{ id: 8, name: "misc", component: "MiscLookupView" },
{ id: 9, name: "skillimprovement", component: "SkillImprovementCostView" },
{ id: 10, name: "learningcost", component: "LearningCostView" },
],
};
},
@@ -0,0 +1,440 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }} - {{ $t('learningcost.title') }}</h2>
</div>
<div v-if="error" class="error-box">{{ error }}</div>
<div v-if="isLoading" class="cd-view">
<p>{{ $t('common.loading') }}</p>
</div>
<!-- Section 1: EP per TE Class × Skill Category matrix -->
<div v-if="!isLoading" class="cd-view">
<h3>{{ $t('learningcost.headerEPPerTE') }}</h3>
<p class="lc-description">{{ $t('learningcost.epPerTEDesc') }}</p>
<div class="cd-list">
<table class="cd-table lc-matrix">
<thead>
<tr>
<th class="cd-table-header lc-sticky-col">{{ $t('learningcost.class') }}</th>
<th v-for="cat in skillCategories" :key="cat.id" class="cd-table-header">
{{ cat.name }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="cc in characterClasses" :key="cc.code">
<td class="lc-sticky-col lc-row-header">{{ cc.code }}</td>
<td
v-for="cat in skillCategories"
:key="cat.id"
class="lc-cell"
@click="startEditEPPerTE(cc.code, cat.name)"
>
<template v-if="editingCell === cellKey('te', cc.code, cat.name)">
<input
v-model.number="editValue"
type="number"
min="0"
class="lc-input"
@keyup.enter="saveEditEPPerTE(cc.code, cat.name)"
@keyup.escape="cancelEdit"
@blur="saveEditEPPerTE(cc.code, cat.name)"
ref="cellInput"
/>
</template>
<template v-else>
{{ getEPPerTE(cc.code, cat.name) ?? '-' }}
</template>
</td>
</tr>
</tbody>
</table>
</div>
<p class="lc-note">{{ $t('learningcost.goldCostTE') }}</p>
<p class="lc-hint">{{ $t('learningcost.clickToEdit') }}</p>
</div>
<!-- Section 2: EP per LE Class × Spell School matrix -->
<div v-if="!isLoading" class="cd-view">
<h3>{{ $t('learningcost.headerEPPerLE') }}</h3>
<p class="lc-description">{{ $t('learningcost.epPerLEDesc') }}</p>
<div class="cd-list">
<table class="cd-table lc-matrix">
<thead>
<tr>
<th class="cd-table-header lc-sticky-col">{{ $t('learningcost.class') }}</th>
<th v-for="school in spellSchools" :key="school.id" class="cd-table-header">
{{ school.name }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="cc in spellCasterClasses" :key="cc.code">
<td class="lc-sticky-col lc-row-header">{{ cc.code }}</td>
<td
v-for="school in spellSchools"
:key="school.id"
class="lc-cell"
@click="startEditEPPerLE(cc.code, school.name)"
>
<template v-if="editingCell === cellKey('le', cc.code, school.name)">
<input
v-model.number="editValue"
type="number"
min="0"
class="lc-input"
@keyup.enter="saveEditEPPerLE(cc.code, school.name)"
@keyup.escape="cancelEdit"
@blur="saveEditEPPerLE(cc.code, school.name)"
ref="cellInput"
/>
</template>
<template v-else>
{{ getEPPerLE(cc.code, school.name) ?? '-' }}
</template>
</td>
</tr>
</tbody>
</table>
</div>
<p class="lc-note">{{ $t('learningcost.goldCostLE') }}</p>
<p class="lc-note">{{ $t('learningcost.maSpecialNote') }}</p>
<p class="lc-note">{{ $t('learningcost.scrollLearnNote') }}</p>
<p class="lc-hint">{{ $t('learningcost.clickToEdit') }}</p>
</div>
<!-- Section 3: LE per Spell Level -->
<div v-if="!isLoading" class="cd-view">
<h3>{{ $t('learningcost.headerSpellLevelLE') }}</h3>
<p class="lc-description">{{ $t('learningcost.spellLevelLEDesc') }}</p>
<div class="cd-list">
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header">{{ $t('learningcost.spellLevel') }}</th>
<th class="cd-table-header">{{ $t('learningcost.leRequired') }}</th>
<th class="cd-table-header"></th>
</tr>
</thead>
<tbody>
<template v-for="cost in spellLevelCosts" :key="cost.id">
<tr v-if="editingSpellLevelId !== cost.id">
<td>{{ cost.level }}</td>
<td>{{ cost.le_required }}</td>
<td><button @click="startEditSpellLevel(cost)">{{ $t('common.edit') }}</button></td>
</tr>
<tr v-else>
<td>{{ cost.level }}</td>
<td>
<input
v-model.number="editSpellLevelValue"
type="number"
min="0"
/>
</td>
<td>
<div class="edit-actions">
<button class="btn-primary btn-save" :disabled="isSaving" @click="saveEditSpellLevel">
<span v-if="!isSaving">{{ $t('common.save') }}</span>
<span v-else>{{ $t('common.saving') }}</span>
</button>
<button class="btn-cancel" :disabled="isSaving" @click="cancelEditSpellLevel">
{{ $t('common.cancel') }}
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.lc-description {
margin: 8px 0;
font-style: italic;
opacity: 0.8;
}
.lc-note {
margin: 4px 0;
font-size: 0.9em;
font-style: italic;
opacity: 0.75;
}
.lc-hint {
margin-top: 6px;
font-size: 0.85em;
opacity: 0.6;
}
.lc-matrix {
min-width: max-content;
}
.lc-matrix th,
.lc-matrix td {
text-align: center;
min-width: 60px;
white-space: nowrap;
}
.lc-sticky-col {
position: sticky;
left: 0;
background: var(--color-background);
z-index: 1;
}
.lc-row-header {
text-align: left;
font-weight: bold;
padding-right: 12px;
}
.lc-cell {
cursor: pointer;
transition: background 0.15s ease;
}
.lc-cell:hover {
background: var(--color-background-mute);
}
.lc-input {
width: 60px;
text-align: center;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: 3px;
}
.error-box {
margin: 10px 0;
padding: 10px 12px;
background: #ffe3e3;
color: #8a1c1c;
border: 1px solid #f5c2c2;
border-radius: 6px;
}
.edit-actions {
display: flex;
gap: 10px;
}
</style>
<script>
import API from '../../utils/api'
export default {
name: 'LearningCostView',
data() {
return {
characterClasses: [],
skillCategories: [],
spellSchools: [],
epPerTECosts: [],
epPerLECosts: [],
spellLevelCosts: [],
isLoading: false,
isSaving: false,
error: '',
// Matrix cell editing
editingCell: null,
editValue: 0,
// Spell level editing
editingSpellLevelId: null,
editSpellLevelValue: 0,
}
},
computed: {
spellCasterClasses() {
const codesWithSpells = new Set(this.epPerLECosts.map(c => c.characterClass || c.character_class?.code))
return this.characterClasses.filter(cc => codesWithSpells.has(cc.code))
},
},
async created() {
await this.loadAll()
},
methods: {
cellKey(type, classCode, colName) {
return `${type}:${classCode}:${colName}`
},
// --- Data loading ---
async loadAll() {
this.isLoading = true
this.error = ''
try {
const [classesResp, categoriesResp, schoolsResp, epTEResp, epLEResp, spellLevelResp] = await Promise.all([
API.get('/api/maintenance/character-classes'),
API.get('/api/maintenance/skill-categories'),
API.get('/api/maintenance/spell-schools'),
API.get('/api/maintenance/class-category-ep-costs'),
API.get('/api/maintenance/class-spell-school-ep-costs'),
API.get('/api/maintenance/spell-level-le-costs'),
])
this.characterClasses = classesResp.data?.character_classes || []
this.skillCategories = categoriesResp.data?.skill_categories || []
this.spellSchools = schoolsResp.data?.spell_schools || []
this.epPerTECosts = epTEResp.data?.costs || []
this.epPerLECosts = epLEResp.data?.costs || []
this.spellLevelCosts = spellLevelResp.data?.costs || []
} catch (err) {
console.error('Failed to load learning costs:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isLoading = false
}
},
// --- EP per TE matrix ---
getEPPerTE(classCode, categoryName) {
const entry = this.epPerTECosts.find(
c => (c.characterClass === classCode || c.character_class?.code === classCode) &&
(c.skillCategory === categoryName || c.skill_category?.name === categoryName)
)
return entry?.ep_per_te
},
findEPPerTEEntry(classCode, categoryName) {
return this.epPerTECosts.find(
c => (c.characterClass === classCode || c.character_class?.code === classCode) &&
(c.skillCategory === categoryName || c.skill_category?.name === categoryName)
)
},
startEditEPPerTE(classCode, categoryName) {
const entry = this.findEPPerTEEntry(classCode, categoryName)
if (!entry) return
this.cancelEdit()
this.editingCell = this.cellKey('te', classCode, categoryName)
this.editValue = entry.ep_per_te
this.$nextTick(() => {
const inputs = this.$refs.cellInput
if (inputs) {
const el = Array.isArray(inputs) ? inputs[0] : inputs
el?.focus()
el?.select()
}
})
},
async saveEditEPPerTE(classCode, categoryName) {
const entry = this.findEPPerTEEntry(classCode, categoryName)
if (!entry || this.editingCell !== this.cellKey('te', classCode, categoryName)) return
if (entry.ep_per_te === this.editValue) {
this.cancelEdit()
return
}
this.isSaving = true
try {
const resp = await API.put(`/api/maintenance/class-category-ep-costs/${entry.id}`, {
ep_per_te: this.editValue,
})
const idx = this.epPerTECosts.findIndex(c => c.id === entry.id)
if (idx !== -1) this.epPerTECosts.splice(idx, 1, resp.data)
this.cancelEdit()
} catch (err) {
console.error('Failed to save EP/TE cost:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
// --- EP per LE matrix ---
getEPPerLE(classCode, schoolName) {
const entry = this.epPerLECosts.find(
c => (c.characterClass === classCode || c.character_class?.code === classCode) &&
(c.spellSchool === schoolName || c.spell_school?.name === schoolName)
)
return entry?.ep_per_le
},
findEPPerLEEntry(classCode, schoolName) {
return this.epPerLECosts.find(
c => (c.characterClass === classCode || c.character_class?.code === classCode) &&
(c.spellSchool === schoolName || c.spell_school?.name === schoolName)
)
},
startEditEPPerLE(classCode, schoolName) {
const entry = this.findEPPerLEEntry(classCode, schoolName)
if (!entry) return
this.cancelEdit()
this.editingCell = this.cellKey('le', classCode, schoolName)
this.editValue = entry.ep_per_le
this.$nextTick(() => {
const inputs = this.$refs.cellInput
if (inputs) {
const el = Array.isArray(inputs) ? inputs[0] : inputs
el?.focus()
el?.select()
}
})
},
async saveEditEPPerLE(classCode, schoolName) {
const entry = this.findEPPerLEEntry(classCode, schoolName)
if (!entry || this.editingCell !== this.cellKey('le', classCode, schoolName)) return
if (entry.ep_per_le === this.editValue) {
this.cancelEdit()
return
}
this.isSaving = true
try {
const resp = await API.put(`/api/maintenance/class-spell-school-ep-costs/${entry.id}`, {
ep_per_le: this.editValue,
})
const idx = this.epPerLECosts.findIndex(c => c.id === entry.id)
if (idx !== -1) this.epPerLECosts.splice(idx, 1, resp.data)
this.cancelEdit()
} catch (err) {
console.error('Failed to save EP/LE cost:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
// --- Spell Level LE costs ---
startEditSpellLevel(cost) {
this.cancelEdit()
this.editingSpellLevelId = cost.id
this.editSpellLevelValue = cost.le_required
},
cancelEditSpellLevel() {
this.editingSpellLevelId = null
this.editSpellLevelValue = 0
},
async saveEditSpellLevel() {
if (this.editingSpellLevelId == null) return
this.isSaving = true
try {
const resp = await API.put(`/api/maintenance/spell-level-le-costs/${this.editingSpellLevelId}`, {
le_required: this.editSpellLevelValue,
})
const idx = this.spellLevelCosts.findIndex(c => c.id === this.editingSpellLevelId)
if (idx !== -1) this.spellLevelCosts.splice(idx, 1, resp.data)
this.cancelEditSpellLevel()
} catch (err) {
console.error('Failed to save spell level LE cost:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
// --- Shared ---
cancelEdit() {
this.editingCell = null
this.editValue = 0
this.editingSpellLevelId = null
this.editSpellLevelValue = 0
},
},
}
</script>
@@ -1,117 +1,200 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }} - {{ $t('skillimprovement.title') }}</h2>
<button class="btn-primary" @click="startCreate">{{ $t('newEntry') }}</button>
</div>
<div v-if="error" class="error-box">{{ error }}</div>
<div class="cd-view">
<div class="cd-list">
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header">{{ $t('skillimprovement.id') }}</th>
<th class="cd-table-header">{{ $t('skillimprovement.level') }}</th>
<th class="cd-table-header">{{ $t('skillimprovement.te') }}</th>
<th class="cd-table-header">{{ $t('skillimprovement.category') }}</th>
<th class="cd-table-header">{{ $t('skillimprovement.difficulty') }}</th>
<th class="cd-table-header"></th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="6">{{ $t('common.loading') }}</td>
</tr>
<tr v-if="creatingNew">
<td>New</td>
<td colspan="5">
<div class="edit-form">
<div class="edit-row">
<label>{{ $t('skillimprovement.level') }}</label>
<input v-model.number="newItem.current_level" type="number" />
<label class="inline-label">{{ $t('skillimprovement.te') }}</label>
<input v-model.number="newItem.te_required" type="number" />
</div>
<div class="edit-row">
<label>{{ $t('skillimprovement.category') }}</label>
<select v-model.number="newItem.category_id">
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">
{{ cat.label }}
</option>
</select>
<label class="inline-label">{{ $t('skillimprovement.difficulty') }}</label>
<select v-model.number="newItem.difficulty_id">
<option v-for="diff in difficultyOptions" :key="diff.id" :value="diff.id">
{{ diff.label }}
</option>
</select>
</div>
<div class="edit-actions">
<button class="btn-primary btn-save" :disabled="isSaving" @click="saveCreate">
<span v-if="!isSaving">{{ $t('common.save') }}</span>
<span v-else>{{ $t('common.saving') }}</span>
</button>
<button class="btn-cancel" :disabled="isSaving" @click="cancelCreate">
{{ $t('common.cancel') }}
</button>
</div>
</div>
</td>
</tr>
<template v-for="cost in costs" :key="cost.id">
<tr v-if="editingId !== cost.id">
<td>{{ cost.id }}</td>
<td>{{ cost.current_level }}</td>
<td>{{ cost.te_required }}</td>
<td>{{ displayCategory(cost) }}</td>
<td>{{ displayDifficulty(cost) }}</td>
<td><button @click="startEdit(cost)">{{ $t('common.edit') }}</button></td>
<p class="si-description">{{ $t('skillimprovement.description') }}</p>
<div class="si-category-tabs">
<button
v-for="cat in availableCategories"
:key="cat.id"
:class="{ active: selectedCategoryId === cat.id }"
@click="selectedCategoryId = cat.id"
>
{{ cat.name }}
</button>
</div>
<div v-if="isLoading" class="cd-view">
<p>{{ $t('common.loading') }}</p>
</div>
<template v-if="!isLoading && selectedCategoryId">
<!-- Lernen section -->
<div class="cd-view">
<h3>{{ $t('skillimprovement.lernen') }}</h3>
<div class="cd-list">
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header">{{ $t('skillimprovement.difficulty') }}</th>
<th class="cd-table-header">{{ $t('skillimprovement.le') }}</th>
<th class="cd-table-header">{{ $t('skillimprovement.skills') }}</th>
</tr>
<tr v-else>
<td>{{ cost.id }}</td>
<td colspan="5">
<div class="edit-form">
<div class="edit-row">
<label>{{ $t('skillimprovement.level') }}</label>
<input v-model.number="editedItem.current_level" type="number" />
<label class="inline-label">{{ $t('skillimprovement.te') }}</label>
<input v-model.number="editedItem.te_required" type="number" />
</div>
<div class="edit-row">
<label>{{ $t('skillimprovement.category') }}</label>
<select v-model.number="editedItem.category_id">
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">
{{ cat.label }}
</option>
</select>
<label class="inline-label">{{ $t('skillimprovement.difficulty') }}</label>
<select v-model.number="editedItem.difficulty_id">
<option v-for="diff in difficultyOptions" :key="diff.id" :value="diff.id">
{{ diff.label }}
</option>
</select>
</div>
<div class="edit-actions">
<button class="btn-primary btn-save" :disabled="isSaving" @click="saveEdit">
<span v-if="!isSaving">{{ $t('common.save') }}</span>
<span v-else>{{ $t('common.saving') }}</span>
</button>
<button class="btn-cancel" :disabled="isSaving" @click="cancelEdit">
{{ $t('common.cancel') }}
</button>
</div>
</div>
</thead>
<tbody>
<tr v-for="row in lernenRows" :key="row.key">
<td>{{ row.difficultyName }}</td>
<td
class="si-cell"
@click="startEditLernen(row)"
>
<template v-if="editingLernenKey === row.key">
<input
v-model.number="editLernenValue"
type="number"
min="0"
class="si-input"
@keyup.enter="saveLernenEdit(row)"
@keyup.escape="cancelLernenEdit"
@blur="saveLernenEdit(row)"
ref="lernenInput"
/>
<span class="si-le-unit">LE</span>
</template>
<template v-else>
{{ row.learnCost }} LE
</template>
</td>
<td>{{ row.skillsDisplay }}</td>
</tr>
<tr v-if="lernenRows.length === 0">
<td colspan="3" class="si-empty">{{ $t('skillimprovement.noLernenData') }}</td>
</tr>
</tbody>
</table>
</div>
<p class="si-hint">{{ $t('skillimprovement.clickLeToEdit') }}</p>
</div>
<!-- Verbessern (TE) section -->
<div class="cd-view">
<h3>{{ $t('skillimprovement.verbessern') }}</h3>
<div class="cd-list">
<table class="cd-table si-matrix">
<thead>
<tr>
<th class="cd-table-header si-sticky-col">{{ $t('skillimprovement.difficulty') }}</th>
<th v-for="level in matrixLevels" :key="level" class="cd-table-header">
+{{ level }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="diff in matrixDifficulties" :key="diff.id">
<td class="si-sticky-col si-row-header">{{ diff.name }}</td>
<td
v-for="level in matrixLevels"
:key="level"
class="si-cell"
@click="startEditTE(diff.id, level)"
>
<template v-if="editingCell === cellKey(diff.id, level)">
<input
v-model.number="editValue"
type="number"
min="0"
class="si-input"
@keyup.enter="saveEditTE(diff.id, level)"
@keyup.escape="cancelEdit"
@blur="saveEditTE(diff.id, level)"
ref="cellInput"
/>
</template>
<template v-else>
{{ displayTE(diff.id, level) }}
</template>
</td>
</tr>
</template>
</tbody>
</table>
<tr v-if="matrixDifficulties.length === 0">
<td :colspan="matrixLevels.length + 1" class="si-empty">{{ $t('skillimprovement.noVerbessernData') }}</td>
</tr>
</tbody>
</table>
</div>
<p class="si-hint">{{ $t('skillimprovement.clickToEdit') }}</p>
</div>
</div>
</template>
</template>
<style scoped>
.si-description {
margin: 8px 0 4px;
font-style: italic;
opacity: 0.8;
}
.si-category-tabs {
display: flex;
gap: 4px;
margin: 10px 0;
flex-wrap: wrap;
}
.si-category-tabs button {
padding: 6px 14px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
cursor: pointer;
transition: all 0.2s ease;
}
.si-category-tabs button:hover {
background: var(--color-background-mute);
}
.si-category-tabs button.active {
background: var(--color-background-mute);
font-weight: bold;
border-color: var(--color-text);
}
.si-matrix {
min-width: max-content;
}
.si-matrix th,
.si-matrix td {
text-align: center;
min-width: 50px;
white-space: nowrap;
}
.si-sticky-col {
position: sticky;
left: 0;
background: var(--color-background);
z-index: 1;
}
.si-row-header {
text-align: left;
font-weight: bold;
padding-right: 12px;
}
.si-cell {
cursor: pointer;
transition: background 0.15s ease;
}
.si-cell:hover {
background: var(--color-background-mute);
}
.si-input {
width: 55px;
text-align: center;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: 3px;
}
.si-hint {
margin-top: 6px;
font-size: 0.85em;
opacity: 0.6;
}
.si-le-unit {
margin-left: 2px;
font-size: 0.9em;
}
.si-empty {
font-style: italic;
opacity: 0.6;
}
.error-box {
margin: 10px 0;
padding: 10px 12px;
@@ -120,24 +203,6 @@
border: 1px solid #f5c2c2;
border-radius: 6px;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.edit-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.edit-actions {
display: flex;
gap: 10px;
}
.inline-label {
margin-left: 10px;
}
</style>
<script>
@@ -147,127 +212,226 @@ export default {
name: 'SkillImprovementCostView',
data() {
return {
costs: [],
editingId: null,
editedItem: null,
creatingNew: false,
newItem: null,
categories: [],
difficulties: [],
skillCatDiffs: [],
improvementCosts: [],
selectedCategoryId: null,
isLoading: false,
isSaving: false,
error: '',
editingCell: null,
editValue: 0,
editingLernenKey: null,
editLernenValue: 0,
}
},
async created() {
await this.loadCosts()
},
computed: {
categoryOptions() {
const seen = new Map()
this.costs.forEach(c => {
const id = c.category_id ?? c.skillCategoryId
const name = c.category_name || c.skillCategoryName
if (id != null && !seen.has(id)) {
seen.set(id, name ? `${name} (${id})` : `${id}`)
}
})
return Array.from(seen.entries()).map(([id, label]) => ({ id, label }))
availableCategories() {
const catIdsWithSKD = new Set(this.skillCatDiffs.map(d => d.skill_category?.id))
const catIdsWithIC = new Set(this.improvementCosts.map(c => c.skillCategoryId))
return this.categories.filter(cat =>
catIdsWithSKD.has(cat.id) || catIdsWithIC.has(cat.id)
)
},
difficultyOptions() {
const seen = new Map()
this.costs.forEach(c => {
const id = c.difficulty_id ?? c.skillDifficultyId
const name = c.difficulty_name || c.skillDifficultyName
if (id != null && !seen.has(id)) {
seen.set(id, name ? `${name} (${id})` : `${id}`)
lernenRows() {
if (!this.selectedCategoryId) return []
const items = this.skillCatDiffs.filter(d =>
d.skill_category?.id === this.selectedCategoryId
)
const groups = new Map()
for (const item of items) {
const diffName = item.skill_difficulty?.name || item.skillDifficulty || ''
const diffId = item.skill_difficulty?.id || 0
const lc = item.learn_cost
const key = `${diffId}:${lc}`
if (!groups.has(key)) {
groups.set(key, {
key,
difficultyId: diffId,
difficultyName: diffName,
learnCost: lc,
itemIds: [],
skills: [],
})
}
})
return Array.from(seen.entries()).map(([id, label]) => ({ id, label }))
groups.get(key).itemIds.push(item.id)
const skill = item.skill
if (skill) {
const be = skill.bonuseigenschaft || '-'
groups.get(key).skills.push(`${skill.name}+${skill.initialwert} (${be})`)
}
}
const rows = Array.from(groups.values())
rows.sort((a, b) => a.difficultyId - b.difficultyId || a.learnCost - b.learnCost)
for (const row of rows) {
row.skills.sort()
row.skillsDisplay = row.skills.join(', ')
}
return rows
},
categoryImprovementCosts() {
if (!this.selectedCategoryId) return []
return this.improvementCosts.filter(c => c.skillCategoryId === this.selectedCategoryId)
},
matrixLevels() {
const levels = [...new Set(this.categoryImprovementCosts.map(c => c.current_level))]
levels.sort((a, b) => a - b)
return levels
},
matrixDifficulties() {
const seen = new Map()
for (const c of this.categoryImprovementCosts) {
const id = c.skillDifficultyId
if (!seen.has(id)) {
const name = c.difficulty_name || this.difficulties.find(d => d.id === id)?.name || `${id}`
seen.set(id, { id, name })
}
}
return Array.from(seen.values()).sort((a, b) => a.id - b.id)
},
},
async created() {
await this.loadAll()
},
methods: {
displayCategory(cost) {
return cost.category_name || cost.skillCategoryName || cost.skillCategoryId || cost.category_id
cellKey(diffId, level) {
return `${diffId}:${level}`
},
displayDifficulty(cost) {
return cost.difficulty_name || cost.skillDifficultyName || cost.skillDifficultyId || cost.difficulty_id
},
async loadCosts() {
async loadAll() {
this.isLoading = true
this.error = ''
try {
const resp = await API.get('/api/maintenance/skill-improvement-cost2')
this.costs = resp.data?.costs || []
const [catResp, diffResp, scdResp, icResp] = await Promise.all([
API.get('/api/maintenance/skill-categories'),
API.get('/api/maintenance/skill-difficulties'),
API.get('/api/maintenance/skill-category-difficulties'),
API.get('/api/maintenance/skill-improvement-cost2'),
])
this.categories = catResp.data?.skill_categories || []
this.difficulties = diffResp.data?.skill_difficulties || []
this.skillCatDiffs = scdResp.data?.items || []
this.improvementCosts = icResp.data?.costs || []
if (this.availableCategories.length > 0 && !this.selectedCategoryId) {
this.selectedCategoryId = this.availableCategories[0].id
}
} catch (err) {
console.error('Failed to load costs:', err)
console.error('Failed to load skill improvement data:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isLoading = false
}
},
startEdit(cost) {
this.editingId = cost.id
this.editedItem = {
...cost,
category_id: cost.category_id ?? cost.skillCategoryId,
difficulty_id: cost.difficulty_id ?? cost.skillDifficultyId,
}
findImprovementCost(diffId, level) {
return this.categoryImprovementCosts.find(
c => c.skillDifficultyId === diffId && c.current_level === level
)
},
cancelEdit() {
this.editingId = null
this.editedItem = null
displayTE(diffId, level) {
const entry = this.findImprovementCost(diffId, level)
if (!entry) return '-'
return entry.te_required === 0 ? '-' : entry.te_required
},
startCreate() {
startEditTE(diffId, level) {
const entry = this.findImprovementCost(diffId, level)
if (!entry) return
this.cancelEdit()
const defaultCategory = this.categoryOptions[0]?.id ?? null
const defaultDifficulty = this.difficultyOptions[0]?.id ?? null
this.newItem = {
current_level: 0,
te_required: 0,
category_id: defaultCategory,
difficulty_id: defaultDifficulty,
}
this.creatingNew = true
this.editingCell = this.cellKey(diffId, level)
this.editValue = entry.te_required
this.$nextTick(() => {
const inputs = this.$refs.cellInput
if (inputs) {
const el = Array.isArray(inputs) ? inputs[0] : inputs
el?.focus()
el?.select()
}
})
},
cancelCreate() {
this.creatingNew = false
this.newItem = null
},
async saveEdit() {
if (!this.editedItem) return
const payload = {
current_level: this.editedItem.current_level,
te_required: this.editedItem.te_required,
category_id: this.editedItem.category_id,
difficulty_id: this.editedItem.difficulty_id,
async saveEditTE(diffId, level) {
const entry = this.findImprovementCost(diffId, level)
if (!entry || this.editingCell !== this.cellKey(diffId, level)) return
if (entry.te_required === this.editValue) {
this.cancelEdit()
return
}
this.isSaving = true
try {
const resp = await API.put(`/api/maintenance/skill-improvement-cost2/${this.editingId}`, payload)
const idx = this.costs.findIndex(c => c.id === this.editingId)
if (idx !== -1) this.costs.splice(idx, 1, resp.data)
const resp = await API.put(`/api/maintenance/skill-improvement-cost2/${entry.id}`, {
current_level: entry.current_level,
te_required: this.editValue,
category_id: entry.skillCategoryId,
difficulty_id: entry.skillDifficultyId,
})
const idx = this.improvementCosts.findIndex(c => c.id === entry.id)
if (idx !== -1) this.improvementCosts.splice(idx, 1, resp.data)
this.cancelEdit()
} catch (err) {
console.error('Failed to save cost:', err)
console.error('Failed to save TE cost:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
async saveCreate() {
if (!this.newItem) return
const payload = {
current_level: this.newItem.current_level,
te_required: this.newItem.te_required,
category_id: this.newItem.category_id,
difficulty_id: this.newItem.difficulty_id,
cancelEdit() {
this.editingCell = null
this.editValue = 0
},
startEditLernen(row) {
this.cancelEdit()
this.editingLernenKey = row.key
this.editLernenValue = row.learnCost
this.$nextTick(() => {
const inputs = this.$refs.lernenInput
if (inputs) {
const el = Array.isArray(inputs) ? inputs[0] : inputs
el?.focus()
el?.select()
}
})
},
cancelLernenEdit() {
this.editingLernenKey = null
this.editLernenValue = 0
},
async saveLernenEdit(row) {
if (this.editingLernenKey !== row.key) return
if (row.learnCost === this.editLernenValue) {
this.cancelLernenEdit()
return
}
const newValue = this.editLernenValue
this.cancelLernenEdit()
this.isSaving = true
try {
const resp = await API.post('/api/maintenance/skill-improvement-cost2', payload)
this.costs.push(resp.data)
this.cancelCreate()
for (const id of row.itemIds) {
const resp = await API.put(`/api/maintenance/skill-category-difficulties/${id}`, {
learn_cost: newValue,
})
const idx = this.skillCatDiffs.findIndex(d => d.id === id)
if (idx !== -1) this.skillCatDiffs.splice(idx, 1, resp.data)
}
} catch (err) {
console.error('Failed to create cost:', err)
console.error('Failed to save learn cost:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
+29 -10
View File
@@ -306,6 +306,7 @@ export default {
litsource:'Literaturquellen',
misc:'Sonstige',
skillimprovement:'Steigerungskosten',
learningcost:'Lernkosten',
},
believe: {
title: 'Glaubensrichtungen',
@@ -364,17 +365,35 @@ export default {
saving: 'Speichern...',
cancel: 'Abbrechen'
},
learningcost: {
title: 'Lernkosten',
headerEPPerTE: 'EP-Kosten für 1 Trainingseinheit (TE)',
headerEPPerLE: 'EP-Kosten für 1 Lerneinheit (LE) für Zauber',
headerSpellLevelLE: 'Benötigte LE pro Zauberstufe',
class: 'Klasse',
epPerTEDesc: '1 Lerneinheit (LE) kostet das Dreifache an EP (+ 6 EP für Elfen).',
epPerLEDesc: 'EP-Kosten für 1 Lerneinheit (LE) für Zauber nach Charakterklasse und Zauberschule (+ 6 EP für Elfen).',
spellLevelLEDesc: 'Benötigte Lerneinheiten (LE) pro Zauberstufe.',
goldCostTE: 'Geldkosten: 20 GS je TE, 200 GS je LE.',
goldCostLE: 'Geldkosten: 100 GS je LE.',
maSpecialNote: '* Ma erhalten LE für Sprüche aus ihrem Spezialgebiet für 30 EP.',
scrollLearnNote: 'Lernen von Spruchrollen: 1/3 EP je LE bei Erfolg, pauschal 20 GS je Lernversuch.',
spellLevel: 'Zauberstufe',
leRequired: 'LE erforderlich',
clickToEdit: 'Klicke auf einen Wert um ihn zu bearbeiten.',
},
skillimprovement: {
title: 'Fertigkeitssteigerungskosten',
id: 'ID',
level: 'Aktueller Wert',
te: 'TE erforderlich',
category: 'Kategorie-ID',
difficulty: 'Schwierigkeits-ID',
edit: 'Bearbeiten',
save: 'Speichern',
saving: 'Speichern...',
cancel: 'Abbrechen'
title: 'Lern- und Trainingslisten',
description: 'Die Zahl an Lern- und Trainingseinheiten f\u00fcr ein und dieselbe Fertigkeit stimmen in allen Gruppen \u00fcberein. Die Gruppenzugeh\u00f6rigkeit entscheidet nur dar\u00fcber, wie viele EP der Abenteurer abh\u00e4ngig von seinem Typ f\u00fcr LE und TE bezahlen muss.',
lernen: 'Lernen',
verbessern: 'Verbessern (TE)',
difficulty: 'Schwierigkeit',
le: 'LE',
skills: 'Fertigkeiten',
noLernenData: 'Keine Lerndaten f\u00fcr diese Kategorie vorhanden.',
noVerbessernData: 'Keine Verbesserungsdaten f\u00fcr diese Kategorie vorhanden.',
clickToEdit: 'Klicke auf einen Wert um ihn zu bearbeiten.',
clickLeToEdit: 'Klicke auf einen LE-Wert um ihn zu bearbeiten.',
},
search:'Suche',
newEntry:'Neuer Eintrag',
+29 -10
View File
@@ -302,6 +302,7 @@ export default {
litsource:'Sources',
misc:'Misc',
skillimprovement:'Improvement Costs',
learningcost:'Learning Costs',
},
believe: {
title: 'Beliefs',
@@ -360,17 +361,35 @@ export default {
saving: 'Saving...',
cancel: 'Cancel'
},
learningcost: {
title: 'Learning Costs',
headerEPPerTE: 'EP Costs per Training Unit (TE)',
headerEPPerLE: 'EP Costs per Learning Unit (LE) for Spells',
headerSpellLevelLE: 'Required LE per Spell Level',
class: 'Class',
epPerTEDesc: '1 Learning Unit (LE) costs triple the EP (+ 6 EP for Elves).',
epPerLEDesc: 'EP costs for 1 Learning Unit (LE) for spells by character class and spell school (+ 6 EP for Elves).',
spellLevelLEDesc: 'Required learning units (LE) per spell level.',
goldCostTE: 'Gold costs: 20 GS per TE, 200 GS per LE.',
goldCostLE: 'Gold costs: 100 GS per LE.',
maSpecialNote: '* Magicians receive LE for spells in their specialization for 30 EP.',
scrollLearnNote: 'Learning from spell scrolls: 1/3 EP per LE on success, flat 20 GS per attempt.',
spellLevel: 'Spell Level',
leRequired: 'LE required',
clickToEdit: 'Click a value to edit it.',
},
skillimprovement: {
title: 'Skill Improvement Costs',
id: 'ID',
level: 'Current level',
te: 'TE required',
category: 'Category ID',
difficulty: 'Difficulty ID',
edit: 'Edit',
save: 'Save',
saving: 'Saving...',
cancel: 'Cancel'
title: 'Learning & Training Lists',
description: 'The number of learning and training units for any given skill is the same across all groups. Group membership only determines how many EP the adventurer must pay for LE and TE based on their type.',
lernen: 'Learning',
verbessern: 'Improving (TE)',
difficulty: 'Difficulty',
le: 'LE',
skills: 'Skills',
noLernenData: 'No learning data for this category.',
noVerbessernData: 'No improvement data for this category.',
clickToEdit: 'Click a value to edit it.',
clickLeToEdit: 'Click an LE value to edit it.',
},
search:'Search',
newEntry:'New Entry',