DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+249
View File
@@ -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")
}
}
+108
View File
@@ -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
}
+124
View File
@@ -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)
}
}
+227
View File
@@ -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)
}
}