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,249 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||||
)
|
||||
|
||||
const defaultNPCList = "npc/npc.lst"
|
||||
|
||||
func Load(ctx context.Context, source Source, options Options) (*Table, error) {
|
||||
if source == nil {
|
||||
return nil, ErrSourceRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
listPath := strings.TrimSpace(options.ListPath)
|
||||
if listPath == "" {
|
||||
listPath = DefaultList
|
||||
}
|
||||
listText, err := source.ReadText(listPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listDoc, err := dnfpvf.Parse(listPath, listText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := dnfpvf.ParseList(listDoc)
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
|
||||
}
|
||||
|
||||
table := &Table{towns: make(map[int64]Town, len(entries))}
|
||||
for _, entry := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
documentPath := townDocumentPath(listPath, entry.Path)
|
||||
text, err := source.ReadText(documentPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read town %d %s: %w", entry.ID, documentPath, err)
|
||||
}
|
||||
doc, err := dnfpvf.Parse(documentPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse town %d %s: %w", entry.ID, documentPath, err)
|
||||
}
|
||||
value, err := parseTown(entry.ID, documentPath, doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := table.towns[value.ID]; !exists {
|
||||
table.towns[value.ID] = value
|
||||
}
|
||||
}
|
||||
loadTownMapNPCs(ctx, source, table)
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func loadTownMapNPCs(ctx context.Context, source Source, table *Table) {
|
||||
if source == nil || table == nil || len(table.towns) == 0 {
|
||||
return
|
||||
}
|
||||
enterTargets := make(map[int64]struct{})
|
||||
exitTargets := make(map[int64]struct{})
|
||||
if listText, err := source.ReadText(defaultNPCList); err == nil {
|
||||
if listDoc, parseErr := dnfpvf.Parse(defaultNPCList, listText); parseErr == nil {
|
||||
for _, entry := range dnfpvf.ParseList(listDoc) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
documentPath := townDocumentPath(defaultNPCList, entry.Path)
|
||||
text, readErr := source.ReadText(documentPath)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
doc, documentErr := dnfpvf.Parse(documentPath, text)
|
||||
if documentErr != nil {
|
||||
continue
|
||||
}
|
||||
enterTarget, hasEnterTarget, hasExitRole := parseTownRoles(doc)
|
||||
if hasEnterTarget {
|
||||
enterTargets[enterTarget] = struct{}{}
|
||||
}
|
||||
if hasExitRole {
|
||||
exitTargets[entry.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for townID, value := range table.towns {
|
||||
for areaIndex := range value.Areas {
|
||||
area := &value.Areas[areaIndex]
|
||||
mapPath, text, readErr := readTownMapText(source, area.MapPath)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
doc, parseErr := dnfpvf.Parse(mapPath, text)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
area.QuestConditions = repeatedSectionInts(doc, "quest condition")
|
||||
mapValue := worldmap.ParseMap(0, mapPath, doc)
|
||||
if trigger, ok := seriaRoomPortalTrigger(doc); ok {
|
||||
if point, found := townPortalReturnPoint(trigger, mapValue.VirtualMovableArea); found {
|
||||
area.SeriaRoomReturn = &point
|
||||
}
|
||||
}
|
||||
// 婚房门外的安全落点(见 wedding_room_return.go)。与 SeriaRoomReturn
|
||||
// 同样只在装载期推导,找不到 101 传送门时保持 nil,对既有城镇零影响。
|
||||
if trigger, ok := weddingRoomPortalTrigger(doc); ok {
|
||||
if point, found := weddingRoomReturnPoint(trigger, mapValue.VirtualMovableArea); found {
|
||||
area.WeddingRoomReturn = &point
|
||||
}
|
||||
}
|
||||
area.NPCs = make([]NPCSpawn, 0, len(mapValue.NPCs))
|
||||
for _, spawn := range mapValue.NPCs {
|
||||
area.NPCs = append(area.NPCs, NPCSpawn{
|
||||
NPCID: spawn.NPCID, Direction: spawn.Direction,
|
||||
X: spawn.Position.X, Y: spawn.Position.Y, Z: spawn.Position.Z,
|
||||
})
|
||||
}
|
||||
area.MovableAreas = make([]MovableArea, 0, len(mapValue.TownMovableAreas))
|
||||
for _, movable := range mapValue.TownMovableAreas {
|
||||
area.MovableAreas = append(area.MovableAreas, MovableArea{
|
||||
X: movable.X, Y: movable.Y, Width: movable.Width, Height: movable.Height,
|
||||
})
|
||||
}
|
||||
seen := make(map[int64]struct{})
|
||||
for _, spawn := range mapValue.NPCs {
|
||||
if _, ok := enterTargets[spawn.NPCID]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := exitTargets[spawn.NPCID]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[spawn.NPCID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[spawn.NPCID] = struct{}{}
|
||||
area.TownExitTargetNPCIDs = append(area.TownExitTargetNPCIDs, spawn.NPCID)
|
||||
}
|
||||
sort.Slice(area.TownExitTargetNPCIDs, func(i, j int) bool {
|
||||
return area.TownExitTargetNPCIDs[i] < area.TownExitTargetNPCIDs[j]
|
||||
})
|
||||
}
|
||||
table.towns[townID] = value
|
||||
}
|
||||
}
|
||||
|
||||
func readTownMapText(source Source, referenced string) (string, string, error) {
|
||||
referenced = strings.TrimSpace(strings.ReplaceAll(referenced, "\\", "/"))
|
||||
candidates := []string{referenced}
|
||||
if !strings.HasPrefix(strings.ToLower(referenced), "map/") {
|
||||
candidates = append(candidates, path.Join("map", referenced))
|
||||
}
|
||||
// 城镇列表里仍然写着**原始**地图名,而实际只有修订版 `(r)` 存在 ——
|
||||
// 赛丽亚房间(城镇 38 区域 1)就是这种情况:`new_seria_room.map` 不存在,
|
||||
// 只有 `(r)new_seria_room.map`,而通往婚房(101/0)的那扇门只写在修订版里。
|
||||
//
|
||||
// 不加这条回落,该区域的 NPC / 移动区 / 门全为空,装载期就推导不出
|
||||
// WeddingRoomReturn,于是从婚房走左门出来会被判成"找不到门"而卡住出不去。
|
||||
//
|
||||
// 顺序是安全的:原图候选排在前面,只有原图缺失时才会用到 `(r)` 版。
|
||||
for _, candidate := range append([]string(nil), candidates...) {
|
||||
dir, base := path.Split(candidate)
|
||||
if base != "" && !strings.HasPrefix(strings.ToLower(base), "(r)") {
|
||||
candidates = append(candidates, dir+"(r)"+base)
|
||||
}
|
||||
}
|
||||
var lastErr error
|
||||
for _, candidate := range candidates {
|
||||
text, err := source.ReadText(candidate)
|
||||
if err == nil {
|
||||
return candidate, text, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", "", lastErr
|
||||
}
|
||||
|
||||
func parseTownRoles(doc *dnfpvf.Document) (enterTarget int64, hasEnterTarget, hasExitRole bool) {
|
||||
tokens, ok := doc.Section("role")
|
||||
if !ok {
|
||||
return 0, false, false
|
||||
}
|
||||
for index, token := range tokens {
|
||||
if token.Kind != dnfpvf.TokenString && token.Kind != dnfpvf.TokenIdent {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(token.Value)) {
|
||||
case "[enter town]":
|
||||
if index+1 < len(tokens) && tokens[index+1].Kind == dnfpvf.TokenInt && tokens[index+1].Int > 0 {
|
||||
enterTarget = tokens[index+1].Int
|
||||
hasEnterTarget = true
|
||||
}
|
||||
case "[exit town]":
|
||||
hasExitRole = true
|
||||
}
|
||||
}
|
||||
return enterTarget, hasEnterTarget, hasExitRole
|
||||
}
|
||||
|
||||
// repeatedSectionInts collects the positive integers of every section named
|
||||
// name, in document order and de-duplicated. Town maps declare one
|
||||
// `[quest condition]` block per gating quest rather than one block with a
|
||||
// list, so Document.Ints (first section only) would drop all but the first.
|
||||
func repeatedSectionInts(doc *dnfpvf.Document, name string) []int64 {
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
values := make([]int64, 0)
|
||||
seen := make(map[int64]struct{})
|
||||
for _, section := range doc.Sections {
|
||||
if strings.ToLower(strings.TrimSpace(section.Name)) != key ||
|
||||
section.Start < 0 || section.End > len(doc.Tokens) || section.Start > section.End {
|
||||
continue
|
||||
}
|
||||
for _, token := range doc.Tokens[section.Start:section.End] {
|
||||
if token.Kind != dnfpvf.TokenInt || token.Int <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[token.Int]; exists {
|
||||
continue
|
||||
}
|
||||
seen[token.Int] = struct{}{}
|
||||
values = append(values, token.Int)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func townDocumentPath(listPath, referenced string) string {
|
||||
referenced = strings.TrimSpace(strings.ReplaceAll(referenced, "\\", "/"))
|
||||
if strings.Contains(referenced, "/") {
|
||||
return path.Clean(referenced)
|
||||
}
|
||||
return path.Join(path.Dir(listPath), referenced)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type memorySource map[string]string
|
||||
|
||||
func (s memorySource) ReadText(name string) (string, error) {
|
||||
value, ok := s[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing %s", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func TestLoadTownAreasAndPermissions(t *testing.T) {
|
||||
townName := "\u827e\u5c14\u6587\u9632\u7ebf"
|
||||
source := memorySource{
|
||||
"town/town.lst": "38 `new_Elvengard.twn`",
|
||||
"town/new_Elvengard.twn": strings.Join([]string{
|
||||
"[area]",
|
||||
"0 `Cataclysm/Town/Elvengard/new_Elvengard.map`",
|
||||
"[permission]",
|
||||
"[need level]",
|
||||
"1",
|
||||
"[/permission]",
|
||||
"`[normal]`",
|
||||
"[/area]",
|
||||
"[area]",
|
||||
"1 `Cataclysm/Town/Elvengard/new_seria_room.map`",
|
||||
"[permission]",
|
||||
"[need level]",
|
||||
"1",
|
||||
"[/permission]",
|
||||
"`[gate]` 450 234",
|
||||
"[/area]",
|
||||
"[area]",
|
||||
"3 `Cataclysm/Town/Elvengard/Elvengard_hendon.map`",
|
||||
"[permission]",
|
||||
"[need level]",
|
||||
"12",
|
||||
"[need quest]",
|
||||
"3155",
|
||||
"[/need quest]",
|
||||
"[/permission]",
|
||||
"`[dungeon gate]` 2",
|
||||
"[/area]",
|
||||
"[name]",
|
||||
"`" + townName + "`",
|
||||
}, "\n"),
|
||||
}
|
||||
table, err := Load(context.Background(), source, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot := table.Snapshot(); snapshot.Towns != 1 || snapshot.Areas != 3 {
|
||||
t.Fatalf("snapshot=%+v", snapshot)
|
||||
}
|
||||
town, ok := table.Find(38)
|
||||
if !ok || town.Name != townName || len(town.Areas) != 3 {
|
||||
t.Fatalf("town=(%+v,%t)", town, ok)
|
||||
}
|
||||
area, ok := table.FindArea(38, 1)
|
||||
if !ok || area.MapPath != "Cataclysm/Town/Elvengard/new_seria_room.map" ||
|
||||
area.Kind != "gate" || area.Gate == nil || area.Gate.X != 450 || area.Gate.Y != 234 || area.MinLevel != 1 {
|
||||
t.Fatalf("seria area=(%+v,%t)", area, ok)
|
||||
}
|
||||
gateArea, ok := table.FindGateArea(38)
|
||||
if !ok || gateArea.ID != 1 || gateArea.MapPath != "Cataclysm/Town/Elvengard/new_seria_room.map" ||
|
||||
gateArea.Gate == nil || gateArea.Gate.X != 450 || gateArea.Gate.Y != 234 {
|
||||
t.Fatalf("gate area=(%+v,%t)", gateArea, ok)
|
||||
}
|
||||
gateArea.Gate.X = 999
|
||||
gateAgain, ok := table.FindGateArea(38)
|
||||
if !ok || gateAgain.Gate == nil || gateAgain.Gate.X != 450 {
|
||||
t.Fatalf("gate area clone leaked mutation=(%+v,%t)", gateAgain, ok)
|
||||
}
|
||||
area, ok = table.FindArea(38, 3)
|
||||
if !ok || area.MinLevel != 12 || len(area.NeedQuests) != 1 || area.NeedQuests[0] != 3155 ||
|
||||
area.DungeonGate == nil || *area.DungeonGate != 2 {
|
||||
t.Fatalf("quest area=(%+v,%t)", area, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBuildsPVFProvenNPCEnterTownRoute(t *testing.T) {
|
||||
source := memorySource{
|
||||
"town/town.lst": "107 `event.twn`",
|
||||
"town/event.twn": strings.Join([]string{
|
||||
"[area]",
|
||||
"0 `Town/Event/event.map` `[dungeon gate]` 114",
|
||||
"[/area]",
|
||||
}, "\n"),
|
||||
"npc/npc.lst": "20275 `entry.npc` 20276 `exit.npc` 20277 `unrelated.npc`",
|
||||
"npc/entry.npc": "[role]\n`[enter town]` 20276\n[/role]",
|
||||
"npc/exit.npc": "[role]\n`[exit town]` 0\n[/role]",
|
||||
"npc/unrelated.npc": "[role]\n`[exit town]` 0\n[/role]",
|
||||
"map/Town/Event/event.map": "[NPC]\n20276 `[left]` 100 200 0 20277 `[left]` 300 400 0\n[/NPC]",
|
||||
}
|
||||
table, err := Load(context.Background(), source, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !table.AllowsNPCEnterTown(107, 0, 20276) {
|
||||
t.Fatal("PVF [enter town] target with target-map [exit town] NPC was not indexed")
|
||||
}
|
||||
if table.AllowsNPCEnterTown(107, 0, 20277) || table.AllowsNPCEnterTown(107, 0, 99999) ||
|
||||
table.AllowsNPCEnterTown(107, 1, 20276) {
|
||||
t.Fatal("unproved NPC enter-town target was accepted")
|
||||
}
|
||||
area, ok := table.FindArea(107, 0)
|
||||
if !ok || len(area.NPCs) != 2 || area.NPCs[0].NPCID != 20276 || area.NPCs[0].X != 100 || area.NPCs[0].Y != 200 ||
|
||||
area.NPCs[1].NPCID != 20277 || area.NPCs[1].X != 300 || area.NPCs[1].Y != 400 {
|
||||
t.Fatalf("town NPC spawns=%+v found=%t", area.NPCs, ok)
|
||||
}
|
||||
area.NPCs[0].X = 999
|
||||
again, _ := table.FindArea(107, 0)
|
||||
if again.NPCs[0].X != 100 {
|
||||
t.Fatalf("town NPC clone leaked mutation: %+v", again.NPCs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// DefaultMovieListPath 是「开启城镇区域后自动播放的过场」表。
|
||||
//
|
||||
// 表结构(实测 etc/townmovielist.etc,13 条):
|
||||
//
|
||||
// [movie play]
|
||||
// [town index] 39 ← 城镇
|
||||
// [area index] 5 ← 区域
|
||||
// [play info]
|
||||
// [title] `诺斯玛尔`
|
||||
// [file path] `Video/movie/origin_cinematic_06.avi`
|
||||
// [/play info]
|
||||
// [/movie play]
|
||||
//
|
||||
// 这张表只回答「播哪段影片」;「什么时候算开启」由各城镇 .twn 里每个 area 的
|
||||
// [permission] 决定(NeedLevel + NeedQuests),也就是 Area.MinLevel / Area.NeedQuests。
|
||||
// 两者合起来才是完整规则:完成某区域的 NeedQuests 任务且达到 NeedLevel 时,
|
||||
// 该区域开启,并播放该区域的影片。
|
||||
const DefaultMovieListPath = "etc/townmovielist.etc"
|
||||
|
||||
// TownMoviePlay 是 [movie play] 的一条记录。
|
||||
type TownMoviePlay struct {
|
||||
TownID int64 `json:"town_id"`
|
||||
AreaID int64 `json:"area_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
// TownMovieTable 按 (town, area) 索引过场表。
|
||||
type TownMovieTable struct {
|
||||
plays map[[2]int64]TownMoviePlay
|
||||
}
|
||||
|
||||
// Find 返回某个城镇区域开启后应播放的过场。
|
||||
func (t *TownMovieTable) Find(townID, areaID int64) (TownMoviePlay, bool) {
|
||||
if t == nil {
|
||||
return TownMoviePlay{}, false
|
||||
}
|
||||
play, ok := t.plays[[2]int64{townID, areaID}]
|
||||
return play, ok
|
||||
}
|
||||
|
||||
// Plays 按 (town, area) 稳定顺序返回全部条目。
|
||||
func (t *TownMovieTable) Plays() []TownMoviePlay {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]TownMoviePlay, 0, len(t.plays))
|
||||
for _, play := range t.plays {
|
||||
out = append(out, play)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].TownID != out[j].TownID {
|
||||
return out[i].TownID < out[j].TownID
|
||||
}
|
||||
return out[i].AreaID < out[j].AreaID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// MovieForQuest 返回「该任务开启的、且带过场」的城镇区域。
|
||||
//
|
||||
// 这是本功能的判定核心:给定一个刚完成的任务与角色等级,找出它开启了哪个
|
||||
// 带过场的城镇区域。同一个任务通常只开一个区域;若命中多个,取 area 序号最小的,
|
||||
// 保证一次只播一段。
|
||||
//
|
||||
// 需要同时满足(与客户端读 .twn 的口径一致):
|
||||
// - 区域声明了 [need quest],且其中包含该任务
|
||||
// - 角色等级 >= 区域的 [need level](0 表示不限)
|
||||
// - 该区域在过场表里有影片
|
||||
func (t *TownMovieTable) MovieForQuest(
|
||||
table *Table,
|
||||
townID int64,
|
||||
questID int64,
|
||||
characterLevel int64,
|
||||
) (Area, TownMoviePlay, bool) {
|
||||
if t == nil || table == nil || questID <= 0 {
|
||||
return Area{}, TownMoviePlay{}, false
|
||||
}
|
||||
town, found := table.Find(townID)
|
||||
if !found {
|
||||
return Area{}, TownMoviePlay{}, false
|
||||
}
|
||||
var (
|
||||
bestArea Area
|
||||
bestMovie TownMoviePlay
|
||||
bestSet bool
|
||||
)
|
||||
for _, area := range town.Areas {
|
||||
if len(area.NeedQuests) == 0 {
|
||||
continue
|
||||
}
|
||||
if area.MinLevel > 0 && characterLevel < area.MinLevel {
|
||||
continue
|
||||
}
|
||||
if !int64SliceContains(area.NeedQuests, questID) {
|
||||
continue
|
||||
}
|
||||
movie, ok := t.Find(townID, area.ID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !bestSet || area.ID < bestArea.ID {
|
||||
bestArea, bestMovie, bestSet = area, movie, true
|
||||
}
|
||||
}
|
||||
return bestArea, bestMovie, bestSet
|
||||
}
|
||||
|
||||
// MovieForQuestAnywhere 在**所有**城镇里查找该任务开启的带过场区域。
|
||||
// 任务完成时玩家可能不在目标城镇(例如在副本结算页),因此需要在全表范围判定。
|
||||
func (t *TownMovieTable) MovieForQuestAnywhere(
|
||||
table *Table,
|
||||
questID int64,
|
||||
characterLevel int64,
|
||||
) (int64, Area, TownMoviePlay, bool) {
|
||||
if t == nil || table == nil || questID <= 0 {
|
||||
return 0, Area{}, TownMoviePlay{}, false
|
||||
}
|
||||
var (
|
||||
bestTown int64
|
||||
bestArea Area
|
||||
bestMovie TownMoviePlay
|
||||
bestSet bool
|
||||
)
|
||||
for townID := range table.towns {
|
||||
area, movie, ok := t.MovieForQuest(table, townID, questID, characterLevel)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !bestSet || townID < bestTown || (townID == bestTown && area.ID < bestArea.ID) {
|
||||
bestTown, bestArea, bestMovie, bestSet = townID, area, movie, true
|
||||
}
|
||||
}
|
||||
return bestTown, bestArea, bestMovie, bestSet
|
||||
}
|
||||
|
||||
func int64SliceContains(values []int64, want int64) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LoadTownMovieTable 读取 etc/townmovielist.etc。
|
||||
//
|
||||
// 文件缺失不算错误:部分 profile 不带这张表,此时没有任何区域会播过场。
|
||||
func LoadTownMovieTable(source Source, listPath string) (*TownMovieTable, error) {
|
||||
if source == nil {
|
||||
return nil, ErrSourceRequired
|
||||
}
|
||||
if strings.TrimSpace(listPath) == "" {
|
||||
listPath = DefaultMovieListPath
|
||||
}
|
||||
text, err := source.ReadText(listPath)
|
||||
if err != nil {
|
||||
return &TownMovieTable{plays: map[[2]int64]TownMoviePlay{}}, nil
|
||||
}
|
||||
doc, err := dnfpvf.Parse(listPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse town movie list %s: %w", listPath, err)
|
||||
}
|
||||
return parseTownMovieTable(doc)
|
||||
}
|
||||
|
||||
func parseTownMovieTable(doc *dnfpvf.Document) (*TownMovieTable, error) {
|
||||
table := &TownMovieTable{plays: make(map[[2]int64]TownMoviePlay)}
|
||||
if doc == nil {
|
||||
return table, nil
|
||||
}
|
||||
tokens := doc.Tokens
|
||||
for index := 0; index < len(tokens); index++ {
|
||||
token := tokens[index]
|
||||
if token.Kind != dnfpvf.TokenSection || sectionName(token.Value) != "movie play" {
|
||||
continue
|
||||
}
|
||||
end := townMovieUnitEnd(tokens, index+1)
|
||||
play, include, err := parseTownMoviePlay(tokens[index+1 : end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if include {
|
||||
table.plays[[2]int64{play.TownID, play.AreaID}] = play
|
||||
}
|
||||
index = end
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func townMovieUnitEnd(tokens []dnfpvf.Token, start int) int {
|
||||
for idx := start; idx < len(tokens); idx++ {
|
||||
if tokens[idx].Kind == dnfpvf.TokenSection && sectionName(tokens[idx].Value) == "/movie play" {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return len(tokens)
|
||||
}
|
||||
|
||||
func parseTownMoviePlay(tokens []dnfpvf.Token) (TownMoviePlay, bool, error) {
|
||||
var (
|
||||
play TownMoviePlay
|
||||
townOK bool
|
||||
areaOK bool
|
||||
pathOK bool
|
||||
)
|
||||
for idx := 0; idx < len(tokens); idx++ {
|
||||
token := tokens[idx]
|
||||
if token.Kind != dnfpvf.TokenSection {
|
||||
continue
|
||||
}
|
||||
switch sectionName(token.Value) {
|
||||
case "town index":
|
||||
if value, ok := nextInt(tokens, idx+1); ok {
|
||||
play.TownID, townOK = value, true
|
||||
}
|
||||
case "area index":
|
||||
if value, ok := nextInt(tokens, idx+1); ok {
|
||||
play.AreaID, areaOK = value, true
|
||||
}
|
||||
case "title":
|
||||
if value, ok := nextText(tokens, idx+1); ok {
|
||||
play.Title = value
|
||||
}
|
||||
case "file path":
|
||||
if value, ok := nextText(tokens, idx+1); ok {
|
||||
play.FilePath = strings.TrimSpace(value)
|
||||
pathOK = play.FilePath != ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if !townOK || !areaOK || play.TownID < 0 || play.AreaID < 0 {
|
||||
return TownMoviePlay{}, false, fmt.Errorf("invalid town movie identity %d/%d", play.TownID, play.AreaID)
|
||||
}
|
||||
if !pathOK {
|
||||
// 没有影片路径的占位条目不参与播放。
|
||||
return TownMoviePlay{}, false, nil
|
||||
}
|
||||
return play, true, nil
|
||||
}
|
||||
|
||||
// nextText 返回 start 之后第一个字符串/标识值,遇到下一个段头即停。
|
||||
func nextText(tokens []dnfpvf.Token, start int) (string, bool) {
|
||||
for idx := start; idx < len(tokens); idx++ {
|
||||
if tokens[idx].Kind == dnfpvf.TokenSection {
|
||||
return "", false
|
||||
}
|
||||
if tokens[idx].Kind == dnfpvf.TokenString || tokens[idx].Kind == dnfpvf.TokenIdent {
|
||||
return strings.TrimSpace(tokens[idx].Value), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// TestRealScriptPVFTownMovieListMatchesAreaPermissions 校验三张表在真实 PVF 里咬合:
|
||||
//
|
||||
// - etc/townmovielist.etc 给出 (town, area) → 影片
|
||||
// - 各城镇 .twn 里该 area 的 [permission] 给出 [need level] + [need quest]
|
||||
//
|
||||
// 本功能的规则就是这两者合起来:完成 NeedQuests 且达到 NeedLevel → 区域开启 → 播影片。
|
||||
// 因此每条过场都必须能反查到它的开区条件,否则「开启」与「播过场」会错位。
|
||||
func TestRealScriptPVFTownMovieListMatchesAreaPermissions(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the town movie list")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatalf("load real Script.pvf: %v", err)
|
||||
}
|
||||
towns, err := Load(context.Background(), archive, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("load real town catalog: %v", err)
|
||||
}
|
||||
movies, err := LoadTownMovieTable(archive, DefaultMovieListPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load town movie list: %v", err)
|
||||
}
|
||||
plays := movies.Plays()
|
||||
if len(plays) != 13 {
|
||||
t.Fatalf("movie plays=%d, want 13", len(plays))
|
||||
}
|
||||
|
||||
// 每条过场都必须能对上一个带 [need quest] 的城镇区域。
|
||||
for _, play := range plays {
|
||||
if play.TownID <= 0 || play.FilePath == "" {
|
||||
t.Errorf("invalid movie play: %+v", play)
|
||||
continue
|
||||
}
|
||||
area, found := towns.FindArea(play.TownID, play.AreaID)
|
||||
if !found {
|
||||
t.Errorf("town=%d area=%d has a movie but no area row", play.TownID, play.AreaID)
|
||||
continue
|
||||
}
|
||||
if len(area.NeedQuests) == 0 {
|
||||
t.Errorf("town=%d area=%d movie=%s but the area declares no [need quest]",
|
||||
play.TownID, play.AreaID, play.FilePath)
|
||||
continue
|
||||
}
|
||||
// 反向:用它的开区任务回查,必须能找回同一格。
|
||||
for _, questID := range area.NeedQuests {
|
||||
gotTown, gotArea, gotMovie, ok := movies.MovieForQuestAnywhere(towns, questID, area.MinLevel)
|
||||
if !ok {
|
||||
t.Errorf("quest=%d (town=%d area=%d) resolves to no cutscene area",
|
||||
questID, play.TownID, play.AreaID)
|
||||
continue
|
||||
}
|
||||
if gotMovie.FilePath != play.FilePath {
|
||||
t.Errorf("quest=%d -> %s, want %s", questID, gotMovie.FilePath, play.FilePath)
|
||||
}
|
||||
if gotTown != play.TownID || gotArea.ID != play.AreaID {
|
||||
t.Errorf("quest=%d -> town=%d area=%d, want town=%d area=%d",
|
||||
questID, gotTown, gotArea.ID, play.TownID, play.AreaID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 钉住一个具体样例,防止整张表被换掉而测试仍然通过。
|
||||
area, found := towns.FindArea(39, 5)
|
||||
if !found {
|
||||
t.Fatal("town=39 area=5 is missing from the town catalog")
|
||||
}
|
||||
if area.MinLevel != 46 || len(area.NeedQuests) != 1 || area.NeedQuests[0] != 3332 {
|
||||
t.Fatalf("town39 area5 permission=(level %d, quests %v), want level 46 quest [3332]",
|
||||
area.MinLevel, area.NeedQuests)
|
||||
}
|
||||
_, gotArea, gotMovie, ok := movies.MovieForQuestAnywhere(towns, 3332, 46)
|
||||
if !ok || gotArea.ID != 5 || gotMovie.FilePath != "Video/movie/origin_cinematic_06.avi" {
|
||||
t.Fatalf("quest 3332 at level 46 -> area %d movie %q ok=%t, want area 5 origin_cinematic_06",
|
||||
gotArea.ID, gotMovie.FilePath, ok)
|
||||
}
|
||||
// 等级不足时不得开区。
|
||||
if _, _, _, ok := movies.MovieForQuestAnywhere(towns, 3332, 45); ok {
|
||||
t.Fatal("quest 3332 at level 45 must not open town39 area5")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("town owner is unavailable")
|
||||
ErrCharacterRequired = errors.New("town character id is required")
|
||||
ErrProjectorRequired = errors.New("town character projector is required")
|
||||
ErrCharacterNotFound = errors.New("town character is missing")
|
||||
ErrAccountMismatch = errors.New("town character does not belong to account")
|
||||
)
|
||||
|
||||
type CharacterProjector func(*dnfrepo.CharacterRecord) (bool, error)
|
||||
|
||||
type LoginLocationCommand struct {
|
||||
AccountID string
|
||||
CharacterID string
|
||||
UpdatedAt time.Time
|
||||
Project CharacterProjector
|
||||
}
|
||||
|
||||
type LoginLocationResult struct {
|
||||
Character dnfrepo.CharacterRecord
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// Owner owns durable town-location changes. Runtime-PVF route resolution,
|
||||
// session snapshots, current-client packets, and transition ordering remain in
|
||||
// the bridge.
|
||||
type Owner struct {
|
||||
assets dnfrepo.CharacterAssetUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.CharacterAssets == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{assets: repositories.CharacterAssets}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) ApplyLoginLocation(ctx context.Context, command LoginLocationCommand) (LoginLocationResult, error) {
|
||||
if o == nil || o.assets == nil {
|
||||
return LoginLocationResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return LoginLocationResult{}, ErrCharacterRequired
|
||||
}
|
||||
if command.Project == nil {
|
||||
return LoginLocationResult{}, ErrProjectorRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return LoginLocationResult{}, err
|
||||
}
|
||||
now := command.UpdatedAt.UTC()
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
accountID := strings.TrimSpace(command.AccountID)
|
||||
|
||||
var result LoginLocationResult
|
||||
err := o.assets.WithinCharacterAssets(ctx, characterID, func(
|
||||
characters dnfrepo.CharacterRepository,
|
||||
_ dnfrepo.InventoryRepository,
|
||||
_ dnfrepo.EquipmentRepository,
|
||||
) error {
|
||||
if characters == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
character, found, err := characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load town character: %w", err)
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
|
||||
}
|
||||
if accountID != "" && strings.TrimSpace(character.AccountID) != accountID {
|
||||
return fmt.Errorf("%w: account=%s character=%s", ErrAccountMismatch, accountID, characterID)
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
changed, err := command.Project(&character)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result = LoginLocationResult{Character: character, Changed: changed}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
character.UpdatedAt = now
|
||||
result.Character.UpdatedAt = now
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return fmt.Errorf("save town character location: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerApplyLoginLocationCommitsProjectedStats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := seededTownOwnerRepositories(t, ctx)
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(2_000_000_000, 0).UTC()
|
||||
result, err := owner.ApplyLoginLocation(ctx, LoginLocationCommand{
|
||||
AccountID: "account-1",
|
||||
CharacterID: "19",
|
||||
UpdatedAt: now,
|
||||
Project: func(character *dnfrepo.CharacterRecord) (bool, error) {
|
||||
character.Stats["town_id"] = 38
|
||||
character.Stats["area_id"] = 1
|
||||
return true, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Changed || result.Character.Stats["town_id"] != 38 || !result.Character.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
stored, found, err := repositories.Character.Load(ctx, "19")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load found=%t err=%v", found, err)
|
||||
}
|
||||
if stored.Stats["town_id"] != 38 || stored.Stats["area_id"] != 1 || !stored.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("stored=%+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyLoginLocationRollsBackProjectorFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := seededTownOwnerRepositories(t, ctx)
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectErr := errors.New("projection failed")
|
||||
_, err = owner.ApplyLoginLocation(ctx, LoginLocationCommand{
|
||||
AccountID: "account-1",
|
||||
CharacterID: "19",
|
||||
Project: func(character *dnfrepo.CharacterRecord) (bool, error) {
|
||||
character.Stats["town_id"] = 38
|
||||
return true, projectErr
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, projectErr) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
stored, _, _ := repositories.Character.Load(ctx, "19")
|
||||
if stored.Stats["town_id"] != 39 {
|
||||
t.Fatalf("projector failure committed: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyLoginLocationRejectsCrossAccountCharacter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := seededTownOwnerRepositories(t, ctx)
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projected := false
|
||||
_, err = owner.ApplyLoginLocation(ctx, LoginLocationCommand{
|
||||
AccountID: "account-2",
|
||||
CharacterID: "19",
|
||||
Project: func(*dnfrepo.CharacterRecord) (bool, error) {
|
||||
projected = true
|
||||
return false, nil
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrAccountMismatch) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if projected {
|
||||
t.Fatal("cross-account command reached projector")
|
||||
}
|
||||
}
|
||||
|
||||
func seededTownOwnerRepositories(t *testing.T, ctx context.Context) dnfrepo.Group {
|
||||
t.Helper()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
AccountID: "account-1",
|
||||
CharacterID: "19",
|
||||
Stats: map[string]int64{
|
||||
"town_id": 39,
|
||||
"area_id": 0,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repositories
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
func parseTown(townID int64, documentPath string, doc *dnfpvf.Document) (Town, error) {
|
||||
if doc == nil || len(doc.Tokens) == 0 {
|
||||
return Town{}, fmt.Errorf("%w: %s", ErrDocumentEmpty, documentPath)
|
||||
}
|
||||
value := Town{ID: townID, Path: documentPath}
|
||||
if name, ok := doc.Text("name"); ok {
|
||||
value.Name = strings.TrimSpace(name)
|
||||
}
|
||||
for idx := 0; idx < len(doc.Tokens); idx++ {
|
||||
token := doc.Tokens[idx]
|
||||
if token.Kind != dnfpvf.TokenSection || sectionName(token.Value) != "area" {
|
||||
continue
|
||||
}
|
||||
end := areaEnd(doc.Tokens, idx+1)
|
||||
area, ok := parseArea(doc.Tokens[idx+1 : end])
|
||||
if ok {
|
||||
value.Areas = append(value.Areas, area)
|
||||
}
|
||||
idx = end
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseArea(tokens []dnfpvf.Token) (Area, bool) {
|
||||
if len(tokens) < 2 || tokens[0].Kind != dnfpvf.TokenInt ||
|
||||
(tokens[1].Kind != dnfpvf.TokenString && tokens[1].Kind != dnfpvf.TokenIdent) {
|
||||
return Area{}, false
|
||||
}
|
||||
area := Area{ID: tokens[0].Int, MapPath: strings.TrimSpace(strings.ReplaceAll(tokens[1].Value, "\\", "/"))}
|
||||
for idx := 2; idx < len(tokens); idx++ {
|
||||
token := tokens[idx]
|
||||
if token.Kind == dnfpvf.TokenSection {
|
||||
switch sectionName(token.Value) {
|
||||
case "need level":
|
||||
if value, ok := nextInt(tokens, idx+1); ok {
|
||||
area.MinLevel = value
|
||||
}
|
||||
case "need quest":
|
||||
area.NeedQuests = append(area.NeedQuests, intsUntilSection(tokens, idx+1)...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if token.Kind != dnfpvf.TokenString && token.Kind != dnfpvf.TokenIdent {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(token.Value)) {
|
||||
case "[normal]":
|
||||
area.Kind = "normal"
|
||||
case "[gate]":
|
||||
values := followingInts(tokens, idx+1, 2)
|
||||
if len(values) == 2 {
|
||||
area.Kind = "gate"
|
||||
area.Gate = &Gate{X: values[0], Y: values[1]}
|
||||
}
|
||||
case "[dungeon gate]":
|
||||
values := followingInts(tokens, idx+1, 1)
|
||||
if len(values) == 1 {
|
||||
area.Kind = "dungeon_gate"
|
||||
area.DungeonGate = &values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return area, area.MapPath != ""
|
||||
}
|
||||
|
||||
func areaEnd(tokens []dnfpvf.Token, start int) int {
|
||||
for idx := start; idx < len(tokens); idx++ {
|
||||
if tokens[idx].Kind == dnfpvf.TokenSection && sectionName(tokens[idx].Value) == "/area" {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return len(tokens)
|
||||
}
|
||||
|
||||
func sectionName(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func nextInt(tokens []dnfpvf.Token, start int) (int64, bool) {
|
||||
for idx := start; idx < len(tokens); idx++ {
|
||||
if tokens[idx].Kind == dnfpvf.TokenSection {
|
||||
return 0, false
|
||||
}
|
||||
if tokens[idx].Kind == dnfpvf.TokenInt {
|
||||
return tokens[idx].Int, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func intsUntilSection(tokens []dnfpvf.Token, start int) []int64 {
|
||||
values := make([]int64, 0, 1)
|
||||
for idx := start; idx < len(tokens); idx++ {
|
||||
if tokens[idx].Kind == dnfpvf.TokenSection {
|
||||
break
|
||||
}
|
||||
if tokens[idx].Kind == dnfpvf.TokenInt {
|
||||
values = append(values, tokens[idx].Int)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func followingInts(tokens []dnfpvf.Token, start, count int) []int64 {
|
||||
values := make([]int64, 0, count)
|
||||
for idx := start; idx < len(tokens) && len(values) < count; idx++ {
|
||||
if tokens[idx].Kind == dnfpvf.TokenSection {
|
||||
break
|
||||
}
|
||||
if tokens[idx].Kind == dnfpvf.TokenInt {
|
||||
values = append(values, tokens[idx].Int)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// Runtime PVF: HendonMyre's main map (town 39 area 0) gates the training-room
|
||||
// passage on quest 10100 through a [quest condition] block. This is the shape
|
||||
// the quest-finish scene refresh keys on, so the loader must surface it.
|
||||
func TestRealScriptPVFHendonMyreDeclaresTrainingRoomQuestCondition(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify town map quest conditions")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatalf("load real Script.pvf: %v", err)
|
||||
}
|
||||
table, err := Load(context.Background(), archive, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("load real town catalog: %v", err)
|
||||
}
|
||||
area, ok := table.FindArea(39, 0)
|
||||
if !ok || area.MapPath != "Cataclysm/Town/HendonMyre/new_Hendon_Main.map" {
|
||||
t.Fatalf("town 39 area 0=(%+v,%t)", area, ok)
|
||||
}
|
||||
found := false
|
||||
for _, questID := range area.QuestConditions {
|
||||
if questID == 10100 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("town 39 area 0 quest conditions=%v, want 10100 (training room gate)", area.QuestConditions)
|
||||
}
|
||||
// At least one other town area must declare none, proving the field is not
|
||||
// populated by accident from unrelated sections.
|
||||
plainSeen := false
|
||||
for _, town := range table.Towns() {
|
||||
for _, candidate := range town.Areas {
|
||||
if len(candidate.QuestConditions) == 0 {
|
||||
plainSeen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if plainSeen {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !plainSeen {
|
||||
t.Fatal("every town area declares quest conditions; the loader is not reading the [quest condition] section selectively")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type questConditionTestSource map[string]string
|
||||
|
||||
func (source questConditionTestSource) ReadText(name string) (string, error) {
|
||||
value, ok := source[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing %s", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Town maps declare one [quest condition] block per gating quest. The loader
|
||||
// must gather every block, in order, without duplicates, and an area whose map
|
||||
// declares none must carry nil rather than an empty slice.
|
||||
func TestLoadCollectsRepeatedQuestConditionBlocksPerArea(t *testing.T) {
|
||||
table, err := Load(context.Background(), questConditionTestSource{
|
||||
"town/town.lst": "39 `hendon.twn` 40 `plain.twn`",
|
||||
"town/hendon.twn": "[area]\n0 `hendon_main.map` `[normal]`\n[/area]\n[name]\n`Hendon`",
|
||||
"town/plain.twn": "[area]\n0 `plain.map` `[normal]`\n[/area]\n[name]\n`Plain`",
|
||||
"map/hendon_main.map": "[quest condition]\n3186\n[/quest condition]\n" +
|
||||
"[quest condition]\n10100\n[/quest condition]\n" +
|
||||
"[quest condition]\n10100\n[/quest condition]\n",
|
||||
"map/plain.map": "[name]\n`plain`\n",
|
||||
}, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
area, ok := table.FindArea(39, 0)
|
||||
if !ok || !reflect.DeepEqual(area.QuestConditions, []int64{3186, 10100}) {
|
||||
t.Fatalf("hendon area=(%+v,%t), want quest conditions [3186 10100]", area, ok)
|
||||
}
|
||||
plain, ok := table.FindArea(40, 0)
|
||||
if !ok || plain.QuestConditions != nil {
|
||||
t.Fatalf("plain area=(%+v,%t), want no quest conditions", plain, ok)
|
||||
}
|
||||
// A caller mutating the returned slice must not reach into the table.
|
||||
area.QuestConditions[0] = 999
|
||||
again, _ := table.FindArea(39, 0)
|
||||
if !reflect.DeepEqual(again.QuestConditions, []int64{3186, 10100}) {
|
||||
t.Fatalf("table quest conditions were mutated through a lookup result: %v", again.QuestConditions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestRealScriptPVFElvengardSeriaRoom(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the current town catalog")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatalf("load real Script.pvf: %v", err)
|
||||
}
|
||||
table, err := Load(context.Background(), archive, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("load real town catalog: %v", err)
|
||||
}
|
||||
town, ok := table.Find(38)
|
||||
if !ok || town.Name != "\u827e\u5c14\u6587\u9632\u7ebf" {
|
||||
t.Fatalf("town 38=(%+v,%t)", town, ok)
|
||||
}
|
||||
area, ok := table.FindArea(38, 1)
|
||||
if !ok || area.MapPath != "Cataclysm/Town/Elvengard/new_seria_room.map" ||
|
||||
area.Gate == nil || area.Gate.X != 450 || area.Gate.Y != 234 {
|
||||
t.Fatalf("town 38 area 1=(%+v,%t)", area, ok)
|
||||
}
|
||||
gateArea, ok := table.FindGateArea(38)
|
||||
if !ok || gateArea.ID != 1 || gateArea.MapPath != area.MapPath ||
|
||||
gateArea.Gate == nil || gateArea.Gate.X != 450 || gateArea.Gate.Y != 234 {
|
||||
t.Fatalf("town 38 gate area=(%+v,%t)", gateArea, ok)
|
||||
}
|
||||
if !table.AllowsNPCEnterTown(107, 0, 20276) {
|
||||
t.Fatal("current PVF did not index town 107 area 0 NPC-enter target 20276")
|
||||
}
|
||||
skyTower, ok := table.FindArea(40, 3)
|
||||
if !ok || skyTower.MapPath != "Cataclysm/Town/west_coast/WestCoast_D.map" ||
|
||||
skyTower.MinLevel != 17 || len(skyTower.NeedQuests) != 1 || skyTower.NeedQuests[0] != 3161 {
|
||||
t.Fatalf("town 40 area 3 Sky Tower gate=(%+v,%t)", skyTower, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// The private Seria room every town portals into.
|
||||
const (
|
||||
sharedSeriaRoomTownID = 38
|
||||
sharedSeriaRoomAreaID = 1
|
||||
)
|
||||
|
||||
// townPortalReturnMargin is how far outside the portal trigger rectangle the
|
||||
// landing point is placed. The trigger is a thin doorway strip; 32 units clears
|
||||
// it without leaving the walkable band around it.
|
||||
const townPortalReturnMargin int64 = 32
|
||||
|
||||
type townMovableArea struct {
|
||||
x, y, width, height int64
|
||||
targetTown int64
|
||||
targetArea int64
|
||||
}
|
||||
|
||||
// seriaRoomPortalTrigger finds the [town movable area] row that moves into the
|
||||
// shared Seria room. Current maps author it in the flat six-int form; older
|
||||
// maps nest it as [town movable area with animation] > [movable area] >
|
||||
// [area] x y w h / [move town] town area, which the flat section reader does
|
||||
// not see, so the nested form is walked token by token.
|
||||
func seriaRoomPortalTrigger(doc *dnfpvf.Document) (townMovableArea, bool) {
|
||||
if doc == nil {
|
||||
return townMovableArea{}, false
|
||||
}
|
||||
if values, ok := sectionInts(doc, "town movable area"); ok {
|
||||
for offset := 0; offset+5 < len(values); offset += 6 {
|
||||
candidate := townMovableArea{
|
||||
x: values[offset],
|
||||
y: values[offset+1],
|
||||
width: values[offset+2],
|
||||
height: values[offset+3],
|
||||
targetTown: values[offset+4],
|
||||
targetArea: values[offset+5],
|
||||
}
|
||||
if candidate.width > 0 && candidate.height > 0 &&
|
||||
candidate.targetTown == sharedSeriaRoomTownID &&
|
||||
candidate.targetArea == sharedSeriaRoomAreaID {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
insideMovableArea := false
|
||||
candidate := townMovableArea{}
|
||||
hasArea := false
|
||||
for idx, token := range doc.Tokens {
|
||||
if token.Kind != dnfpvf.TokenSection {
|
||||
continue
|
||||
}
|
||||
switch sectionName(token.Value) {
|
||||
case "movable area":
|
||||
insideMovableArea = true
|
||||
candidate = townMovableArea{}
|
||||
hasArea = false
|
||||
case "/movable area":
|
||||
insideMovableArea = false
|
||||
candidate = townMovableArea{}
|
||||
hasArea = false
|
||||
case "area":
|
||||
if !insideMovableArea {
|
||||
continue
|
||||
}
|
||||
values := followingInts(doc.Tokens, idx+1, 4)
|
||||
if len(values) == 4 && values[2] > 0 && values[3] > 0 {
|
||||
candidate.x = values[0]
|
||||
candidate.y = values[1]
|
||||
candidate.width = values[2]
|
||||
candidate.height = values[3]
|
||||
hasArea = true
|
||||
}
|
||||
case "move town":
|
||||
if !insideMovableArea || !hasArea {
|
||||
continue
|
||||
}
|
||||
values := followingInts(doc.Tokens, idx+1, 2)
|
||||
if len(values) == 2 && values[0] == sharedSeriaRoomTownID && values[1] == sharedSeriaRoomAreaID {
|
||||
candidate.targetTown = values[0]
|
||||
candidate.targetArea = values[1]
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return townMovableArea{}, false
|
||||
}
|
||||
|
||||
// townPortalReturnPoint picks the nearest point outside the trigger rectangle
|
||||
// that still lies inside one of the map's [virtual movable area] rectangles,
|
||||
// trying below, above, right, then left of the doorway.
|
||||
func townPortalReturnPoint(trigger townMovableArea, virtual []int64) (Gate, bool) {
|
||||
if trigger.width <= 0 || trigger.height <= 0 {
|
||||
return Gate{}, false
|
||||
}
|
||||
centerX := trigger.x + trigger.width/2
|
||||
centerY := trigger.y + trigger.height/2
|
||||
candidates := []Gate{
|
||||
{X: centerX, Y: trigger.y + trigger.height + townPortalReturnMargin},
|
||||
{X: centerX, Y: trigger.y - townPortalReturnMargin},
|
||||
{X: trigger.x + trigger.width + townPortalReturnMargin, Y: centerY},
|
||||
{X: trigger.x - townPortalReturnMargin, Y: centerY},
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if pointInsideAnyMovableArea(candidate, virtual) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
// Older maps may omit [virtual movable area]. Their Seria portals use a
|
||||
// horizontal doorway, so the first safe point below the trigger is the
|
||||
// closest non-overlapping fallback.
|
||||
if len(virtual) < 4 {
|
||||
return candidates[0], true
|
||||
}
|
||||
return Gate{}, false
|
||||
}
|
||||
|
||||
func pointInsideAnyMovableArea(point Gate, virtual []int64) bool {
|
||||
for offset := 0; offset+3 < len(virtual); offset += 4 {
|
||||
x, y := virtual[offset], virtual[offset+1]
|
||||
width, height := virtual[offset+2], virtual[offset+3]
|
||||
if width <= 0 || height <= 0 {
|
||||
continue
|
||||
}
|
||||
if point.X >= x && point.X <= x+width && point.Y >= y && point.Y <= y+height {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sectionInts returns the integer tokens of the first section named name.
|
||||
func sectionInts(doc *dnfpvf.Document, name string) ([]int64, bool) {
|
||||
tokens, ok := doc.Section(name)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
values := make([]int64, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if token.Kind == dnfpvf.TokenInt {
|
||||
values = append(values, token.Int)
|
||||
}
|
||||
}
|
||||
return values, len(values) > 0
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// Elvengard authors its Seria portal in the nested "with animation" form; the
|
||||
// landing point must be the doorway centre pushed 32 units below the trigger
|
||||
// and inside the virtual movable band. Mutating a lookup result must not leak
|
||||
// back into the table.
|
||||
func TestLoadDerivesSeriaRoomReturnPointFromNestedPortal(t *testing.T) {
|
||||
table, err := Load(context.Background(), questConditionTestSource{
|
||||
"town/town.lst": "38 `new_Elvengard.twn`",
|
||||
"town/new_Elvengard.twn": "[area]\n0 `Cataclysm/Town/Elvengard/new_Elvengard.map` `[normal]`\n[/area]\n[area]\n1 `new_seria_room.map` `[gate]` 450 234\n[/area]\n[name]\n`Elvengard`",
|
||||
"map/Cataclysm/Town/Elvengard/new_Elvengard.map": strings.Join([]string{
|
||||
"[town movable area with animation]",
|
||||
"[movable area]",
|
||||
"[area]",
|
||||
"600 150 100 30",
|
||||
"[move town]",
|
||||
"38 1",
|
||||
"[/movable area]",
|
||||
"[/town movable area with animation]",
|
||||
"[virtual movable area]",
|
||||
"0 200 1000 200",
|
||||
}, "\n"),
|
||||
}, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
area, ok := table.FindSeriaRoomReturnArea(38)
|
||||
if !ok || area.ID != 0 || area.SeriaRoomReturn == nil || area.SeriaRoomReturn.X != 650 || area.SeriaRoomReturn.Y != 212 {
|
||||
t.Fatalf("Seria return area=(%+v,%t), want area 0 at 650/212", area, ok)
|
||||
}
|
||||
area.SeriaRoomReturn.X = 999
|
||||
again, ok := table.FindSeriaRoomReturnArea(38)
|
||||
if !ok || again.SeriaRoomReturn == nil || again.SeriaRoomReturn.X != 650 {
|
||||
t.Fatalf("Seria return clone leaked mutation=(%+v,%t)", again, ok)
|
||||
}
|
||||
// The Seria room itself declares no portal back into itself.
|
||||
room, ok := table.FindArea(38, 1)
|
||||
if !ok || room.SeriaRoomReturn != nil {
|
||||
t.Fatalf("Seria room area=(%+v,%t), want no return point", room, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// The flat six-int [town movable area] form and the doorway-side fallback
|
||||
// order: below the trigger first, then above, right, left; a doorway whose
|
||||
// below-point falls outside every virtual band lands above it instead.
|
||||
func TestTownPortalReturnPointPrefersBelowThenFallsBackInsideVirtualArea(t *testing.T) {
|
||||
trigger := townMovableArea{x: 600, y: 150, width: 100, height: 30, targetTown: 38, targetArea: 1}
|
||||
if point, ok := townPortalReturnPoint(trigger, []int64{0, 200, 1000, 200}); !ok || point != (Gate{X: 650, Y: 212}) {
|
||||
t.Fatalf("below point=%+v ok=%t", point, ok)
|
||||
}
|
||||
if point, ok := townPortalReturnPoint(trigger, []int64{0, 0, 1000, 140}); !ok || point != (Gate{X: 650, Y: 118}) {
|
||||
t.Fatalf("above point=%+v ok=%t", point, ok)
|
||||
}
|
||||
if point, ok := townPortalReturnPoint(trigger, nil); !ok || point != (Gate{X: 650, Y: 212}) {
|
||||
t.Fatalf("no-virtual fallback point=%+v ok=%t", point, ok)
|
||||
}
|
||||
if _, ok := townPortalReturnPoint(trigger, []int64{5000, 5000, 10, 10}); ok {
|
||||
t.Fatal("a virtual band that contains no candidate must yield no point")
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime PVF: CentralPark (town 31) area 1 portals into the Seria room and
|
||||
// its derived landing point is 1891/234, next to the doorway on the walkable
|
||||
// band rather than on the trigger itself.
|
||||
func TestRealScriptPVFCentralParkSeriaRoomReturnPoint(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the Seria-room return point")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatalf("load real Script.pvf: %v", err)
|
||||
}
|
||||
table, err := Load(context.Background(), archive, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("load real town catalog: %v", err)
|
||||
}
|
||||
area, ok := table.FindSeriaRoomReturnArea(31)
|
||||
if !ok || area.ID != 1 || area.SeriaRoomReturn == nil ||
|
||||
area.SeriaRoomReturn.X != 1891 || area.SeriaRoomReturn.Y != 234 {
|
||||
t.Fatalf("town 31 Seria-room return=(%+v,%t)", area, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const DefaultList = "town/town.lst"
|
||||
|
||||
var (
|
||||
ErrSourceRequired = errors.New("dnf town pvf source is required")
|
||||
ErrListEmpty = errors.New("dnf town list is empty")
|
||||
ErrDocumentEmpty = errors.New("dnf town document is empty")
|
||||
)
|
||||
|
||||
type Source interface {
|
||||
ReadText(string) (string, error)
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
ListPath string
|
||||
}
|
||||
|
||||
type Gate struct {
|
||||
X int64 `json:"x"`
|
||||
Y int64 `json:"y"`
|
||||
}
|
||||
|
||||
type NPCSpawn struct {
|
||||
NPCID int64 `json:"npc_id"`
|
||||
Direction string `json:"direction,omitempty"`
|
||||
X int64 `json:"x"`
|
||||
Y int64 `json:"y"`
|
||||
Z int64 `json:"z"`
|
||||
}
|
||||
|
||||
// MovableArea preserves a client-authored [town movable area] rectangle. In
|
||||
// the current PVF many rows are narrow portal/transition trigger regions, not
|
||||
// a complete map-wide walkable polygon, so callers must not treat their union
|
||||
// as unrestricted actor placement space.
|
||||
type MovableArea struct {
|
||||
X int64 `json:"x"`
|
||||
Y int64 `json:"y"`
|
||||
Width int64 `json:"width"`
|
||||
Height int64 `json:"height"`
|
||||
}
|
||||
|
||||
type Area struct {
|
||||
ID int64 `json:"id"`
|
||||
MapPath string `json:"map_path"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Gate *Gate `json:"gate,omitempty"`
|
||||
DungeonGate *int64 `json:"dungeon_gate,omitempty"`
|
||||
MinLevel int64 `json:"min_level,omitempty"`
|
||||
NeedQuests []int64 `json:"need_quests,omitempty"`
|
||||
// QuestConditions are the repeated [quest condition] owners declared by
|
||||
// this area's map. Unlike NeedQuests they do not block entering the area;
|
||||
// the client consults them while constructing quest-gated passages and map
|
||||
// animations for the scene, so finishing one of them needs a same-area
|
||||
// scene rebuild before the passage appears.
|
||||
QuestConditions []int64 `json:"quest_conditions,omitempty"`
|
||||
TownExitTargetNPCIDs []int64 `json:"town_exit_target_npc_ids,omitempty"`
|
||||
NPCs []NPCSpawn `json:"npcs,omitempty"`
|
||||
MovableAreas []MovableArea `json:"movable_areas,omitempty"`
|
||||
// SeriaRoomReturn is the safe town-side landing point derived from this
|
||||
// area's portal into the shared Seria room (town 38 area 1): the nearest
|
||||
// point outside the portal trigger rectangle and inside [virtual movable
|
||||
// area]. Landing inside the trigger keeps the client's overlap latch armed
|
||||
// and re-enters the room. Nil when the map declares no such portal.
|
||||
SeriaRoomReturn *Gate `json:"seria_room_return,omitempty"`
|
||||
// WeddingRoomReturn is the safe landing point on this area's side of the
|
||||
// portal into the couple room (town 101). Leaving the couple room re-enters
|
||||
// this scene, so landing inside the trigger rectangle would bounce the
|
||||
// character straight back. Nil when the map declares no such portal.
|
||||
WeddingRoomReturn *Gate `json:"wedding_room_return,omitempty"`
|
||||
}
|
||||
|
||||
type Town struct {
|
||||
ID int64 `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Areas []Area `json:"areas"`
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
towns map[int64]Town
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Towns int `json:"towns"`
|
||||
Areas int `json:"areas"`
|
||||
}
|
||||
|
||||
func (t *Table) Find(townID int64) (Town, bool) {
|
||||
if t == nil {
|
||||
return Town{}, false
|
||||
}
|
||||
value, ok := t.towns[townID]
|
||||
return cloneTown(value), ok
|
||||
}
|
||||
|
||||
// Towns returns a deterministic cloned snapshot of every loaded PVF town.
|
||||
func (t *Table) Towns() []Town {
|
||||
if t == nil || len(t.towns) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int64, 0, len(t.towns))
|
||||
for id := range t.towns {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
out := make([]Town, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, cloneTown(t.towns[id]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *Table) FindArea(townID, areaID int64) (Area, bool) {
|
||||
town, ok := t.Find(townID)
|
||||
if !ok {
|
||||
return Area{}, false
|
||||
}
|
||||
for _, area := range town.Areas {
|
||||
if area.ID == areaID {
|
||||
return cloneArea(area), true
|
||||
}
|
||||
}
|
||||
return Area{}, false
|
||||
}
|
||||
|
||||
// AllowsNPCEnterTown reports whether the target NPC is proven by the runtime
|
||||
// PVF as an [enter town] destination, owns an [exit town] role, and is spawned
|
||||
// on the requested town-area map.
|
||||
func (t *Table) AllowsNPCEnterTown(townID, areaID, targetNPCID int64) bool {
|
||||
if targetNPCID <= 0 {
|
||||
return false
|
||||
}
|
||||
area, ok := t.FindArea(townID, areaID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, npcID := range area.TownExitTargetNPCIDs {
|
||||
if npcID == targetNPCID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FindGateArea returns the first gate area declared by the requested town.
|
||||
// Gate coordinates and area ownership come from the runtime PVF catalog.
|
||||
func (t *Table) FindGateArea(townID int64) (Area, bool) {
|
||||
town, ok := t.Find(townID)
|
||||
if !ok {
|
||||
return Area{}, false
|
||||
}
|
||||
for _, area := range town.Areas {
|
||||
if area.Kind == "gate" && area.Gate != nil {
|
||||
return cloneArea(area), true
|
||||
}
|
||||
}
|
||||
return Area{}, false
|
||||
}
|
||||
|
||||
// FindSeriaRoomReturnArea returns the town area that owns the PVF portal into
|
||||
// the shared Seria room. A Seria-room exit request carries no destination, so
|
||||
// callers use this reverse edge and its safe landing point rather than the
|
||||
// character's unrelated pre-login area and coordinates.
|
||||
func (t *Table) FindSeriaRoomReturnArea(townID int64) (Area, bool) {
|
||||
town, ok := t.Find(townID)
|
||||
if !ok {
|
||||
return Area{}, false
|
||||
}
|
||||
for _, area := range town.Areas {
|
||||
if area.SeriaRoomReturn != nil {
|
||||
return cloneArea(area), true
|
||||
}
|
||||
}
|
||||
return Area{}, false
|
||||
}
|
||||
|
||||
func (t *Table) Snapshot() Snapshot {
|
||||
if t == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
snapshot := Snapshot{Towns: len(t.towns)}
|
||||
for _, town := range t.towns {
|
||||
snapshot.Areas += len(town.Areas)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func cloneTown(value Town) Town {
|
||||
value.Areas = append([]Area(nil), value.Areas...)
|
||||
for idx := range value.Areas {
|
||||
value.Areas[idx] = cloneArea(value.Areas[idx])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func cloneArea(value Area) Area {
|
||||
value.NeedQuests = append([]int64(nil), value.NeedQuests...)
|
||||
if value.QuestConditions != nil {
|
||||
value.QuestConditions = append([]int64(nil), value.QuestConditions...)
|
||||
}
|
||||
value.TownExitTargetNPCIDs = append([]int64(nil), value.TownExitTargetNPCIDs...)
|
||||
value.NPCs = append([]NPCSpawn(nil), value.NPCs...)
|
||||
value.MovableAreas = append([]MovableArea(nil), value.MovableAreas...)
|
||||
if value.Gate != nil {
|
||||
gate := *value.Gate
|
||||
value.Gate = &gate
|
||||
}
|
||||
if value.DungeonGate != nil {
|
||||
dungeonGate := *value.DungeonGate
|
||||
value.DungeonGate = &dungeonGate
|
||||
}
|
||||
if value.SeriaRoomReturn != nil {
|
||||
seriaReturn := *value.SeriaRoomReturn
|
||||
value.SeriaRoomReturn = &seriaReturn
|
||||
}
|
||||
if value.WeddingRoomReturn != nil {
|
||||
weddingReturn := *value.WeddingRoomReturn
|
||||
value.WeddingRoomReturn = &weddingReturn
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// weddingRoomTownID 是婚房城镇号(PVF:town/coupleroom.twn)。
|
||||
const weddingRoomTownID int64 = 101
|
||||
|
||||
// weddingRoomPortalTrigger 找出本区域地图里"通往婚房(101/0)"的 [town movable
|
||||
// area] 行。
|
||||
//
|
||||
// 复用 seriaRoomPortalTrigger 的两段式解析(扁平六整数形式 + 嵌套
|
||||
// [movable area]/[area]/[move town] 形式),只把目标城镇换成 101。
|
||||
//
|
||||
// 存在的意义:从婚房走左门出来时,客户端会以 (300,200)/方向5 请求落到赛丽亚
|
||||
// 房间;如果落点正好压在这扇门的触发矩形里,客户端重叠 latch 不解除,角色会被
|
||||
// 立刻送回婚房。所以在装载期就把"门外最近的安全落点"算好(WeddingRoomReturn),
|
||||
// 运行时直接用,避免在 bridge 侧复刻第二套未被验证的几何。
|
||||
func weddingRoomPortalTrigger(doc *dnfpvf.Document) (townMovableArea, bool) {
|
||||
if doc == nil {
|
||||
return townMovableArea{}, false
|
||||
}
|
||||
if values, ok := sectionInts(doc, "town movable area"); ok {
|
||||
for offset := 0; offset+5 < len(values); offset += 6 {
|
||||
candidate := townMovableArea{
|
||||
x: values[offset],
|
||||
y: values[offset+1],
|
||||
width: values[offset+2],
|
||||
height: values[offset+3],
|
||||
targetTown: values[offset+4],
|
||||
targetArea: values[offset+5],
|
||||
}
|
||||
if candidate.width > 0 && candidate.height > 0 &&
|
||||
candidate.targetTown == weddingRoomTownID && candidate.targetArea == 0 {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
insideMovableArea := false
|
||||
candidate := townMovableArea{}
|
||||
hasArea := false
|
||||
for idx, token := range doc.Tokens {
|
||||
if token.Kind != dnfpvf.TokenSection {
|
||||
continue
|
||||
}
|
||||
switch sectionName(token.Value) {
|
||||
case "movable area":
|
||||
insideMovableArea = true
|
||||
candidate = townMovableArea{}
|
||||
hasArea = false
|
||||
case "/movable area":
|
||||
insideMovableArea = false
|
||||
candidate = townMovableArea{}
|
||||
hasArea = false
|
||||
case "area":
|
||||
if !insideMovableArea {
|
||||
continue
|
||||
}
|
||||
values := followingInts(doc.Tokens, idx+1, 4)
|
||||
if len(values) == 4 && values[2] > 0 && values[3] > 0 {
|
||||
candidate.x = values[0]
|
||||
candidate.y = values[1]
|
||||
candidate.width = values[2]
|
||||
candidate.height = values[3]
|
||||
hasArea = true
|
||||
}
|
||||
case "move town":
|
||||
if !insideMovableArea || !hasArea {
|
||||
continue
|
||||
}
|
||||
values := followingInts(doc.Tokens, idx+1, 2)
|
||||
if len(values) == 2 && values[0] == weddingRoomTownID && values[1] == 0 {
|
||||
candidate.targetTown = values[0]
|
||||
candidate.targetArea = values[1]
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return townMovableArea{}, false
|
||||
}
|
||||
|
||||
// weddingRoomReturnPoint 复刻当前 EXE 对这道门的到达算法:保留传送门的垂直中心
|
||||
// (原生 0x17CA8F0 的算法),并把角色放在门矩形**右边缘再往外半个门宽**处,也就是
|
||||
// 朝房间内部那一侧。以 `[49,224,34,52 -> 101/0]` 为例得到 (100,250)。
|
||||
//
|
||||
// 与 townPortalReturnPoint 的分工:那个函数在四个方向上找"门外 + 可走"的最近点,
|
||||
// 是通用兜底;婚房这道门有原生既定的落点,所以先用原生公式,只有当该点确实不在
|
||||
// [virtual movable area] 内(或地图没声明该区域)时才退回通用兜底。这样既对齐
|
||||
// 原生行为,又不会把角色放进墙里。
|
||||
func weddingRoomReturnPoint(trigger townMovableArea, virtual []int64) (Gate, bool) {
|
||||
if trigger.width <= 0 || trigger.height <= 0 {
|
||||
return Gate{}, false
|
||||
}
|
||||
primary := Gate{
|
||||
X: trigger.x + trigger.width + trigger.width/2,
|
||||
Y: trigger.y + trigger.height/2,
|
||||
}
|
||||
if len(virtual) < 4 || pointInsideAnyMovableArea(primary, virtual) {
|
||||
return primary, true
|
||||
}
|
||||
return townPortalReturnPoint(trigger, virtual)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package town
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The couple-room doorway must land on the native arrival point: the portal's
|
||||
// vertical center, half a portal width beyond its right edge. For the live row
|
||||
// `49 224 34 52 101 0` that is (100,250). Landing inside the trigger rectangle
|
||||
// would re-arm the client's overlap latch and bounce the character back.
|
||||
func TestLoadDerivesWeddingRoomReturnPointFromPortal(t *testing.T) {
|
||||
table, err := Load(context.Background(), memorySource{
|
||||
"town/town.lst": "38 `seria.twn`",
|
||||
"town/seria.twn": "[area]\n0 `Cataclysm/Town/Elvengard/new_Elvengard.map` `[normal]`\n[/area]\n[area]\n1 `Cataclysm/Town/Elvengard/new_seria_room.map` `[gate]` 450 234\n[/area]",
|
||||
"map/Cataclysm/Town/Elvengard/new_seria_room.map": "[town movable area]\n858 183 30 100 38 0 49 224 34 52 101 0\n[/town movable area]",
|
||||
}, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
area, ok := table.FindArea(38, 1)
|
||||
if !ok || area.WeddingRoomReturn == nil {
|
||||
t.Fatalf("wedding doorway missing: ok=%v area=%+v", ok, area)
|
||||
}
|
||||
if area.WeddingRoomReturn.X != 100 || area.WeddingRoomReturn.Y != 250 {
|
||||
t.Fatalf("wedding doorway = %+v, want (100,250)", *area.WeddingRoomReturn)
|
||||
}
|
||||
// The arrival must sit outside every authored trigger rectangle.
|
||||
for _, movable := range area.MovableAreas {
|
||||
if movable.Width <= 0 || movable.Height <= 0 {
|
||||
continue
|
||||
}
|
||||
if area.WeddingRoomReturn.X >= movable.X && area.WeddingRoomReturn.X <= movable.X+movable.Width &&
|
||||
area.WeddingRoomReturn.Y >= movable.Y && area.WeddingRoomReturn.Y <= movable.Y+movable.Height {
|
||||
t.Fatalf("arrival retriggers portal %+v", movable)
|
||||
}
|
||||
}
|
||||
// A map with no couple-room portal must not invent one.
|
||||
area, ok = table.FindArea(38, 0)
|
||||
if !ok || area.WeddingRoomReturn != nil {
|
||||
t.Fatalf("town area gained a couple-room return: ok=%v area=%+v", ok, area)
|
||||
}
|
||||
// The catalog must not hand out a mutable alias.
|
||||
first, _ := table.FindArea(38, 1)
|
||||
first.WeddingRoomReturn.X = 999
|
||||
again, _ := table.FindArea(38, 1)
|
||||
if again.WeddingRoomReturn == nil || again.WeddingRoomReturn.X != 100 {
|
||||
t.Fatalf("mutable catalog alias: %+v", again.WeddingRoomReturn)
|
||||
}
|
||||
}
|
||||
|
||||
// A nested [movable area]/[move town] form must be understood too, because the
|
||||
// flat six-int reader cannot see it.
|
||||
func TestLoadDerivesWeddingRoomReturnFromNestedPortal(t *testing.T) {
|
||||
table, err := Load(context.Background(), memorySource{
|
||||
"town/town.lst": "38 `seria.twn`",
|
||||
"town/seria.twn": "[area]\n1 `Cataclysm/Town/Elvengard/new_seria_room.map` `[gate]` 450 234\n[/area]",
|
||||
"map/Cataclysm/Town/Elvengard/new_seria_room.map": "[town movable area with animation]\n[movable area]\n[area] 60 210 40 60\n[move town] 101 0\n[/movable area]\n[/town movable area with animation]",
|
||||
}, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
area, ok := table.FindArea(38, 1)
|
||||
if !ok || area.WeddingRoomReturn == nil {
|
||||
t.Fatalf("nested wedding doorway missing: ok=%v area=%+v", ok, area)
|
||||
}
|
||||
if area.WeddingRoomReturn.X != 120 || area.WeddingRoomReturn.Y != 240 {
|
||||
t.Fatalf("nested wedding doorway = %+v, want (120,240)", *area.WeddingRoomReturn)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user