From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- internal/game/correct_combine_skill_test.go | 100 ++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 internal/game/correct_combine_skill_test.go (limited to 'internal/game/correct_combine_skill_test.go') diff --git a/internal/game/correct_combine_skill_test.go b/internal/game/correct_combine_skill_test.go new file mode 100644 index 0000000..d6de229 --- /dev/null +++ b/internal/game/correct_combine_skill_test.go @@ -0,0 +1,100 @@ +package game + +// Regression: combine recipes declare type "combine", which is an action and +// not a skill. Resolving the recipe type straight to a player.SkillName made +// p.Level read a map miss (level 1) for the success roll and made awardSkillXP +// persist a phantom "combine" skill entry. + +import ( + "path/filepath" + "testing" + + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +const correctCombineYAML = `id: correct_combine_out +name: correct combine out +stackable: false +value: 5 +craft: + - type: combine + ingredients: + - items: + - correct_combine_part + quantity: 1 + xp: 7 + output_qty: 1 + ticks_per_cycle: 1 +` + +func TestCombineSkillResolvesARealSkill(t *testing.T) { + for _, tc := range []struct { + name string + typ string + want player.SkillName + }{ + {"combine is an action, not a skill", "combine", player.Crafting}, + {"empty type defaults to combine", "", player.Crafting}, + {"a declared real skill is honored", "smithing", player.Smithing}, + } { + t.Run(tc.name, func(t *testing.T) { + got := combineSkill(&item.CraftDef{Type: tc.typ}) + if got != tc.want { + t.Fatalf("combineSkill(type %q) = %q, want %q", tc.typ, got, tc.want) + } + if !player.ValidSkill(got) { + t.Fatalf("combineSkill(type %q) resolved to unknown skill %q", tc.typ, got) + } + }) + } +} + +func TestCombineXPGoesToCraftingNotAPhantomSkill(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + writeSeedFile(t, filepath.Join(h.dir, "items", "correct_combine_out.yaml"), []byte(correctCombineYAML)) + writeSeedFile(t, filepath.Join(h.dir, "items", "correct_combine_part.yaml"), + []byte("id: correct_combine_part\nname: correct combine part\nstackable: false\nvalue: 1\n")) + + conn := newChanConn() + sess := &net.Session{Conn: conn, Player: p} + sess.SetState(net.StateGame) + + startCrafting := p.Skills[player.Crafting] + h.g.RunOnOwner(func() { + h.g.ItemStore.Reload(h.dir) + p.SetInvSlot(0, &player.InventorySlot{ItemID: "correct_combine_part", Quantity: 1}) + def := mustLoadItem(t, h.g, "correct_combine_out") + h.g.startCombine(sess, p, def, def.FirstCraft(), 1) + if p.Action == nil { + t.Fatal("combine did not start") + } + h.g.advanceCombine(sess, p) // phase 0 -> 1 + h.g.advanceCombine(sess, p) // phase 1 -> 2 + h.g.advanceCombine(sess, p) // phase 2: consume + output + + if got := p.CountItem("correct_combine_out"); got != 1 { + t.Fatalf("combine output = %d, want 1", got) + } + if got := p.Skills[player.Crafting]; got != startCrafting+7 { + t.Errorf("crafting xp = %d, want %d", got, startCrafting+7) + } + if got, ok := p.Skills[player.SkillName("combine")]; ok { + t.Errorf("combine recipes persist a phantom skill: combine = %d", got) + } + }) + + saved, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatalf("load character: %v", err) + } + if got, ok := saved.Skills[player.SkillName("combine")]; ok { + t.Errorf("phantom combine skill was persisted: combine = %d", got) + } + if got := saved.Skills[player.Crafting]; got != startCrafting+7 { + t.Errorf("persisted crafting xp = %d, want %d", got, startCrafting+7) + } +} -- cgit v1.2.3