package companion import ( "reflect" "testing" "time" "longheng.io/server/internal/modules/dnf/equip" "longheng.io/server/internal/modules/dnf/repository" ) func visualFixture( sourceID, name, job string, grow int64, weapon int64, avatars map[string]repository.EquipmentEntry, ) (repository.CompanionSourceState, Selection) { entries := map[string]repository.EquipmentEntry{ "weapon": { SlotIndex: 12, ItemID: weapon, RawEntry: []byte{1, 2, 3}, Extra: map[string]string{ "item_kind": "equipment", "equipment_type": "17", "current_exe_equipment_type": "12", "pvf_path": "equipment/character/test/weapon/test.equ", }, }, } for key, value := range avatars { entries[key] = value } state := repository.CompanionSourceState{ Owner: repository.CharacterRecord{ CharacterID: "1", AccountID: "account-test", Stats: map[string]int64{"delete_flag": 0}, }, Source: repository.CharacterRecord{ CharacterID: sourceID, AccountID: "account-test", Name: name, Job: job, Level: 86, Stats: map[string]int64{"delete_flag": 0, "grow_type": grow}, }, Equipment: repository.EquipmentRecord{ CharacterID: sourceID, Entries: entries, }, HasEquipment: true, } selection := Selection{ AccountID: "account-test", OwnerCharacterID: "1", SourceCharacterID: sourceID, OnlineStateKnown: true, } return state, selection } func avatarFixture(slot int16, item int64, equipmentType string) repository.EquipmentEntry { return repository.EquipmentEntry{ SlotIndex: slot, ItemID: item, RawEntry: []byte{4, 5, 6}, Extra: map[string]string{ "item_kind": "avatar", "equipment_type": equipmentType, "pvf_path": "equipment/character/test/avatar/test.equ", }, } } func TestVisualProjectionSwitchesABAndDoesNotMutateSource(t *testing.T) { aState, aSelection := visualFixture( "2", "DETSCS", "0", 35, 101010912, map[string]repository.EquipmentEntry{ "hat": avatarFixture(0, 401550018, "[hat avatar]"), "hair": avatarFixture(1, 101560869, "[hair avatar]"), }, ) bState, bSelection := visualFixture( "8", "DETSDT", "13", 3, 114000248, map[string]repository.EquipmentEntry{ "weapon-avatar": avatarFixture(10, 414600011, "[weapon avatar]"), }, ) aBefore := repository.CompanionSourceState{ Owner: repository.CloneCharacter(aState.Owner), Source: repository.CloneCharacter(aState.Source), Equipment: repository.CloneEquipment(aState.Equipment), HasEquipment: aState.HasEquipment, } bBefore := repository.CompanionSourceState{ Owner: repository.CloneCharacter(bState.Owner), Source: repository.CloneCharacter(bState.Source), Equipment: repository.CloneEquipment(bState.Equipment), HasEquipment: bState.HasEquipment, } now := time.Unix(1_800_000_000, 0) a, err := BuildVisualProjection(aState, aSelection, now) if err != nil { t.Fatal(err) } b, err := BuildVisualProjection(bState, bSelection, now) if err != nil { t.Fatal(err) } if a.Name != "DETSCS" || b.Name != "DETSDT" || a.Job != 0 || b.Job != 13 || a.GrowType != 35 || b.GrowType != 3 { t.Fatalf("identity projection A=%+v B=%+v", a, b) } if a.Weapon == nil || b.Weapon == nil || a.Weapon.ItemID != 101010912 || b.Weapon.ItemID != 114000248 { t.Fatalf("weapon projection A=%+v B=%+v", a.Weapon, b.Weapon) } if len(a.Avatars) != 2 || len(b.Avatars) != 1 || a.Avatars[0].DisplayItemID != 401550018 || b.Avatars[0].DisplayItemID != 414600011 { t.Fatalf("avatar projection A=%+v B=%+v", a.Avatars, b.Avatars) } if a.Fingerprint() == b.Fingerprint() { t.Fatal("different source characters produced same visual fingerprint") } if !reflect.DeepEqual(aState.Owner, aBefore.Owner) || !reflect.DeepEqual(aState.Source, aBefore.Source) || !reflect.DeepEqual(aState.Equipment, aBefore.Equipment) || !reflect.DeepEqual(bState.Owner, bBefore.Owner) || !reflect.DeepEqual(bState.Source, bBefore.Source) || !reflect.DeepEqual(bState.Equipment, bBefore.Equipment) { t.Fatal("BuildVisualProjection mutated source records") } a.Weapon.ItemID = 1 a.Avatars[0].ItemID = 2 if aState.Equipment.Entries["weapon"].ItemID != 101010912 || aState.Equipment.Entries["hat"].ItemID != 401550018 { t.Fatal("returned projection aliases source equipment") } } func TestVisualProjectionAcceptsFreshCharacterWeaponWithoutDerivedKind(t *testing.T) { state, selection := visualFixture( "2", "DETXX", "11", 0, 101010912, nil, ) weapon := state.Equipment.Entries["weapon"] delete(weapon.Extra, "item_kind") delete(weapon.Extra, "current_exe_equipment_type") weapon.Extra["pvf_path"] = "equipment/character/swordman/weapon/katana/101010912.equ" weapon.Extra["equipment_type"] = "17" state.Equipment.Entries["weapon"] = weapon got, err := BuildVisualProjection( state, selection, time.Unix(1_800_000_000, 0), ) if err != nil { t.Fatal(err) } if got.Weapon == nil || got.Weapon.ItemID != 101010912 { t.Fatalf("fresh-character weapon projection=%+v", got.Weapon) } } func TestVisualProjectionUsesCloneAvatarDisplayID(t *testing.T) { clone := avatarFixture(3, 499999999, "[coat avatar]") clone.Extra[equip.CloneAvatarDisplayItemIDExtraKey] = "412500009" state, selection := visualFixture( "8", "DETSDT", "13", 3, 114000248, map[string]repository.EquipmentEntry{"coat": clone}, ) got, err := BuildVisualProjection( state, selection, time.Unix(1_800_000_000, 0), ) if err != nil { t.Fatal(err) } if len(got.Avatars) != 1 || got.Avatars[0].ItemID != 499999999 || got.Avatars[0].DisplayItemID != 412500009 { t.Fatalf("clone avatar projection=%+v", got.Avatars) } } func TestVisualProjectionAcceptsPrimaryAndSupportWeapon(t *testing.T) { state, selection := visualFixture("13", "Vagabond", "11", 4, 28246, nil) secondary := state.Equipment.Entries["weapon"] secondary.SlotIndex, secondary.ItemID = 24, 28239 state.Equipment.Entries["support"] = secondary before := repository.CloneEquipment(state.Equipment) got, err := BuildVisualProjection(state, selection, time.Unix(1_800_000_000, 0)) if err != nil || got.Weapon == nil || got.Weapon.Slot != 12 || got.Weapon.ItemID != 28246 { t.Fatalf("valid dual weapons rejected or primary changed: %+v err=%v", got, err) } if !reflect.DeepEqual(before, state.Equipment) { t.Fatal("display projection changed source equipment") } delete(state.Equipment.Entries, "weapon") got, err = BuildVisualProjection(state, selection, time.Unix(1_800_000_000, 0)) if err != nil || got.Weapon != nil { t.Fatalf("support weapon substituted for missing primary: %+v err=%v", got, err) } } func TestVisualProjectionRejectsDuplicateWeapons(t *testing.T) { state, selection := visualFixture( "2", "DETSCS", "0", 35, 101010912, nil, ) state.Equipment.Entries["weapon2"] = repository.EquipmentEntry{ SlotIndex: 20, ItemID: 101010913, Extra: map[string]string{ "item_kind": "equipment", "pvf_path": "equipment/character/test/weapon/other.equ", }, } if _, err := BuildVisualProjection( state, selection, time.Unix(1_800_000_000, 0), ); err != ErrSnapshot { t.Fatalf("duplicate weapon err=%v want=%v", err, ErrSnapshot) } } func TestProjectionSettingsSelectExactDifferentCharacter(t *testing.T) { scope, err := SettingsScope("3") if err != nil || scope != "character:3:companion-v1" { t.Fatalf("scope=%q err=%v", scope, err) } record := repository.SettingsRecord{ Scope: scope, Values: map[string]string{ "source_character_id": "2", }, } source, found, err := SourceCharacterIDFromSettings(record, "3") if err != nil || !found || source != "2" { t.Fatalf("source=%q found=%t err=%v", source, found, err) } record.Values["source_character_id"] = "8" source, found, err = SourceCharacterIDFromSettings(record, "3") if err != nil || !found || source != "8" { t.Fatalf("source=%q found=%t err=%v", source, found, err) } for _, tc := range []struct { name string record repository.SettingsRecord owner string want error }{ {"self", repository.SettingsRecord{Scope: scope, Values: map[string]string{"source_character_id": "3"}}, "3", ErrSelf}, {"leading zero", repository.SettingsRecord{Scope: scope, Values: map[string]string{"source_character_id": "02"}}, "3", ErrSelection}, {"wrong scope", repository.SettingsRecord{Scope: "character:4:companion-v1", Values: map[string]string{"source_character_id": "2"}}, "3", ErrSelection}, } { if _, _, got := SourceCharacterIDFromSettings(tc.record, tc.owner); got != tc.want { t.Fatalf("%s err=%v want=%v", tc.name, got, tc.want) } } }