DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,765 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
||||
dnfmonster "longheng.io/server/internal/modules/dnf/monster"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||||
)
|
||||
|
||||
const (
|
||||
tutorialScopeATSwordmanDungeonReference = "Cataclysm/NewTutorial/Tutorial_ATSwordman.dgn"
|
||||
tutorialScopeATSwordmanMapDirectory = "Cataclysm/NewTutorial/ATSwordman"
|
||||
tutorialScopeKnightFDungeonReference = "Cataclysm/NewTutorial/knight_F.dgn"
|
||||
tutorialScopeKnightFMapDirectory = "Cataclysm/NewTutorial/knight_F"
|
||||
tutorialScopeOtherDungeonReference = "Cataclysm/NewTutorial/Tutorial_Swordman.dgn"
|
||||
tutorialScopeOtherMapDirectory = "Cataclysm/NewTutorial/Swordman"
|
||||
tutorialScopeGunnerDungeonReference = "Cataclysm/NewTutorial/Tutorial_Gunner.dgn"
|
||||
tutorialScopeGunnerMapDirectory = "Cataclysm/NewTutorial/Gunner"
|
||||
)
|
||||
|
||||
func TestIsATSwordmanTutorialSceneRequiresExactScopedOwner(t *testing.T) {
|
||||
_, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{})
|
||||
scene, ok := runtime.Session.Scene()
|
||||
if !ok {
|
||||
t.Fatal("prepared tutorial runtime has no current scene")
|
||||
}
|
||||
if runtime.Character.Job != "11" {
|
||||
t.Fatalf("fixture job=%q want=11", runtime.Character.Job)
|
||||
}
|
||||
if runtime.Dungeon.Path != "dungeon/"+tutorialScopeATSwordmanDungeonReference {
|
||||
t.Fatalf("fixture dungeon path=%q", runtime.Dungeon.Path)
|
||||
}
|
||||
if scene.Map.Map.Path != "map/"+tutorialScopeATSwordmanMapDirectory+"/boss.map" || !scene.Boss {
|
||||
t.Fatalf("fixture scene path=%q boss=%t", scene.Map.Map.Path, scene.Boss)
|
||||
}
|
||||
if !isATSwordmanTutorialScene(runtime, scene) {
|
||||
t.Fatal("exact ATSwordman tutorial Boss scene was not recognized")
|
||||
}
|
||||
|
||||
// The runtime PVF preserves mixed case. Also require slash/case
|
||||
// normalization without relaxing either the exact dungeon owner or the map
|
||||
// directory boundary.
|
||||
normalizedRuntime := *runtime
|
||||
normalizedRuntime.Dungeon = runtime.Dungeon
|
||||
normalizedRuntime.Dungeon.Path = ` .\DUNGEON\Cataclysm\NewTutorial\Tutorial_ATSwordman.dgn `
|
||||
normalizedScene := scene
|
||||
normalizedScene.Map.Map.Path = ` MAP\Cataclysm\NewTutorial\ATSwordman\boss.map `
|
||||
if !isATSwordmanTutorialScene(&normalizedRuntime, normalizedScene) {
|
||||
t.Fatal("normalized ATSwordman tutorial paths were not recognized")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*runtimeDungeonState, *worldmap.DungeonRoomScene)
|
||||
}{
|
||||
{
|
||||
name: "other job",
|
||||
mutate: func(candidate *runtimeDungeonState, _ *worldmap.DungeonRoomScene) {
|
||||
candidate.Character.Job = "0"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "other profession dungeon path",
|
||||
mutate: func(candidate *runtimeDungeonState, _ *worldmap.DungeonRoomScene) {
|
||||
candidate.Dungeon.Path = "dungeon/" + tutorialScopeOtherDungeonReference
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dungeon path suffix collision",
|
||||
mutate: func(candidate *runtimeDungeonState, _ *worldmap.DungeonRoomScene) {
|
||||
candidate.Dungeon.Path = "dungeon/" + tutorialScopeATSwordmanDungeonReference + ".copy"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "other profession map path",
|
||||
mutate: func(_ *runtimeDungeonState, candidate *worldmap.DungeonRoomScene) {
|
||||
candidate.Map.Map.Path = "map/" + tutorialScopeOtherMapDirectory + "/boss.map"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "map prefix collision",
|
||||
mutate: func(_ *runtimeDungeonState, candidate *worldmap.DungeonRoomScene) {
|
||||
candidate.Map.Map.Path = "map/" + tutorialScopeATSwordmanMapDirectory + "Extra/boss.map"
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidateRuntime := *runtime
|
||||
candidateRuntime.Dungeon = runtime.Dungeon
|
||||
candidateRuntime.Character = runtime.Character
|
||||
candidateScene := scene
|
||||
test.mutate(&candidateRuntime, &candidateScene)
|
||||
if isATSwordmanTutorialScene(&candidateRuntime, candidateScene) {
|
||||
t.Fatalf("unscoped scene accepted: job=%q dungeon=%q map=%q boss=%t",
|
||||
candidateRuntime.Character.Job,
|
||||
candidateRuntime.Dungeon.Path,
|
||||
candidateScene.Map.Map.Path,
|
||||
candidateScene.Boss,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPVFTutorialDungeonSceneUsesMetadataNotProfessionOrPath(t *testing.T) {
|
||||
tests := []tutorialScopeFixtureOptions{
|
||||
{},
|
||||
{
|
||||
job: "12",
|
||||
dungeonReference: tutorialScopeKnightFDungeonReference,
|
||||
mapDirectory: tutorialScopeKnightFMapDirectory,
|
||||
currentRoomNonBoss: true,
|
||||
bossRank: "[normal]",
|
||||
singleMonster: true,
|
||||
},
|
||||
{
|
||||
job: "0",
|
||||
dungeonReference: tutorialScopeOtherDungeonReference,
|
||||
mapDirectory: tutorialScopeOtherMapDirectory,
|
||||
currentRoomNonBoss: true,
|
||||
},
|
||||
}
|
||||
for index, fixture := range tests {
|
||||
t.Run(fmt.Sprintf("profession_%d", index), func(t *testing.T) {
|
||||
_, runtime := prepareTutorialScopeRuntime(t, fixture)
|
||||
scene, ok := runtime.Session.Scene()
|
||||
if !ok {
|
||||
t.Fatal("prepared tutorial runtime has no current scene")
|
||||
}
|
||||
if !isPVFTutorialDungeonScene(runtime, scene) {
|
||||
t.Fatalf("PVF tutorial scene rejected: job=%q dungeon=%q map=%q metadata=%+v",
|
||||
runtime.Character.Job,
|
||||
runtime.Dungeon.Path,
|
||||
scene.Map.Map.Path,
|
||||
runtime.Dungeon.Metadata.TutorialDungeon,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, fixture := range []tutorialScopeFixtureOptions{
|
||||
{omitTutorialFlag: true},
|
||||
{disableTutorial: true},
|
||||
} {
|
||||
_, runtime := prepareTutorialScopeRuntime(t, fixture)
|
||||
scene, ok := runtime.Session.Scene()
|
||||
if !ok {
|
||||
t.Fatal("prepared non-tutorial runtime has no current scene")
|
||||
}
|
||||
if isPVFTutorialDungeonScene(runtime, scene) {
|
||||
t.Fatalf("non-enabled tutorial metadata accepted: %+v", runtime.Dungeon.Metadata.TutorialDungeon)
|
||||
}
|
||||
}
|
||||
|
||||
_, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{})
|
||||
scene, ok := runtime.Session.Scene()
|
||||
if !ok {
|
||||
t.Fatal("prepared tutorial runtime has no current scene")
|
||||
}
|
||||
staleScene := scene
|
||||
staleScene.Map.Map.ID++
|
||||
if isPVFTutorialDungeonScene(runtime, staleScene) {
|
||||
t.Fatal("stale map scene was accepted as the current tutorial room")
|
||||
}
|
||||
staleScene = scene
|
||||
staleScene.Coordinate.X++
|
||||
if isPVFTutorialDungeonScene(runtime, staleScene) {
|
||||
t.Fatal("stale coordinate was accepted as the current tutorial room")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeDungeonRoomAnnouncedMonsterReturnsOnlyAnnouncedOrdinaryMonster(t *testing.T) {
|
||||
_, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{})
|
||||
before := runtime.Room.Snapshot()
|
||||
if len(before.Monsters) != 2 {
|
||||
t.Fatalf("fixture monsters=%+v", before.Monsters)
|
||||
}
|
||||
bossKey := before.Monsters[0].ObjectKey
|
||||
normalKey := before.Monsters[1].ObjectKey
|
||||
if _, ok := runtime.Room.AnnouncedMonster(bossKey); ok {
|
||||
t.Fatal("planned ordinary monster was reported as announced")
|
||||
}
|
||||
|
||||
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
|
||||
t.Fatalf("announce tutorial actors: %v", err)
|
||||
}
|
||||
boss, ok := runtime.Room.AnnouncedMonster(bossKey)
|
||||
if !ok || boss.ObjectKey != bossKey || boss.State != runtimeDungeonMonsterAnnounced || boss.Spawn.Rank != "[boss]" {
|
||||
t.Fatalf("announced Boss lookup=(%+v,%t)", boss, ok)
|
||||
}
|
||||
normal, ok := runtime.Room.AnnouncedMonster(normalKey)
|
||||
if !ok || normal.ObjectKey != normalKey || normal.State != runtimeDungeonMonsterAnnounced || normal.Spawn.Rank != "[normal]" {
|
||||
t.Fatalf("announced normal lookup=(%+v,%t)", normal, ok)
|
||||
}
|
||||
if _, ok := runtime.Room.AnnouncedMonster(999999); ok {
|
||||
t.Fatal("unknown object key was reported as an announced ordinary monster")
|
||||
}
|
||||
|
||||
if _, cleared, err := runtime.Room.CommitActorDeathReport(bossKey, runtime.Session); err != nil || cleared {
|
||||
t.Fatalf("commit one of two hostiles: cleared=%t error=%v", cleared, err)
|
||||
}
|
||||
if _, ok := runtime.Room.AnnouncedMonster(bossKey); ok {
|
||||
t.Fatal("defeated ordinary monster was still reported as currently announced")
|
||||
}
|
||||
if _, ok := runtime.Room.AnnouncedMonster(normalKey); !ok {
|
||||
t.Fatal("unrelated announced ordinary monster disappeared after Boss death")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonMonsterDeathAcceptsPVFTutorialScriptedBossSentinelOwner(t *testing.T) {
|
||||
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{})
|
||||
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
|
||||
t.Fatalf("announce tutorial actors: %v", err)
|
||||
}
|
||||
roomBefore := runtime.Room.Snapshot()
|
||||
if len(roomBefore.Monsters) != 2 || roomBefore.Monsters[0].Spawn.Rank != "[boss]" || roomBefore.Monsters[1].Spawn.Rank != "[normal]" {
|
||||
t.Fatalf("fixture room=%+v", roomBefore)
|
||||
}
|
||||
bossKey := roomBefore.Monsters[0].ObjectKey
|
||||
normalKey := roomBefore.Monsters[1].ObjectKey
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: conn,
|
||||
connID: "pvf-tutorial-scripted-boss-test",
|
||||
selectedCharacterID: 99,
|
||||
dungeon: dungeonSessionState{runtime: runtime},
|
||||
}
|
||||
|
||||
if err := service.handleDungeonMonsterDeath(session, tutorialScopeVariableZeroCombatDeathBody(bossKey)); err != nil {
|
||||
t.Fatalf("accept PVF tutorial scripted Boss death: %v", err)
|
||||
}
|
||||
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
wantBody, err := buildCurrentDungeonDeathNotificationBody(bossKey, currentDungeonDeathResponseMonster)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if packet.Header.Classification != 0 ||
|
||||
packet.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
|
||||
!bytes.Equal(packet.Body, wantBody) || len(rest) != 0 {
|
||||
t.Fatalf("escape Boss response header=%+v body=%x want=%x rest=%x", packet.Header, packet.Body, wantBody, rest)
|
||||
}
|
||||
|
||||
roomAfter := runtime.Room.Snapshot()
|
||||
if roomAfter.Monsters[0].ObjectKey != bossKey || roomAfter.Monsters[0].State != runtimeDungeonMonsterDefeated {
|
||||
t.Fatalf("Boss state=%+v", roomAfter.Monsters[0])
|
||||
}
|
||||
if roomAfter.Monsters[1].ObjectKey != normalKey || roomAfter.Monsters[1].State != runtimeDungeonMonsterAnnounced {
|
||||
t.Fatalf("normal hostile state=%+v", roomAfter.Monsters[1])
|
||||
}
|
||||
if _, ok := runtime.Room.AnnouncedMonster(bossKey); ok {
|
||||
t.Fatal("ACKed escape Boss remained currently announced")
|
||||
}
|
||||
if _, ok := runtime.Room.AnnouncedMonster(normalKey); !ok {
|
||||
t.Fatal("escape Boss ACK retired an unrelated normal hostile")
|
||||
}
|
||||
snapshot := runtime.Session.Snapshot()
|
||||
if snapshot.Scene.Cleared || len(snapshot.Scene.DefeatedObjects) != 1 || snapshot.Scene.DefeatedObjects[0] != bossKey {
|
||||
t.Fatalf("escape Boss fabricated room clear/deaths: %+v", snapshot.Scene)
|
||||
}
|
||||
if snapshot.Run.Status != worldmap.DungeonRunActive || snapshot.Run.Current != (worldmap.RoomCoordinate{X: 0, Y: 0}) {
|
||||
t.Fatalf("escape Boss changed run lifecycle=%+v", snapshot.Run)
|
||||
}
|
||||
if session.dungeon.runtime != runtime {
|
||||
t.Fatalf("escape Boss changed runtime ownership: session_runtime=%p want=%p", session.dungeon.runtime, runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonMonsterDeathAcceptsGenericPVFTutorialScriptedMonsterDeath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixture tutorialScopeFixtureOptions
|
||||
}{
|
||||
{
|
||||
name: "knight_f_invisible_start_guide",
|
||||
fixture: tutorialScopeFixtureOptions{
|
||||
job: "12",
|
||||
dungeonReference: tutorialScopeKnightFDungeonReference,
|
||||
mapDirectory: tutorialScopeKnightFMapDirectory,
|
||||
currentRoomNonBoss: true,
|
||||
bossRank: "[normal]",
|
||||
singleMonster: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "another_profession_tutorial",
|
||||
fixture: tutorialScopeFixtureOptions{
|
||||
job: "0",
|
||||
dungeonReference: tutorialScopeOtherDungeonReference,
|
||||
mapDirectory: tutorialScopeOtherMapDirectory,
|
||||
currentRoomNonBoss: true,
|
||||
bossRank: "[normal]",
|
||||
singleMonster: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
service, runtime := prepareTutorialScopeRuntime(t, test.fixture)
|
||||
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
|
||||
t.Fatalf("announce tutorial actors: %v", err)
|
||||
}
|
||||
roomBefore := runtime.Room.Snapshot()
|
||||
if len(roomBefore.Monsters) != 1 || roomBefore.Monsters[0].Spawn.Rank != "[normal]" {
|
||||
t.Fatalf("single scripted tutorial monster fixture=%+v", roomBefore.Monsters)
|
||||
}
|
||||
sceneBefore, ok := runtime.Session.Scene()
|
||||
if !ok || !sceneBefore.Start || sceneBefore.Boss || sceneBefore.Cleared {
|
||||
t.Fatalf("tutorial start scene=%+v ok=%t", sceneBefore, ok)
|
||||
}
|
||||
objectKey := roomBefore.Monsters[0].ObjectKey
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: conn,
|
||||
connID: "generic-pvf-tutorial-scripted-death-test",
|
||||
selectedCharacterID: 99,
|
||||
dungeon: dungeonSessionState{runtime: runtime},
|
||||
}
|
||||
|
||||
if err := service.handleDungeonMonsterDeath(session, tutorialScopeVariableZeroCombatDeathBody(objectKey)); err != nil {
|
||||
t.Fatalf("accept generic tutorial scripted death: %v", err)
|
||||
}
|
||||
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
wantBody, err := buildCurrentDungeonDeathNotificationBody(objectKey, currentDungeonDeathResponseMonster)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if packet.Header.Classification != 0 ||
|
||||
packet.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
|
||||
!bytes.Equal(packet.Body, wantBody) || len(rest) != 0 {
|
||||
t.Fatalf("scripted tutorial response header=%+v body=%x want=%x rest=%x", packet.Header, packet.Body, wantBody, rest)
|
||||
}
|
||||
|
||||
roomAfter := runtime.Room.Snapshot()
|
||||
if len(roomAfter.Monsters) != 1 || roomAfter.Monsters[0].State != runtimeDungeonMonsterDefeated {
|
||||
t.Fatalf("scripted tutorial monster state=%+v", roomAfter.Monsters)
|
||||
}
|
||||
if _, announced := runtime.Room.AnnouncedMonster(objectKey); announced {
|
||||
t.Fatal("ACKed scripted tutorial monster remained announced")
|
||||
}
|
||||
snapshot := runtime.Session.Snapshot()
|
||||
if !snapshot.Scene.Cleared || len(snapshot.Scene.DefeatedObjects) != 1 || snapshot.Scene.DefeatedObjects[0] != objectKey {
|
||||
t.Fatalf("scripted tutorial death did not clear exactly the owned hostile: %+v", snapshot.Scene)
|
||||
}
|
||||
if snapshot.Run.Status != worldmap.DungeonRunActive || snapshot.Run.Current != (worldmap.RoomCoordinate{X: 0, Y: 0}) {
|
||||
t.Fatalf("scripted tutorial death changed run lifecycle=%+v", snapshot.Run)
|
||||
}
|
||||
if session.dungeon.runtime != runtime {
|
||||
t.Fatalf("scripted tutorial death changed runtime ownership: session_runtime=%p want=%p", session.dungeon.runtime, runtime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFTutorialSentinelOwnerKeepsNonHostileAPCRetirementSeparate(t *testing.T) {
|
||||
source := bridgeDungeonPVF(false)
|
||||
source["dungeon/test.dgn"] += "[tutorial dungeon]\n1\n"
|
||||
source["map/dungeon/test/start.map"] += "[ai character]\n" +
|
||||
"4002 30 20 0 `[character]` `[normal]` 0 0\n" +
|
||||
"4003 40 20 0 `[character]` `[normal]` 0 0\n"
|
||||
source[defaultDungeonAICharacterList] = "4002 `Test/Friend.aic`\n4003 `Test/Friend2.aic`\n"
|
||||
source["AICharacter/Test/Friend.aic"] = "[minimum info]\n`Friend APC` 1 2 3 4 25\n"
|
||||
source["AICharacter/Test/Friend2.aic"] = "[minimum info]\n`Friend APC 2` 1 2 3 4 25\n"
|
||||
source[defaultDungeonCinematicList] = "9001 `Dungeon/Tutorial/apc.cmt`\n"
|
||||
source["cinematic/Dungeon/Tutorial/apc.cmt"] = "[MAP]\n100\n[SCENE]\n[BEHAVIOR]\n" +
|
||||
"[ACTOR]\n[TYPE]\n[MONSTER]\n[INDEX]\n0\n[/ACTOR]\n[DESTROY]\n[/DESTROY]\n[/BEHAVIOR]\n[/SCENE]\n"
|
||||
aiCatalog, err := newPVFDungeonAICharacterCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatalf("load tutorial APC catalog: %v", err)
|
||||
}
|
||||
tutorialScripts, err := newPVFDungeonTutorialScriptCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatalf("load tutorial cinematic catalog: %v", err)
|
||||
}
|
||||
runtime := prepareSyntheticDungeonRuntimeForEntryTest(t, source, aiCatalog)
|
||||
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
|
||||
t.Fatalf("announce tutorial monster and APC: %v", err)
|
||||
}
|
||||
before := runtime.Room.Snapshot()
|
||||
if len(before.Monsters) != 1 || len(before.ExtendedActors) != 2 {
|
||||
t.Fatalf("tutorial APC fixture=%+v", before)
|
||||
}
|
||||
monsterKey := before.Monsters[0].ObjectKey
|
||||
apcKey := before.ExtendedActors[0].ObjectKey
|
||||
playerOwnedAPCKey := before.ExtendedActors[1].ObjectKey
|
||||
if _, ordinary := runtime.Room.AnnouncedMonster(apcKey); ordinary {
|
||||
t.Fatal("non-hostile APC appeared in ordinary announced-monster ownership")
|
||||
}
|
||||
|
||||
conn := &bufferConn{}
|
||||
service := &Service{
|
||||
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
dungeonTutorialScripts: tutorialScripts,
|
||||
}
|
||||
session := &gameSession{conn: conn, connID: "pvf-tutorial-apc-retirement-test", dungeon: dungeonSessionState{runtime: runtime}}
|
||||
if err := service.handleDungeonMonsterDeath(session, tutorialScopeVariableZeroCombatDeathBody(apcKey)); err != nil {
|
||||
t.Fatalf("retire tutorial APC: %v", err)
|
||||
}
|
||||
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
wantBody, err := buildCurrentDungeonDeathNotificationBody(apcKey, currentDungeonDeathResponseAICharacter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if packet.Header.Classification != 0 ||
|
||||
packet.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
|
||||
!bytes.Equal(packet.Body, wantBody) || len(rest) != 0 {
|
||||
t.Fatalf("tutorial APC retirement response header=%+v body=%x want=%x rest=%x", packet.Header, packet.Body, wantBody, rest)
|
||||
}
|
||||
after := runtime.Room.Snapshot()
|
||||
if after.ExtendedActors[0].State != runtimeDungeonMonsterDefeated ||
|
||||
after.ExtendedActors[1].State != runtimeDungeonMonsterAnnounced ||
|
||||
after.Monsters[0].State != runtimeDungeonMonsterAnnounced {
|
||||
t.Fatalf("tutorial APC retirement crossed actor ownership: before=%+v after=%+v", before, after)
|
||||
}
|
||||
if _, ordinary := runtime.Room.AnnouncedMonster(monsterKey); !ordinary {
|
||||
t.Fatal("tutorial APC retirement removed the ordinary hostile")
|
||||
}
|
||||
scene, ok := runtime.Session.Scene()
|
||||
if !ok || scene.Cleared || len(scene.DefeatedObjects) != 0 {
|
||||
t.Fatalf("tutorial APC retirement changed hostile clear state: scene=%+v ok=%t", scene, ok)
|
||||
}
|
||||
|
||||
// Priest_M tutorial map 76025 reports its two non-hostile training APCs
|
||||
// with the selected player as owner and one variable combat row. That live
|
||||
// shape must receive the same native op38 visual-retirement ACK without
|
||||
// entering hostile death, drop, EXP, or clear ownership.
|
||||
conn.write.Reset()
|
||||
session.selectedCharacterID = 99
|
||||
playerOwnedBody := make([]byte, dungeoncmd.DieMonsterVariableBaseSize+dungeoncmd.DieMonsterVariableCombatEntrySize)
|
||||
binary.LittleEndian.PutUint32(playerOwnedBody[0:4], playerOwnedAPCKey)
|
||||
binary.LittleEndian.PutUint16(playerOwnedBody[4:6], currentSceneActorObjectKey(session.selectedCharacterID))
|
||||
playerOwnedBody[22] = 1
|
||||
if err := service.handleDungeonMonsterDeath(session, playerOwnedBody); err != nil {
|
||||
t.Fatalf("retire player-owned tutorial APC: %v", err)
|
||||
}
|
||||
playerPacket, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
wantPlayerBody, err := buildCurrentDungeonDeathNotificationBody(
|
||||
playerOwnedAPCKey,
|
||||
currentDungeonDeathResponseAICharacter,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if playerPacket.Header.Classification != 0 ||
|
||||
playerPacket.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
|
||||
!bytes.Equal(playerPacket.Body, wantPlayerBody) || len(rest) != 0 {
|
||||
t.Fatalf("player-owned tutorial APC response header=%+v body=%x want=%x rest=%x",
|
||||
playerPacket.Header, playerPacket.Body, wantPlayerBody, rest)
|
||||
}
|
||||
after = runtime.Room.Snapshot()
|
||||
scene, ok = runtime.Session.Scene()
|
||||
if after.ExtendedActors[0].State != runtimeDungeonMonsterDefeated ||
|
||||
after.ExtendedActors[1].State != runtimeDungeonMonsterDefeated ||
|
||||
after.Monsters[0].State != runtimeDungeonMonsterAnnounced ||
|
||||
!ok || scene.Cleared || len(scene.DefeatedObjects) != 0 {
|
||||
t.Fatalf("player-owned tutorial APC retirement changed hostile ownership: room=%+v scene=%+v ok=%t",
|
||||
after, scene, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonMonsterDeathRejectsUnprovenTutorialSentinelOwner(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options tutorialScopeFixtureOptions
|
||||
announce bool
|
||||
targetIndex int
|
||||
body func(uint32) []byte
|
||||
}{
|
||||
{
|
||||
name: "PVF tutorial flag missing",
|
||||
options: tutorialScopeFixtureOptions{omitTutorialFlag: true},
|
||||
announce: true,
|
||||
},
|
||||
{
|
||||
name: "PVF tutorial flag disabled",
|
||||
options: tutorialScopeFixtureOptions{disableTutorial: true},
|
||||
announce: true,
|
||||
},
|
||||
{
|
||||
name: "ordinary monster not announced",
|
||||
announce: false,
|
||||
},
|
||||
{
|
||||
name: "announced ordinary monster is not a CMT destroy target",
|
||||
announce: true,
|
||||
targetIndex: 1,
|
||||
},
|
||||
{
|
||||
name: "fixed62 request shape",
|
||||
announce: true,
|
||||
body: tutorialScopeFixedDeathBody,
|
||||
},
|
||||
{
|
||||
name: "variable request with one combat entry",
|
||||
announce: true,
|
||||
body: tutorialScopeVariableOneCombatDeathBody,
|
||||
},
|
||||
{
|
||||
name: "variable request with opaque tail",
|
||||
announce: true,
|
||||
body: tutorialScopeVariableDeathBodyWithTail,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
service, runtime := prepareTutorialScopeRuntime(t, test.options)
|
||||
before := runtime.Room.Snapshot()
|
||||
if len(before.Monsters) != 2 {
|
||||
t.Fatalf("fixture monsters=%+v", before.Monsters)
|
||||
}
|
||||
if test.targetIndex < 0 || test.targetIndex >= len(before.Monsters) {
|
||||
t.Fatalf("target index=%d monsters=%+v", test.targetIndex, before.Monsters)
|
||||
}
|
||||
targetKey := before.Monsters[test.targetIndex].ObjectKey
|
||||
if test.announce {
|
||||
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
|
||||
t.Fatalf("announce tutorial actors: %v", err)
|
||||
}
|
||||
}
|
||||
roomBefore := runtime.Room.Snapshot()
|
||||
sessionBefore := runtime.Session.Snapshot()
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: conn,
|
||||
connID: "atswordman-escape-negative-test",
|
||||
selectedCharacterID: 99,
|
||||
dungeon: dungeonSessionState{runtime: runtime},
|
||||
}
|
||||
bodyBuilder := test.body
|
||||
if bodyBuilder == nil {
|
||||
bodyBuilder = tutorialScopeVariableZeroCombatDeathBody
|
||||
}
|
||||
if err := service.handleDungeonMonsterDeath(session, bodyBuilder(targetKey)); err != nil {
|
||||
t.Fatalf("rejected tutorial sentinel request returned error: %v", err)
|
||||
}
|
||||
if conn.write.Len() != 0 {
|
||||
t.Fatalf("unproven tutorial sentinel emitted response=%x", conn.write.Bytes())
|
||||
}
|
||||
|
||||
roomAfter := runtime.Room.Snapshot()
|
||||
sessionAfter := runtime.Session.Snapshot()
|
||||
if len(roomAfter.Monsters) != len(roomBefore.Monsters) {
|
||||
t.Fatalf("monster count changed: before=%+v after=%+v", roomBefore.Monsters, roomAfter.Monsters)
|
||||
}
|
||||
for index := range roomBefore.Monsters {
|
||||
if roomAfter.Monsters[index].ObjectKey != roomBefore.Monsters[index].ObjectKey ||
|
||||
roomAfter.Monsters[index].State != roomBefore.Monsters[index].State {
|
||||
t.Fatalf("monster[%d] changed: before=%+v after=%+v", index, roomBefore.Monsters[index], roomAfter.Monsters[index])
|
||||
}
|
||||
}
|
||||
if sessionAfter.Scene.Cleared || len(sessionAfter.Scene.DefeatedObjects) != 0 {
|
||||
t.Fatalf("rejected tutorial sentinel changed clear/death state=%+v", sessionAfter.Scene)
|
||||
}
|
||||
if sessionAfter.Run.Status != worldmap.DungeonRunActive ||
|
||||
sessionAfter.Run.Current != sessionBefore.Run.Current ||
|
||||
sessionAfter.Scene.Coordinate != sessionBefore.Scene.Coordinate ||
|
||||
sessionAfter.Scene.Map.Map.ID != sessionBefore.Scene.Map.Map.ID {
|
||||
t.Fatalf("rejected tutorial sentinel changed run/scene: before=%+v after=%+v", sessionBefore, sessionAfter)
|
||||
}
|
||||
if session.dungeon.runtime != runtime {
|
||||
t.Fatalf("rejected tutorial sentinel changed runtime owner: runtime=%p want=%p", session.dungeon.runtime, runtime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type tutorialScopeFixtureOptions struct {
|
||||
job string
|
||||
dungeonReference string
|
||||
mapDirectory string
|
||||
currentRoomNonBoss bool
|
||||
bossRank string
|
||||
bossSuffixMarker string
|
||||
singleMonster bool
|
||||
incompleteCinematicCoverage bool
|
||||
omitTutorialFlag bool
|
||||
disableTutorial bool
|
||||
tutorialCompleted bool
|
||||
clearCondition string
|
||||
}
|
||||
|
||||
func prepareTutorialScopeRuntime(
|
||||
t *testing.T,
|
||||
fixture tutorialScopeFixtureOptions,
|
||||
) (*Service, *runtimeDungeonState) {
|
||||
t.Helper()
|
||||
if fixture.job == "" {
|
||||
fixture.job = "11"
|
||||
}
|
||||
if fixture.dungeonReference == "" {
|
||||
fixture.dungeonReference = tutorialScopeATSwordmanDungeonReference
|
||||
}
|
||||
if fixture.mapDirectory == "" {
|
||||
fixture.mapDirectory = tutorialScopeATSwordmanMapDirectory
|
||||
}
|
||||
if fixture.bossRank == "" {
|
||||
fixture.bossRank = "[boss]"
|
||||
}
|
||||
source := tutorialScopePVF(fixture)
|
||||
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
|
||||
tutorialScripts, err := newPVFDungeonTutorialScriptCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatalf("load synthetic tutorial script catalog: %v", err)
|
||||
}
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
characterStats := map[string]int64{
|
||||
"fatigue": 100,
|
||||
"town_id": 7,
|
||||
"area_id": 3,
|
||||
"pos_x": 474,
|
||||
"pos_y": 234,
|
||||
"direction": 5,
|
||||
"area_state": 3,
|
||||
}
|
||||
if fixture.tutorialCompleted {
|
||||
characterStats[currentDungeonTutorialCompletedKey] = currentDungeonTutorialCompleteFlag
|
||||
}
|
||||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: "99",
|
||||
AccountID: "account-1",
|
||||
Name: "ATSwordman",
|
||||
Job: fixture.job,
|
||||
Level: 20,
|
||||
Stats: characterStats,
|
||||
}); err != nil {
|
||||
t.Fatalf("save tutorial character: %v", err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "99",
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
}); err != nil {
|
||||
t.Fatalf("save tutorial inventory: %v", err)
|
||||
}
|
||||
service := &Service{
|
||||
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
worldMapTable: table,
|
||||
worldMapResolver: resolver,
|
||||
dungeonMonsterTable: monsters,
|
||||
dungeonTutorialScripts: tutorialScripts,
|
||||
dungeonChoice: func(int) (int, error) { return 0, nil },
|
||||
dungeonSeed: func() (uint32, error) { return 1, nil },
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||||
}
|
||||
runtime, _, err := service.prepareDungeonRuntime(
|
||||
context.Background(),
|
||||
&gameSession{selectedCharacterID: 99},
|
||||
dungeoncmd.SelectDungeonRequest{DungeonID: 700, Difficulty: 1},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare ATSwordman tutorial runtime: %v", err)
|
||||
}
|
||||
return service, runtime
|
||||
}
|
||||
|
||||
func tutorialScopePVF(fixture tutorialScopeFixtureOptions) bridgePVFSource {
|
||||
startReference := fixture.mapDirectory + "/boss.map"
|
||||
startDocument := "map/" + startReference
|
||||
dungeonDocument := "dungeon/" + fixture.dungeonReference
|
||||
startMap := "[map name]\n`ATSwordman tutorial Boss`\n" +
|
||||
"[dungeon]\n700\n" +
|
||||
"[type]\n`[boss]`\n" +
|
||||
"[monster]\n" +
|
||||
"3001 10 0 100 200 0 0 0 `[fixed]` `" + fixture.bossRank + "`"
|
||||
if fixture.bossSuffixMarker != "" {
|
||||
startMap += " `" + fixture.bossSuffixMarker + "`"
|
||||
}
|
||||
startMap += "\n"
|
||||
if !fixture.singleMonster {
|
||||
startMap += "3001 10 0 140 200 0 0 0 `[fixed]` `[normal]`\n"
|
||||
}
|
||||
|
||||
mapList := "100 `" + startReference + "`\n"
|
||||
dungeonText := "[name]\n`Synthetic ATSwordman Tutorial`\n" +
|
||||
"[minimum required level]\n10\n" +
|
||||
"[basis level]\n20\n" +
|
||||
"[limit party count]\n1\n" +
|
||||
"[no fatigue]\n"
|
||||
if !fixture.omitTutorialFlag {
|
||||
tutorialValue := 1
|
||||
if fixture.disableTutorial {
|
||||
tutorialValue = 0
|
||||
}
|
||||
dungeonText += "[tutorial dungeon]\n" + fmt.Sprintf("%d\n", tutorialValue)
|
||||
}
|
||||
dungeonText += "[maze info]\n"
|
||||
cinematic := "[MAP]\n100\n" +
|
||||
"[SCENE]\n[BEHAVIOR]\n[ACTOR]\n[TYPE]\n[MONSTER]\n[INDEX]\n0\n[/ACTOR]\n" +
|
||||
"[DESTROY]\n[IS SHOW DIE]\n0\n[/DESTROY]\n[/BEHAVIOR]\n"
|
||||
if !fixture.singleMonster && !fixture.incompleteCinematicCoverage {
|
||||
cinematic += "[BEHAVIOR]\n[ACTOR]\n[TYPE]\n[MONSTER]\n[INDEX]\n1\n[/ACTOR]\n[MOVE]\n[/MOVE]\n[/BEHAVIOR]\n"
|
||||
}
|
||||
cinematic += "[/SCENE]\n"
|
||||
source := bridgePVFSource{
|
||||
worldmap.DefaultDungeonList: "700 `" + fixture.dungeonReference + "`\n",
|
||||
dungeonDocument: dungeonText,
|
||||
worldmap.DefaultWorldMapList: "1 `tutorial_scope.wdm`\n",
|
||||
"worldmap/tutorial_scope.wdm": "[name]\n`Synthetic Tutorial Area`\n[dungeon]\n700 -1\n[/dungeon]\n",
|
||||
dnfmonster.DefaultList: "3001 `tutorial_scope.gob`\n",
|
||||
"monster/tutorial_scope.gob": "[name]\n`Synthetic Tutorial Monster`\n[level]\n10\n[hp]\n500\n[exp]\n25\n",
|
||||
defaultDungeonCinematicList: "9001 `Dungeon/Tutorial/scope.cmt`\n",
|
||||
"cinematic/Dungeon/Tutorial/scope.cmt": cinematic,
|
||||
}
|
||||
if !fixture.currentRoomNonBoss {
|
||||
source[worldmap.DefaultMapList] = mapList
|
||||
source[startDocument] = startMap
|
||||
source[dungeonDocument] += "[size]\n1 1\n" +
|
||||
"[greed]\n`A`\n" +
|
||||
"[map specification]\n`map` 0 0 100\n" +
|
||||
"[start map]\n0 0\n" +
|
||||
"[boss map]\n0 0\n"
|
||||
if fixture.clearCondition != "" {
|
||||
source[dungeonDocument] += fixture.clearCondition
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
bossReference := fixture.mapDirectory + "/next.map"
|
||||
source[worldmap.DefaultMapList] = mapList + "101 `" + bossReference + "`\n"
|
||||
source[startDocument] = startMap
|
||||
source["map/"+bossReference] = "[map name]\n`Later Boss`\n[dungeon]\n700\n[type]\n`[boss]`\n"
|
||||
source[dungeonDocument] += "[size]\n2 1\n" +
|
||||
"[greed]\n`AA`\n" +
|
||||
"[map specification]\n`map` 0 0 100 `boss` 1 0 101\n" +
|
||||
"[start map]\n0 0\n" +
|
||||
"[boss map]\n1 0\n"
|
||||
if fixture.clearCondition != "" {
|
||||
source[dungeonDocument] += fixture.clearCondition
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func tutorialScopeVariableZeroCombatDeathBody(objectKey uint32) []byte {
|
||||
body := make([]byte, dungeoncmd.DieMonsterVariableBaseSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], objectKey)
|
||||
binary.LittleEndian.PutUint16(body[4:6], ^uint16(0))
|
||||
body[22] = 0
|
||||
return body
|
||||
}
|
||||
|
||||
func tutorialScopeFixedDeathBody(objectKey uint32) []byte {
|
||||
body := make([]byte, dungeoncmd.DieMonsterRequestSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], objectKey)
|
||||
binary.LittleEndian.PutUint16(body[4:6], ^uint16(0))
|
||||
return body
|
||||
}
|
||||
|
||||
func tutorialScopeVariableOneCombatDeathBody(objectKey uint32) []byte {
|
||||
body := make([]byte, dungeoncmd.DieMonsterVariableBaseSize+dungeoncmd.DieMonsterVariableCombatEntrySize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], objectKey)
|
||||
binary.LittleEndian.PutUint16(body[4:6], ^uint16(0))
|
||||
body[22] = 1
|
||||
return body
|
||||
}
|
||||
|
||||
func tutorialScopeVariableDeathBodyWithTail(objectKey uint32) []byte {
|
||||
return append(tutorialScopeVariableZeroCombatDeathBody(objectKey), 0xfa)
|
||||
}
|
||||
Reference in new issue
Block a user