Expand movement ring to all 65 registered maps with paged categories

This commit is contained in:
shen
2026-09-30 17:33:13 -07:00
parent cf9ed052b1
commit 059ceadec2
8 changed files with 2049 additions and 77 deletions
+3
View File
@@ -47,6 +47,9 @@ metin2-client 是 40250 Windows 客户端的跨平台版本。**首要目标是
2026-09-30 用户授权扩展:自动狩猎引怪改为服务端披风式追击,免费选择 3/10/20 个实际怪物队伍;
见 `tools/server/autohunt-pull/README.md`,203 已部署并实测 3 组/9 只怪物。原披风物品保持不变。
2026-09-30 用户授权扩展:移动戒指列出 203 已登记的全部 65 张地图,按六类分页,包含活动、战争和测试地图;
受限副本走原有入口,活动保留入场流程,测试传送仅限 GM。见 `tools/server/ring-warp/README.md`。
## 3. 三个层和代码位置
| 层 | 40250 单元 | 做法 | 位置 |
+48 -9
View File
@@ -1,14 +1,53 @@
# 移动戒指(203 服务端)
40250 的移动戒指 70058(任务物品,30 级可用)通过 `special_item_group.txt` 的 10031 组触发任务 `ring_warp`:
选择本国一村 / 二村、升龙谷、影悲沙漠、舒寒山、导炎大地后传送。原版能用 50 次、每次冷却 30 分钟
(item_proto value0 / value1,记在物品 socket 0 / 1 里),用完就消失。
物品 70058 通过 special_item_group 10031 的 `sig_use` 触发 `ring_warp`。
戒指不限等级、次数,不消耗物品,也没有自身冷却。传送仍遵守服务端的 `pc.can_warp()` 检查。
百店老板娘的商城出售此物品(`../npc-shops/item-mall`,1 金币)。
这里的 `ring_warp.quest` 去掉了次数和冷却:不再读写 socket,戒指不会消耗。`install.sh` 同时去掉
`item_proto.txt` 里的 30 级限制(服务端 `CHARACTER::CanUseItem` 按它拒绝低等级角色使用),新建的角色也能用。在百店老板娘的商城里卖
(`../npc-shops/item-mall`,1 金币)。
2026-09-30 补齐 203 的 `newcibn/map/index` 全部 **65 张地图**,包括活动、战争和测试地图。
目录及坐标来自实际服务器的 `index`、`Setting.txt`、`Town.txt`、`npc.txt`,保存在 `maps.json`。
部分地图名称采用中文描述,目录名和地图编号用于唯一对应。
sh install.sh # 在服务器上,从本目录运行;然后重启 db 和 game
| 分类 | 数量 | 操作 |
| --- | ---: | --- |
| 各国村庄 | 6 | 传送至各国一村、二村 |
| 野外地图 | 13 | 传送至地图出生点 |
| 洞穴与副本 | 15 | 普通洞穴传送;受限副本传送至原有入口 |
| 公会地图 | 6 | 公会领地及公会村庄 |
| 活动与战争 | 20 | 列出地图及原有入场方式,保留报名、婚礼及战争流程 |
| 测试地图 | 5 | 所有人可查看;仅 GM 可传送 |
`install.sh` 会备份到 `/root/backup-ringwarp/<时间>`,把任务转成 GBK 换掉 `ring_warp.quest`,用 `make.py` 重新编译
全部任务,并把服务端的物品名(`item_names.txt`、DB `item_proto.locale_name`)改成「移动戒指」。
每页最多 5 个地点,包含上一页、下一页、返回分类;选择传送后再确认。
受限副本编号为 66、71、72、73、208、216、217、351。蜘蛛洞三层先经一层守卫进入二层;
流放二层和蓝龙巢穴先经流放洞窟入口进入,戒指不绕过通行证、血石、组队及副本入场条件。
活动地图只显示入场说明,避免进入尚未开放或未分配给玩家的场景。
资源活动地图 101 的服务器 Town.txt 坐标在地图边界外,目录记录保留原值,未用于传送。
## 修改与验证
修改 `maps.json` 后执行:
```sh
python3 tools/server/ring-warp/generate.py
python3 tools/server/ring-warp/tests/test_ring.py SERVER_LIBLUA_DIRECTORY LIVE_MAP_INDEX
```
测试使用 40250 服务端的 Lua 5.0 源码构建临时解释器,执行生成的真实任务函数。
检查地图覆盖、坐标边界、GBK 编码、全部 65 个地点的选择,以及翻页、取消、传送限制和 GM 权限。
可增加第三个参数 `COMPILED_OBJECT_ROOT`,直接验证服务器 qc 生成的两个任务对象。
## 安装
从此目录在服务器执行:
```sh
sh install.sh --quest-only # 仅更新任务;每个 game core 重载任务或重启 game
sh install.sh # 同时安装物品名、移除等级限制;首次安装需重启 db/game
```
安装前备份任务源码、两个任务对象、物品表至 `/root/backup-ringwarp/<时间>`。
先在隔离目录编译并检查结果,再替换 ring_warp 的两个对象;不会清空其他任务对象。
`--quest-only` 不更改物品表或数据库,不需要重启 db/auth。
任务转换成 GBK;拒绝包含反斜杠字节的编码,避免 qc 解析异常。
203 已于本次部署仅重启四个 game core,db/auth 保持运行。此次是服务端任务更新,已有客户端重登后即可使用。
+104
View File
@@ -0,0 +1,104 @@
# coding: utf-8
"""Generate the quest from the audited server map catalog (Python 3)."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def generate():
catalog = json.loads((ROOT / 'maps.json').read_text(encoding='utf-8'))
def quote(value):
return json.dumps(value, ensure_ascii=False)
lines = ['-- Generated by generate.py from maps.json. Do not edit by hand.',
'-- UTF-8 source; install.sh compiles GBK for the 203 server.',
'quest ring_warp begin', ' state start begin',
' function destinations()', ' return {']
for category in catalog['categories']:
lines.append(' {name = %s, maps = {' % quote(category['name']))
for m in category['maps']:
lines.append(' {id = %d, name = %s, x = %d, y = %d, mode = %s, note = %s},' %
(m['id'], quote(m['name']), m['target'][1], m['target'][2], quote(m['mode']), quote(m['note'])))
lines.append(' }},')
lines.extend([' }', ' end'])
lines.append(''' when 10031.sig_use begin
local categories = ring_warp.destinations()
local category_options = {}
for i = 1, table.getn(categories) do
table.insert(category_options, categories[i].name)
end
table.insert(category_options, "关闭")
while true do
say_title("移动戒指")
say("请选择地图分类:")
local category = select_table(category_options)
if category == table.getn(category_options) then return end
local maps = categories[category].maps
local page = 1
local browsing = true
while browsing do
local first = (page - 1) * 5 + 1
local last = math.min(first + 4, table.getn(maps))
local options = {}
for i = first, last do
local label = maps[i].name
if maps[i].mode == "entrance" then label = label .. "(入口)" end
table.insert(options, label)
end
local count = table.getn(options)
local previous = 0
local following = 0
if page > 1 then
table.insert(options, "上一页")
previous = table.getn(options)
end
if last < table.getn(maps) then
table.insert(options, "下一页")
following = table.getn(options)
end
table.insert(options, "返回分类")
say_title(categories[category].name)
say("第 " .. page .. " 页 / 共 " .. math.ceil(table.getn(maps) / 5) .. " 页")
local choice = select_table(options)
if choice == table.getn(options) then
browsing = false
elseif previous > 0 and choice == previous then
page = page - 1
elseif following > 0 and choice == following then
page = page + 1
elseif choice >= 1 and choice <= count then
local destination = maps[first + choice - 1]
say_title(destination.name)
say("地图编号:" .. destination.id)
if destination.mode == "gm" and not pc.is_gm() then
say(destination.note)
wait()
elseif destination.mode == "event" then
say(destination.note)
wait()
else
if destination.mode == "entrance" then say(destination.note) end
if destination.mode == "gm" then say("管理员专用测试地图。") end
if select("传送", "返回列表") == 1 then
if not pc.can_warp() then
say("当前无法传送,请结束交易或其他操作后再试。")
return
end
pc.warp(destination.x, destination.y)
return
end
end
end
end
end
end
end
end''')
result = '\n'.join(lines) + '\n'
encoded = result.encode('gbk')
if b'\\' in encoded:
bad = sorted({c for c in result if b'\\' in c.encode('gbk')})
raise ValueError('qc cannot parse GBK backslash bytes: %r' % bad)
return result
if __name__ == '__main__':
(ROOT / 'ring_warp.quest').write_text(generate(), encoding='utf-8')
+19 -3
View File
@@ -1,9 +1,11 @@
#!/bin/sh
# Installs the unlimited 移动戒指 (README.md): run on the server from this directory, then restart db + game.
# Replaces quest ring_warp with ./ring_warp.quest (GBK), recompiles all quests with make.py, and names
# Replaces quest ring_warp with ./ring_warp.quest (GBK), compiles only ring_warp in an isolated staging directory, and names
# item 70058 移动戒指 on the server (item_names.txt and item_proto.locale_name; the client has its own name), and
# drops its level 30 limit in item_proto.txt (CHARACTER::CanUseItem refuses it to lower characters).
set -eu
MODE=${1:-full}
case "$MODE" in full|--quest-only) ;; *) echo "usage: $0 [--quest-only]" >&2; exit 2 ;; esac
SERVER=${SERVER:-/usr/metin2/server}
LOCALE=${LOCALE:-newcibn}
@@ -21,7 +23,22 @@ echo "backup: $BACKUP"
# A GBK trail byte 0x5C would be a backslash to qc.
iconv -f UTF-8 -t GBK "$HERE/ring_warp.quest" > "$BACKUP/ring_warp.gbk"
if grep -q '\\' "$BACKUP/ring_warp.gbk"; then echo "ring_warp.quest: backslash byte in GBK" >&2; exit 1; fi
# Compile in isolation before touching any live quest objects.
mkdir -p "$BACKUP/stage" "$BACKUP/object/state" "$BACKUP/object/10031/sig_use"
cp -p "$QUEST/object/state/ring_warp" "$BACKUP/object/state/"
cp -p "$QUEST/object/10031/sig_use/ring_warp.start" "$BACKUP/object/10031/sig_use/"
cp "$QUEST/quest_functions" "$BACKUP/stage/"
cp "$BACKUP/ring_warp.gbk" "$BACKUP/stage/ring_warp.quest"
(cd "$BACKUP/stage" && "$QUEST/qc" ring_warp.quest) > "$BACKUP/qc.log" 2>&1 || { cat "$BACKUP/qc.log" >&2; exit 1; }
test -s "$BACKUP/stage/object/state/ring_warp"
test -s "$BACKUP/stage/object/10031/sig_use/ring_warp.start"
cat "$BACKUP/ring_warp.gbk" > "$QUEST/ring_warp.quest"
cp "$BACKUP/stage/object/state/ring_warp" "$QUEST/object/state/ring_warp"
cp "$BACKUP/stage/object/10031/sig_use/ring_warp.start" "$QUEST/object/10031/sig_use/ring_warp.start"
if [ "$MODE" = "--quest-only" ]; then
echo "done; reload quests on every game core, or restart game cores (db/auth need no restart)"
exit 0
fi
name=$(printf '移动戒指' | iconv -f UTF-8 -t GB2312)
awk -F'\t' -v OFS='\t' -v name="$name" '$1 == 70058 { $2 = name; n++ } { print }
@@ -38,7 +55,6 @@ awk -F'\t' -v OFS='\t' '$1 == 70058 { if ($15 == "LEVEL") { $15 = "LIMIT_NONE";
cat "$BACKUP/item_proto.new" > "$PROTO"
mysql -uroot player -e "UPDATE item_proto SET limittype0 = 0, limitvalue0 = 0 WHERE vnum = 70058 AND limittype0 = 1"
# make.py's shebang (/usr/local/bin/python) does not exist on 203.
cd "$QUEST" && /usr/local/bin/python2.7 make.py > "$BACKUP/make.log" 2>&1 || { tail "$BACKUP/make.log" >&2; exit 1; }
# Only ring_warp is compiled; other quest objects are preserved.
ls "$QUEST/object/10031/sig_use"
echo "done; restart db and game"
File diff suppressed because it is too large Load Diff
+156 -65
View File
@@ -1,67 +1,158 @@
-- 移动戒指 70058 (special_item_group 10031 "Quest" -> sig_use), from 40250 ring_warp.quest.
-- Changed for 203 (install.sh): no use count, no cooldown, the ring is never used up.
-- UTF-8 here; install.sh converts it to GBK for qc.
-- Generated by generate.py from maps.json. Do not edit by hand.
-- UTF-8 source; install.sh compiles GBK for the 203 server.
quest ring_warp begin
state start begin
when 10031.sig_use begin
local empire = pc.get_empire()
say("请选择传送地点:")
local town1 = {
gameforge.functions._100_say,
gameforge.functions._110_say,
gameforge.functions._120_say,
}
local town2 = {
gameforge.functions._130_say,
gameforge.functions._140_say,
gameforge.functions._150_say,
}
local sub_set = select(town1[empire], town2[empire], locale.map_name[64], locale.map_name[63], locale.map_name[61], locale.map_name[62], gameforge.locale.cancel)
if sub_set == 7 then
return
end
local warp = {
{ -- empire town -1
{ 474300, 954800 },
{ 63800, 166400 },
{ 959900, 269200 },
},
{ -- empire town -2
{ 353100, 882900 },
{ 145500, 240000 },
{ 863900, 246000 },
},
-- 64 map_n_threeway
{
{ 402100, 673900 },
{ 270400, 739900 },
{ 321300, 808000 },
},
-- 63 metin2_map_n_desert_01
{
{ 217800, 627200 },
{ 221900, 502700 },
{ 344000, 502500 },
},
-- 61 map_n_snowm_01
{
{ 434200, 290600 },
{ 375200, 174900 },
{ 491800, 173600 },
},
-- 62 metin2_map_n_flame_01
{
{ 599400, 756300 },
{ 597800, 622200 },
{ 730700, 689800 },
},
}
pc.warp(warp[sub_set][empire][1], warp[sub_set][empire][2])
end
end
state start begin
function destinations()
return {
{name = "各国村庄", maps = {
{id = 1, name = "盛唐国一村", x = 469300, y = 964200, mode = "direct", note = ""},
{id = 3, name = "盛唐国二村", x = 363000, y = 875200, mode = "direct", note = ""},
{id = 21, name = "秦皇国一村", x = 55700, y = 157900, mode = "direct", note = ""},
{id = 23, name = "秦皇国二村", x = 137800, y = 234600, mode = "direct", note = ""},
{id = 41, name = "周武国一村", x = 969600, y = 278400, mode = "direct", note = ""},
{id = 43, name = "周武国二村", x = 877400, y = 253700, mode = "direct", note = ""},
}},
{name = "野外地图", maps = {
{id = 61, name = "舒寒山", x = 435200, y = 230400, mode = "direct", note = ""},
{id = 62, name = "导炎大地", x = 665600, y = 691200, mode = "direct", note = ""},
{id = 63, name = "影悲沙漠", x = 281600, y = 563200, mode = "direct", note = ""},
{id = 64, name = "升龙谷", x = 323500, y = 806900, mode = "direct", note = ""},
{id = 65, name = "鬼族寺院", x = 553700, y = 145000, mode = "direct", note = ""},
{id = 67, name = "幽灵森林", x = 290100, y = 5700, mode = "direct", note = ""},
{id = 68, name = "红森林", x = 1119600, y = 70000, mode = "direct", note = ""},
{id = 69, name = "巨人荒地", x = 1058800, y = 725800, mode = "direct", note = ""},
{id = 70, name = "蛇之领域", x = 827700, y = 763400, mode = "direct", note = ""},
{id = 301, name = "龙头海角", x = 1104800, y = 1788800, mode = "direct", note = ""},
{id = 302, name = "迷雾森林", x = 1225700, y = 1682000, mode = "direct", note = ""},
{id = 303, name = "黑沙海湾", x = 1087900, y = 1649800, mode = "direct", note = ""},
{id = 304, name = "雷鸣山脉", x = 1134100, y = 1655400, mode = "direct", note = ""},
}},
{name = "洞穴与副本", maps = {
{id = 5, name = "盛唐国猴洞", x = 775200, y = 447700, mode = "direct", note = ""},
{id = 25, name = "秦皇国猴洞", x = 852000, y = 447700, mode = "direct", note = ""},
{id = 45, name = "周武国猴洞", x = 928800, y = 447700, mode = "direct", note = ""},
{id = 66, name = "恶鬼塔", x = 590800, y = 110800, mode = "entrance", note = "将传送到恶鬼塔守卫附近,请与守卫交谈入场。"},
{id = 71, name = "蜘蛛洞二层", x = 92400, y = 525500, mode = "entrance", note = "将传送到蜘蛛洞守卫附近,二层仍需使用原有通行证。"},
{id = 72, name = "流放洞窟一层", x = 284200, y = 811100, mode = "entrance", note = "将传送到流放洞窟入口附近,仍需满足原有等级及血石条件。"},
{id = 73, name = "流放洞窟二层", x = 284200, y = 811100, mode = "entrance", note = "请先从入口进入流放洞窟,再通过洞内通道前往二层。"},
{id = 104, name = "蜘蛛洞一层", x = 60000, y = 496600, mode = "direct", note = ""},
{id = 107, name = "猴洞初级", x = 826400, y = 62800, mode = "direct", note = ""},
{id = 108, name = "猴洞中级", x = 135200, y = 652500, mode = "direct", note = ""},
{id = 109, name = "猴洞高级", x = 135200, y = 729300, mode = "direct", note = ""},
{id = 208, name = "蓝龙巢穴", x = 284200, y = 811100, mode = "entrance", note = "请先进入流放洞窟,再到二层的蓝龙入口办理入场。"},
{id = 216, name = "恶魔地下墓穴", x = 592400, y = 100600, mode = "entrance", note = "将传送到地下墓穴入口附近,请与入口守卫交谈。"},
{id = 217, name = "蜘蛛洞三层", x = 92400, y = 525500, mode = "entrance", note = "请先通过守卫进入二层,再从二层入口进入三层。"},
{id = 351, name = "火焰地牢", x = 614800, y = 707000, mode = "entrance", note = "将传送到火焰地牢入口附近,请按原有组队及入场流程进入。"},
}},
{name = "公会地图", maps = {
{id = 4, name = "盛唐国公会领地", x = 135400, y = 5500, mode = "direct", note = ""},
{id = 24, name = "秦皇国公会领地", x = 221900, y = 9200, mode = "direct", note = ""},
{id = 44, name = "周武国公会领地", x = 270900, y = 12700, mode = "direct", note = ""},
{id = 6, name = "盛唐国公会村庄", x = 230600, y = 231900, mode = "direct", note = ""},
{id = 26, name = "秦皇国公会村庄", x = 640200, y = 411100, mode = "direct", note = ""},
{id = 46, name = "周武国公会村庄", x = 281800, y = 846300, mode = "direct", note = ""},
}},
{name = "活动与战争", maps = {
{id = 81, name = "婚礼地图", x = 822300, y = 22000, mode = "event", note = "婚礼地图请通过婚礼管理员及婚礼邀请进入。"},
{id = 100, name = "野外活动地牢", x = 3215100, y = 7600, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 101, name = "资源活动地图", x = 3212800, y = 64000, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 112, name = "决斗场", x = 857400, y = 2400, mode = "event", note = "决斗场请通过决斗管理员报名或观战。"},
{id = 113, name = "知识竞赛场", x = 896500, y = 24600, mode = "event", note = "知识竞赛请在活动开放时通过活动管理员报名。"},
{id = 114, name = "圣地战场", x = 948100, y = 41500, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 118, name = "火焰圣地通道一", x = 1149100, y = 118500, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 119, name = "火焰圣地通道二", x = 1149100, y = 220900, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 120, name = "火焰圣地通道三", x = 1149100, y = 323300, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 121, name = "冰雪圣地", x = 1189300, y = 17900, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 122, name = "冰雪圣地通道一", x = 1200700, y = 168900, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 123, name = "冰雪圣地通道二", x = 1200700, y = 271300, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 124, name = "冰雪圣地通道三", x = 1200700, y = 373700, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 125, name = "沙漠圣地", x = 1270000, y = 56400, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 126, name = "沙漠圣地通道一", x = 1242400, y = 142100, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 127, name = "沙漠圣地通道二", x = 1242400, y = 244500, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 128, name = "沙漠圣地通道三", x = 1242400, y = 346900, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 181, name = "帝国战争一", x = 980000, y = 114400, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 182, name = "帝国战争二", x = 934400, y = 166400, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
{id = 183, name = "帝国战争三", x = 985600, y = 166400, mode = "event", note = "请在对应活动或战争开放时,通过原有报名及入场流程进入。"},
}},
{name = "测试地图", maps = {
{id = 103, name = "测试地图一", x = 45200, y = 83500, mode = "gm", note = "此地图仅供管理员测试,普通角色不能直接进入。"},
{id = 105, name = "测试地图二", x = 19200, y = 12800, mode = "gm", note = "此地图仅供管理员测试,普通角色不能直接进入。"},
{id = 110, name = "测试地图三", x = 44800, y = 12800, mode = "gm", note = "此地图仅供管理员测试,普通角色不能直接进入。"},
{id = 111, name = "测试地图四", x = 70400, y = 12800, mode = "gm", note = "此地图仅供管理员测试,普通角色不能直接进入。"},
{id = 200, name = "公会建设测试地图", x = 105200, y = 10000, mode = "gm", note = "此地图仅供管理员测试,普通角色不能直接进入。"},
}},
}
end
when 10031.sig_use begin
local categories = ring_warp.destinations()
local category_options = {}
for i = 1, table.getn(categories) do
table.insert(category_options, categories[i].name)
end
table.insert(category_options, "关闭")
while true do
say_title("移动戒指")
say("请选择地图分类:")
local category = select_table(category_options)
if category == table.getn(category_options) then return end
local maps = categories[category].maps
local page = 1
local browsing = true
while browsing do
local first = (page - 1) * 5 + 1
local last = math.min(first + 4, table.getn(maps))
local options = {}
for i = first, last do
local label = maps[i].name
if maps[i].mode == "entrance" then label = label .. "(入口)" end
table.insert(options, label)
end
local count = table.getn(options)
local previous = 0
local following = 0
if page > 1 then
table.insert(options, "上一页")
previous = table.getn(options)
end
if last < table.getn(maps) then
table.insert(options, "下一页")
following = table.getn(options)
end
table.insert(options, "返回分类")
say_title(categories[category].name)
say("第 " .. page .. " 页 / 共 " .. math.ceil(table.getn(maps) / 5) .. " 页")
local choice = select_table(options)
if choice == table.getn(options) then
browsing = false
elseif previous > 0 and choice == previous then
page = page - 1
elseif following > 0 and choice == following then
page = page + 1
elseif choice >= 1 and choice <= count then
local destination = maps[first + choice - 1]
say_title(destination.name)
say("地图编号:" .. destination.id)
if destination.mode == "gm" and not pc.is_gm() then
say(destination.note)
wait()
elseif destination.mode == "event" then
say(destination.note)
wait()
else
if destination.mode == "entrance" then say(destination.note) end
if destination.mode == "gm" then say("管理员专用测试地图。") end
if select("传送", "返回列表") == 1 then
if not pc.can_warp() then
say("当前无法传送,请结束交易或其他操作后再试。")
return
end
pc.warp(destination.x, destination.y)
return
end
end
end
end
end
end
end
end
+13
View File
@@ -0,0 +1,13 @@
/* Minimal Lua 5.0 runner; use the server's liblua sources, not a newer Lua. */
#include <stdio.h>
#include "lua.h"
#include "lauxlib.h"
#include "lualib.h"
int main(int argc, char **argv) {
lua_State *L = lua_open();
luaopen_base(L); luaopen_table(L); luaopen_string(L); luaopen_math(L);
if (argc != 2 || luaL_loadfile(L, argv[1]) || lua_pcall(L, 0, 0, 0)) {
fprintf(stderr, "%s\n", lua_tostring(L, -1)); return 1;
}
lua_close(L); return 0;
}
+111
View File
@@ -0,0 +1,111 @@
# coding: utf-8
"""Catalog coverage plus Lua 5.0 menu/permission/warp integration checks.
Usage: python3 test_ring.py SERVER_LIBLUA_DIRECTORY [LIVE_MAP_INDEX] [COMPILED_OBJECT_ROOT]
"""
import importlib.util
import json
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('ring_generate', ROOT / 'generate.py')
generator = importlib.util.module_from_spec(spec)
spec.loader.exec_module(generator)
quest = generator.generate()
assert quest == (ROOT / 'ring_warp.quest').read_text(encoding='utf-8'), 'regenerate quest'
catalog = json.loads((ROOT / 'maps.json').read_text(encoding='utf-8'))
maps = [m for c in catalog['categories'] for m in c['maps']]
byid = {m['id']:m for m in maps}
assert len(maps) == len(byid) == 65
if len(sys.argv) > 2:
live = dict((int(line.split()[0]),line.split()[1]) for line in Path(sys.argv[2]).read_text().splitlines() if line.strip() and not line.lstrip().startswith('#'))
assert {m['id']:m['directory'] for m in maps} == live
for m in maps:
assert m['mode'] in ('direct', 'entrance', 'gm', 'event')
target = byid[m['target'][0]]
for j in range(2):
assert target['base'][j] <= m['target'][j+1] < target['base'][j] + target['size'][j] * 25600, m
assert {m['id'] for m in maps if m['mode']=='entrance'} == {66,71,72,73,208,216,217,351}
assert byid[217]['target'][0] == 104 and byid[208]['target'][0] == 64
assert 'remove_item' not in quest and 'socket' not in quest and 'setqf' not in quest
# Run the actual generated functions/event body under the server's Lua 5.0.
helper = quest.split(' function destinations()',1)[1].split(' when 10031.sig_use begin',1)[0]
body = quest.split(' when 10031.sig_use begin',1)[1].rsplit(' end',1)[0]
script = 'ring_warp = {}\nfunction ring_warp.destinations()' + helper + '\nfunction use_ring()\n' + body + '\nend\n'
if len(sys.argv) > 3:
objects = Path(sys.argv[3])
state = (objects/'state/ring_warp').read_bytes().decode('gbk')
event = (objects/'10031/sig_use/ring_warp.start').read_bytes().decode('gbk')
script = state + '\nfunction use_ring()\n' + event + '\nend\n'
script += '''
local categories = ring_warp.destinations()
local queue = {}
local cursor = 0
local gm = false
local can_warp = true
local warps = {}
pc = {
is_gm = function() return gm end,
can_warp = function() return can_warp end,
warp = function(x,y) table.insert(warps,{x,y}) end,
}
say_title = function(text) end
say = function(text) end
wait = function() end
select_table = function(options)
assert(table.getn(options) <= 8, "menu too tall")
cursor = cursor + 1
local choice = queue[cursor]
assert(choice and choice >= 1 and choice <= table.getn(options), "bad selection")
return choice
end
select = function(...) return select_table(arg) end
local runs = 0
function run(answers, is_gm, allowed)
queue = answers; cursor = 0; gm = is_gm; can_warp = allowed; warps = {}
use_ring()
assert(cursor == table.getn(queue), "unexpected navigation")
runs = runs + 1
end
for category = 1, table.getn(categories) do
local maps = categories[category].maps
for index = 1, table.getn(maps) do
local page = math.floor((index-1)/5)+1
local answer = {}
table.insert(answer, category)
for p = 1,page-1 do table.insert(answer, p==1 and 6 or 7) end
table.insert(answer, math.mod(index-1,5)+1)
local m = maps[index]
local blocked = m.mode == "gm" or m.mode == "event"
if blocked then
local count = math.min(5,table.getn(maps)-(page-1)*5)
local buttons = count + 1
if page > 1 then buttons = buttons + 1 end
if page*5 < table.getn(maps) then buttons = buttons + 1 end
table.insert(answer,buttons); table.insert(answer,7)
else table.insert(answer,1) end
run(answer,false,true)
assert(table.getn(warps) == (blocked and 0 or 1))
if not blocked then assert(warps[1][1]==m.x and warps[1][2]==m.y) end
end
end
run({7},false,true) -- close
run({1,6,2,7,7},false,true) -- next, previous, return list/category, close
run({1,1,1},false,false) -- blocked by active trade etc.
assert(table.getn(warps)==0)
run({6,1,1},true,true) -- GM test-map access
assert(table.getn(warps)==1 and warps[1][1]==categories[6].maps[1].x)
print("PASS: 65 map selections, paging, back/cancel, warp guard and GM access; " .. runs .. " runs")
'''
lua = Path(sys.argv[1])
with tempfile.TemporaryDirectory(prefix='ring-warp-test-') as d:
d = Path(d)
sources = [p for p in sorted((lua/'src').glob('*.c')) if p.name != 'ltests.c']
sources += [lua/'src/lib'/name for name in ('lauxlib.c','lbaselib.c','ltablib.c','lstrlib.c','lmathlib.c')]
command = ['cc','-w','-I'+str(lua/'include'),'-I'+str(lua/'src'),str(ROOT/'tests/lua_runner.c'),*[str(p) for p in sources],'-lm','-o',str(d/'lua')]
subprocess.run(command,check=True)
(d/'test.lua').write_bytes(script.encode('gbk'))
subprocess.run([str(d/'lua'),str(d/'test.lua')],check=True)
print('PASS: catalog matches server index, coordinates in bounds, GBK source safe')