diff --git a/data/zh_40250/README.md b/data/zh_40250/README.md index d8261082..5524b337 100644 --- a/data/zh_40250/README.md +++ b/data/zh_40250/README.md @@ -54,6 +54,13 @@ 服务端 `item_proto.txt` 里改过的物品使用限制(每行 VNUM、限制类型 0、值 0、限制类型 1、值 1;0 = 无,1 = 等级), 由 `locale_proto_patch` 写进客户端 `item_proto`,提示框显示的等级要求与服务端一致。其余字段仍逐字节校验不变。 +## 物品占格与标志(item_shapes_server.tsv) + +服务端给了新用途、改过占格或标志的物品(每行 VNUM、size、flags、anti flags,数值),由 `locale_proto_patch` +写进客户端 `item_proto`,背包占格和堆叠与服务端一致;只允许这几条记录的这三个字段变化。目前是 VIP 会员卡 +38001/38002(`tools/server/vip-card`):40250 原为 size 0,背包里不显示也不能移动。 +该表跟在 `item_stones_server.tsv` 后面传入(位置参数),所以需要石头表也存在。 + ## 物品说明(itemdesc_translate_zh.tsv) 40250 `itemdesc.txt` 的说明列(第 3 列)和摘要列(第 4 列,“Potion production”“Research”)按英文原文整句翻译, diff --git a/data/zh_40250/item_names_zh_overlay.txt b/data/zh_40250/item_names_zh_overlay.txt index 7eb81026..ec9808fb 100644 --- a/data/zh_40250/item_names_zh_overlay.txt +++ b/data/zh_40250/item_names_zh_overlay.txt @@ -685,8 +685,8 @@ VNUM LOCALE_NAME 31092 超冷怪的皮肤碎片 35000 药草+ 35001 虎肝+ -38001 VIP特权 -38002 抽奖券(今日) +38001 VIP会员卡(7天) +38002 VIP会员卡(30天) 38003 抽奖券(明日) 38004 抽奖重抽券 38005 高级抽奖转盘券 diff --git a/data/zh_40250/item_shapes_server.tsv b/data/zh_40250/item_shapes_server.tsv new file mode 100644 index 00000000..1ccdaa9c --- /dev/null +++ b/data/zh_40250/item_shapes_server.tsv @@ -0,0 +1,6 @@ +# vnum size flags anti flags: item_proto.txt size/flags changed on our server (tools/server/*), mirrored into the +# client proto by locale_proto_patch so the item takes a cell and stacks like on the server. +# 38001/38002 VIP 会员卡 (tools/server/vip-card): 40250 ships them size 0, no flags; now size 1, +# ITEM_STACKABLE | ITEM_LOG (4 | 8192), ANTI_SELL (256). +38001 1 8196 256 +38002 1 8196 256 diff --git a/scripts/build_zh_locale.py b/scripts/build_zh_locale.py index 4274d03a..9160ccbc 100644 --- a/scripts/build_zh_locale.py +++ b/scripts/build_zh_locale.py @@ -350,11 +350,15 @@ def build(en: Path, zh: Path, output: Path, client_base: Path, limits = names_dir / "item_limits_server.tsv" if (names_dir / "item_stones_server.tsv").is_file() and not limits.is_file(): raise ValueError("stone overlay requires the item limits table argument") + if (names_dir / "item_shapes_server.tsv").is_file() and not (names_dir / "item_stones_server.tsv").is_file(): + raise ValueError("item shapes require the stone overlay argument") result = subprocess.run( [str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"), str(item_names), str(mob_names), str(locale_dir / "item_proto"), str(locale_dir / "mob_proto")] + ([str(limits)] if limits.is_file() else []) + ([str(names_dir / "item_stones_server.tsv")] if (names_dir / "item_stones_server.tsv").is_file() else []), + # item_shapes_server.tsv follows the stone overlay (positional): size and flags of new-use items. + + ([str(names_dir / "item_shapes_server.tsv")] if (names_dir / "item_shapes_server.tsv").is_file() else []), check=True, capture_output=True, text=True, ) report["proto"] = { diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index 26622c98..d71147d3 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -1005,6 +1005,7 @@ namespace PythonBoot #include "DisplayScript.inc" #include "ChannelScript.inc" #include "BossScript.inc" +#include "VipScript.inc" static void install_script_module(const char* c_szModule, const char* c_szScript) { @@ -1084,6 +1085,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error) install_script_module("mt_display", c_szDisplayScript); install_script_module("mt_channel", c_szChannelScript); install_script_module("mt_boss", c_szBossScript); + install_script_module("mt_vip", c_szVipScript); if (g_touch_input) install_script_module("mt_mobile_hud", c_szMobileHUDScript); } diff --git a/src/platform/ScriptLib/VipScript.inc b/src/platform/ScriptLib/VipScript.inc new file mode 100644 index 00000000..8936d388 --- /dev/null +++ b/src/platform/ScriptLib/VipScript.inc @@ -0,0 +1,176 @@ +// PORT: VIP panel. The server's VIP cards (38001 7 days, 38002 30 days, sold by 百店老板娘) extend the seven +// original premium services of the account; this window shows what is left of each, asked with /mt_vip, and +// reports card use in the chat. The premium icons (affects 500..506) are the original ones. +// Protocol: metin2-server docs/FEATURE-VIP.md. +static const char c_szVipScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import chat +import grp +import net +import ui +import game +import weakref +from mt_biolog import Action, Text, L + +g_window=None +g_entry=None +SEND_GAP=1. +TIMEOUT=3. +MAX_DAYS=400 +CARDS={38001:7,38002:30} +# PREMIUM_EXP .. PREMIUM_GOLD, the order of MTVIP1 times. +SERVICES=('经验加成','物品掉落翻倍','仓库扩展到 3 页','金币直接进背包','钓鱼成功率提升','结婚亲密度加快','金币掉落翻倍') +ERRORS={'rate':'操作过快,请稍后再试','invalid':'参数无效','full':'VIP 剩余时间不能超过 %d 天'%MAX_DAYS} + + +def Number(text,low=0,high=2**31-1): + if not text.isdigit() or len(text)>10:raise ValueError(text) + value=int(text) + if not low<=value<=high:raise ValueError(text) + return value + + +def Remaining(seconds): + seconds=int(seconds) + if seconds<=0:return '未开通' + days,rest=divmod(seconds,86400);hours,rest=divmod(rest,3600);minutes=rest//60 + if days:return '剩余 %d 天 %d 小时'%(days,hours) + if hours:return '剩余 %d 小时 %d 分'%(hours,minutes) + return '剩余 %d 分'%max(1,minutes) + + +class Status(object): + # Protocol state only. send(text) posts a chat command, now() is seconds. + def __init__(self,send,now,listener=None): + self.send=send;self.now=now;self.listener=listener + self.waiting=None;self.nextSend=0;self.unsupported=False + self.times=None;self.at=0;self.error=None;self.added=None + + def Query(self): + self.Tick() + now=self.now() + if self.waiting is not None or now=self.waiting: + self.waiting=None;self.unsupported=True;self.Notify('unsupported') + + def Left(self,index): + if self.times is None:return 0 + return max(0,self.times[index]-(self.now()-self.at)) + + def Notify(self,kind): + if self.listener:self.listener(kind) + + def Receive(self,line): + if not line.startswith('MTVIP1 '):return False + words=line.split() + try: + if words[1]=='times' and len(words)==2+len(SERVICES): + self.times=[Number(w) for w in words[2:]];self.at=self.now();self.error=None;kind='times' + elif words[1]=='added' and len(words)==4: + self.added=(Number(words[2]),Number(words[3],1,MAX_DAYS));kind='added' + elif words[1]=='error' and len(words)==3 and words[2] in ERRORS:self.error=words[2];kind='error' + else:return True + except ValueError:return True + if kind!='added':self.waiting=None + self.unsupported=False;self.Notify(kind) + return True + + +class VipWindow(ui.Window): + def __init__(self,status): + ui.Window.__init__(self);self.SetWindowName('VipWindow');self.AddFlag('float');self.AddFlag('movable') + self.status=status;self.SetSize(332,118+len(SERVICES)*24) + self.heading=Text(self,18,18,'VIP 会员',0xffffd68b) + self.closeButton=Action(self,256,6,60,'关闭',self.Hide) + self.names=[Text(self,18,64+i*24,name,0xffe8dec8) for i,name in enumerate(SERVICES)] + self.values=[Text(self,190,64+i*24,'',0xffc3b7a1) for i in range(len(SERVICES))] + self.note=Text(self,18,self.GetHeight()-44,'',0xffc3b7a1) + self.hint=Text(self,18,self.GetHeight()-22,'VIP 卡在各村百店老板娘处购买,使用后全部特权延长',0xff8f8676) + self.second=-1 + self.Hide() + + def Open(self): + self.status.Query();self.Refresh();self.SetCenterPosition();self.Show();self.SetTop() + + def Toggle(self): + if self.IsShow():self.Hide() + else:self.Open() + + def Refresh(self): + s=self.status + for i,value in enumerate(self.values): + left=s.Left(i) + value.SetText(L(Remaining(left) if s.times is not None else '……')) + value.SetPackedFontColor(0xff9fe08b if left>0 else 0xff8f8676) + if s.unsupported:note='!服务器尚未启用 VIP 卡' + elif s.error:note='!'+ERRORS[s.error] + elif s.times is None:note='正在查询……' + elif any(s.Left(i)>0 for i in range(len(SERVICES))):note='VIP 生效中' + else:note='尚未开通 VIP' + self.note.SetText(L(note)) + + def OnUpdate(self): + self.status.Tick() + second=int(app.GetTime()) + if second!=self.second:self.second=second;self.Refresh() + + def OnRender(self): + x,y=self.GetGlobalPosition();grp.SetColor(0xf51b1916);grp.RenderBar(x,y,self.GetWidth(),self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,self.GetWidth(),self.GetHeight()) + + def OnPressEscapeKey(self):self.Hide();return True + + def Destroy(self): + self.Hide();self.closeButton.event=None + + +class Entry(Action): + def __init__(self,window): + Action.__init__(self,None,0,0,64,'VIP',Toggle) + self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('VipEntry');self.OnUpdate() + + def OnUpdate(self): + x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+408) + + +def OnStatus(kind): + s=g_status + if kind=='added': + vnum,days=s.added + chat.AppendChat(chat.CHAT_TYPE_INFO,L('VIP 会员已延长 %d 天,全部特权立即生效'%days)) + elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[s.error])) + if g_window:g_window.Refresh() + + +g_status=Status(net.SendChatPacket,app.GetTime,OnStatus) + + +def Toggle(): + if g_window:g_window.Toggle() + + +_open=game.GameWindow.Open +_close=game.GameWindow.Close +_command=game.GameWindow.BINARY_ServerCommand_Run + +def Open(self): + global g_window,g_entry + _open(self);g_status.waiting=None;g_status.times=None;g_window=VipWindow(g_status);g_entry=Entry(self) + +def Close(self): + global g_window,g_entry + if g_entry:g_entry.Hide();g_entry.event=None;g_entry=None + if g_window:g_window.Destroy();g_window=None + _close(self) + +def Command(self,line): + if g_status.Receive(line):return 1 + return _command(self,line) + +game.GameWindow.Open=Open +game.GameWindow.Close=Close +game.GameWindow.BINARY_ServerCommand_Run=Command +)PY"; diff --git a/tests/port/locale_proto_patch.cpp b/tests/port/locale_proto_patch.cpp index f00096b0..82e89076 100644 --- a/tests/port/locale_proto_patch.cpp +++ b/tests/port/locale_proto_patch.cpp @@ -2,7 +2,9 @@ // Runtime loading remains the original 40250 path and binary format. // An optional item limits table (vnum, limit type 0, value 0, limit type 1, value 1) mirrors the few // item_proto.txt limit changes made on our server (tools/server/*), so the tooltip states what the -// server enforces. An optional 14-row stone overlay restores extracted +6 stone data. +// server enforces. An optional 14-row stone overlay restores extracted +6 stone data. An optional shape table +// (vnum, size, flags, anti flags) mirrors items the server gives a new use (tools/server/vip-card), so they +// take a cell, stack and refuse NPC sale as the server does. #include "GameLib/StdAfx.h" #include "GameLib/ItemData.h" #include "UserInterface/StdAfx.h" @@ -25,6 +27,7 @@ namespace { using Item = CItemData::TItemTable; using Mob = CPythonNonPlayer::TMobTable; std::map> stones; +std::map> shapes; constexpr std::array item_key{{173217, 72619434, 408587239, 27973291}}; constexpr std::array mob_key{{4813894, 18955, 552631, 6822045}}; @@ -109,6 +112,12 @@ void apply_limits(Item& record, const std::map& limits, std record.aApplies[0].bType = s[3]; record.aApplies[0].lValue = s[4]; for (int i = 0; i < 6; ++i) record.alValues[i] = s[5+i]; } + const auto shape = shapes.find(record.dwVnum); + if (shape != shapes.end()) { + record.bSize = static_cast(shape->second[0]); + record.dwFlags = shape->second[1]; + record.dwAntiFlags = shape->second[2]; + } const auto found = limits.find(record.dwVnum); if (found == limits.end()) return; std::memcpy(record.aLimits, found->second.data(), sizeof(record.aLimits)); @@ -120,6 +129,8 @@ bool limits_field(std::size_t, DWORD, const std::map&) { return f template <> bool limits_field(std::size_t offset, DWORD vnum, const std::map& limits) { + if (shapes.count(vnum) && (offset == offsetof(Item, bSize) || + (offset >= offsetof(Item, dwAntiFlags) && offset < offsetof(Item, dwFlags)+sizeof(Item::dwFlags)))) return true; if (stones.count(vnum) && (offset == offsetof(Item, bType) || offset == offsetof(Item, bSubType) || (offset >= offsetof(Item, dwWearFlags) && offset < offsetof(Item, dwWearFlags)+sizeof(Item::dwWearFlags)) || (offset >= offsetof(Item, aApplies) && offset < offsetof(Item, aApplies)+sizeof(Item::aApplies)) || @@ -199,14 +210,25 @@ std::vector patch(const char* source, const char* names_path, } // namespace int main(int argc, char** argv) { - if (argc != 7 && argc != 8 && argc != 9) { + if (argc < 7 || argc > 10) { std::cerr << "usage: locale_proto_patch item_proto mob_proto item_names_gbk mob_names_gbk out_item out_mob" - " [item_limits [item_stones]]\n"; + " [item_limits [item_stones [item_shapes]]]\n"; return 2; } try { static CLZO lzo; - if (argc == 9) { + if (argc == 10) { + std::ifstream stream(argv[9]); + if (!stream) throw std::runtime_error("cannot read item shapes"); + std::string line; + while (std::getline(stream, line)) { + if (line.empty() || line[0] == '#') continue; + std::istringstream row(line); DWORD vnum; std::array values{}; + if (!(row >> vnum >> values[0] >> values[1] >> values[2]) || values[0] < 1 || values[0] > 3 || + !shapes.emplace(vnum, values).second) throw std::runtime_error("bad item shape row: " + line); + } + } + if (argc >= 9) { std::ifstream stream(argv[8]); if (!stream) throw std::runtime_error("cannot read stone overlay"); std::string line; diff --git a/tests/wiki/test_vip.py b/tests/wiki/test_vip.py new file mode 100644 index 00000000..f5ad8485 --- /dev/null +++ b/tests/wiki/test_vip.py @@ -0,0 +1,125 @@ +import re +import sys +import types +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] + +def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0] + + +class Widget(object): + def __init__(self,*a,**k):self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0);self.color=0 + def __getattr__(self,name):return lambda *a,**k:None + def Show(self):self.shown=True + def Hide(self):self.shown=False + def IsShow(self):return self.shown + def SetText(self,text):self.text=text + def SetPackedFontColor(self,color):self.color=color + def SetPosition(self,x,y):self.pos=(x,y) + def GetGlobalPosition(self):return self.pos + def SetSize(self,w,h):self.size=(w,h) + def GetWidth(self):return self.size[0] + def GetHeight(self):return self.size[1] + + +class Action(Widget): + def __init__(self,parent,x,y,width,text,event): + Widget.__init__(self);self.pos=(x,y);self.event=event;self.text=text;self.shown=True + + +class Minimap(Widget): + def GetGlobalPosition(self):return (700,10) + + +class VipTests(unittest.TestCase): + def setUp(self): + self.time=100.;self.sent=[];self.chat=[] + mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','net','ui','mt_biolog')} + mods['app'].GetTime=lambda:self.time + mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m) + mods['net'].SendChatPacket=self.sent.append + mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None, + BINARY_ServerCommand_Run=lambda s,line:7)) + grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None + mods['ui'].Window=Widget + mods['mt_biolog'].Action=Action;mods['mt_biolog'].L=lambda t:t + mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:self.text(t,c) + self.previous={n:sys.modules.get(n) for n in mods} + sys.modules.update(mods) + self.ns={'__name__':'mt_vip'};exec(script('VipScript.inc'),self.ns) + self.status=self.ns['Status'](self.sent.append,lambda:self.time) + + def text(self,t,c): + w=Widget();w.text=t;w.color=c;return w + + def tearDown(self): + for name,value in self.previous.items(): + if value is None:sys.modules.pop(name,None) + else:sys.modules[name]=value + + def test_query_parses_times_and_counts_down(self): + self.assertTrue(self.status.Query());self.assertFalse(self.status.Query()) + self.assertEqual(self.sent,['/mt_vip 1 status']) + self.assertTrue(self.status.Receive('MTVIP1 times 604800 604800 0 604800 604800 604800 90')) + self.assertIsNone(self.status.waiting);self.assertEqual(self.status.Left(2),0) + self.time+=100;self.assertEqual(self.status.Left(0),604700);self.assertEqual(self.status.Left(6),0) + self.time+=.5;self.assertTrue(self.status.Query()) # past the send gap + + def test_added_error_malformed_and_foreign(self): + events=[];self.status.listener=events.append + self.status.Receive('MTVIP1 added 38002 30');self.assertEqual(self.status.added,(38002,30)) + self.status.Receive('MTVIP1 error full');self.assertEqual(self.status.error,'full') + for bad in ('MTVIP1 times 1 2 3','MTVIP1 times 1 2 3 4 5 6 x','MTVIP1 times 1 2 3 4 5 6 -7','MTVIP1 added 38001 0', + 'MTVIP1 added 38001 401','MTVIP1 error boom','MTVIP1 boom'): + self.assertTrue(self.status.Receive(bad)) + self.assertEqual(events,['added','error']);self.assertIsNone(self.status.times) + self.assertFalse(self.status.Receive('MTCH1 error kind')) + + def test_timeout_marks_unsupported(self): + events=[];self.status.listener=events.append + self.status.Query();self.time+=3.1;self.status.Tick() + self.assertTrue(self.status.unsupported);self.assertEqual(events,['unsupported']) + self.status.Receive('MTVIP1 times 0 0 0 0 0 0 0');self.assertFalse(self.status.unsupported) + + def test_remaining_text(self): + R=self.ns['Remaining'] + self.assertEqual(R(0),'未开通');self.assertEqual(R(-5),'未开通') + self.assertEqual(R(7*86400+3*3600+59),'剩余 7 天 3 小时') + self.assertEqual(R(5*3600+120),'剩余 5 小时 2 分');self.assertEqual(R(30),'剩余 1 分') + + def test_window_entry_and_chat(self): + game=sys.modules['game'];gw=game.GameWindow() + gw.interface=types.SimpleNamespace(wndMiniMap=Minimap()) + game.GameWindow.Open(gw);w=self.ns['g_window'];entry=self.ns['g_entry'] + self.assertEqual(entry.pos,(632,418)) + entry.event();self.assertTrue(w.IsShow());self.assertEqual(self.sent,['/mt_vip 1 status']) + self.assertEqual(w.note.text,'正在查询……');self.assertEqual(w.values[0].text,'……') + self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 times 0 0 0 0 0 0 0'),1) + self.assertEqual(w.note.text,'尚未开通 VIP');self.assertEqual(w.values[3].text,'未开通') + self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 added 38001 7'),1) + self.assertEqual(self.chat,['VIP 会员已延长 7 天,全部特权立即生效']) + game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 times 604800 604800 604800 604800 604800 604800 604800') + self.assertEqual(w.values[6].text,'剩余 7 天 0 小时');self.assertEqual(w.note.text,'VIP 生效中') + game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 error full') + self.assertEqual(self.chat[-1],'VIP 剩余时间不能超过 400 天');self.assertIn('400',w.note.text) + self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'other'),7) + entry.event();self.assertFalse(w.IsShow()) + self.time+=2;entry.event();self.time+=3.1;w.OnUpdate() + self.assertEqual(w.note.text,'!服务器尚未启用 VIP 卡') + game.GameWindow.Close(gw);self.assertIsNone(self.ns['g_window']);self.assertIsNone(self.ns['g_entry']) + + def test_server_constants_match(self): + server=ROOT.parent/'metin2-server/src/server/game/src/port_premium.h' + if not server.exists():self.skipTest('server checkout not installed') + text=server.read_text(encoding='latin1') + self.assertIn('kPortVipMaxDays=400',text);self.assertEqual(self.ns['MAX_DAYS'],400) + self.assertIn('kPortVipGapMs=1000',text);self.assertEqual(self.ns['SEND_GAP'],1.) + cards={int(v):int(d) for v,d in re.findall(r'\{(\d+),(\d+)\}',text.split('kPortVipCards',1)[1].split(';',1)[0])} + self.assertEqual(cards,self.ns['CARDS']) + self.assertEqual(text.count('_expire"'),len(self.ns['SERVICES'])) + self.assertTrue(set(re.findall(r'MTVIP1 error ([a-z]+)',text))<=set(self.ns['ERRORS'])) + + +if __name__=='__main__':unittest.main() diff --git a/tools/server/npc-shops/README.md b/tools/server/npc-shops/README.md index b571ab99..4860fc5d 100644 --- a/tools/server/npc-shops/README.md +++ b/tools/server/npc-shops/README.md @@ -46,6 +46,7 @@ | +6 镶嵌石(28700~28713,全部 14 种,宝石页) | 各 1 | | 移动戒指(不限次数、无冷却,见 `../ring-warp`) | 1 | | 追加物品属性秘籍、龙神的祝福书 | 各 100 万 | +| VIP 会员卡 7 天 / 30 天(7 项会员特权全部延长,见 `../vip-card`) | 500 万 / 1800 万 | ## 坐骑(mount) diff --git a/tools/server/npc-shops/item-mall/items.tsv b/tools/server/npc-shops/item-mall/items.tsv index a6e9d3e1..63388026 100644 --- a/tools/server/npc-shops/item-mall/items.tsv +++ b/tools/server/npc-shops/item-mall/items.tsv @@ -43,6 +43,8 @@ 71032 1 1000000 龙神的祝福书 70058 1 1 移动戒指 不限次数、无冷却(../../ring-warp) 50307 1 0 小狩猎套装礼包 1级+9全套、满属性(../../hunt-set) +38001 1 5000000 VIP会员卡 7天,7项会员特权全部延长(../../vip-card) +38002 1 18000000 VIP会员卡 30天(../../vip-card) 71052 1 1 超级属性转换秘籍 71051 1 1 超级属性追加秘籍 [宝石] diff --git a/tools/server/rebuild.sh b/tools/server/rebuild.sh index 1fafb7ad..99e14385 100644 --- a/tools/server/rebuild.sh +++ b/tools/server/rebuild.sh @@ -106,6 +106,7 @@ data) sh "$HERE/ring-warp/install.sh" sh "$HERE/support-skills/install.sh" sh "$HERE/hunt-set/install.sh" + sh "$HERE/vip-card/install.sh" sh "$HERE/npc-shops/install.sh" "$HERE/npc-shops/item-mall" sh "$HERE/npc-shops/install.sh" "$HERE/npc-shops/mount" start_server diff --git a/tools/server/vip-card/README.md b/tools/server/vip-card/README.md new file mode 100644 index 00000000..5903a11a --- /dev/null +++ b/tools/server/vip-card/README.md @@ -0,0 +1,42 @@ +# VIP 会员卡(203 服务端) + +服务端原有 7 种会员特权(`account.account` 的 `*_expire` 列,登录时读入,左上角图标 affect 500~506): + +| 序号 | 特权 | 列 | +|---|---|---| +| 0 | 经验加成 | `silver_expire` | +| 1 | 物品掉落翻倍 | `gold_expire` | +| 2 | 仓库扩展到 3 页 | `safebox_expire` | +| 3 | 金币直接进背包 | `autoloot_expire` | +| 4 | 钓鱼成功率提升 | `fish_mind_expire` | +| 5 | 结婚亲密度加快 | `marriage_fast_expire` | +| 6 | 金币掉落翻倍 | `money_drop_rate_expire` | + +40250 里没有游戏内开通的途径。VIP 卡用 40250 没用到的两个物品: + +| 物品 | 名称 | 时长 | 百店老板娘「道具」页价格 | +|---|---|---|---| +| 38001 | VIP会员卡(7天) | 7 天 | 500 万金币 | +| 38002 | VIP会员卡(30天) | 30 天 | 1800 万金币 | + +价格在 `../npc-shops/item-mall/items.tsv`,改完重跑 npc-shops 的 install.sh 即可。 + +右键使用:7 项特权同时从「现在」和「当前到期时间」中较晚的那个往后延长,立即生效(图标、自动拾取、仓库页数)。 +剩余时间加上卡的时长超过 400 天时拒绝,卡不扣。两次使用至少间隔 1 秒。客户端小地图旁的「VIP」按钮打开面板, +显示每项剩余时间。协议见 metin2-server `docs/FEATURE-VIP.md`。 + +## 经验加成 +100% + +newcibn 的会员经验在 `char_battle.cpp` 里固定 ×1.2,之后再乘 `GetMobExpRate` 返回的事件标记 +`mob_exp_buyer`(默认 100)。`/e mob_exp_buyer 167` 后 1.2 × 1.67 ≈ 2.0,即 +100%。事件标记存在 +`player.quest`(pid 0),所有核心共享,部署时用 GM 角色设一次即可,重启后仍在。 + +## 文件 + +- `install.sh`:`item_proto.txt` 里 38001/38002 改为占 1 格、可堆叠、记日志、不能卖给 NPC(40250 原来是 + size 0,两张卡会叠在同一格、无法移动),服务端改名(`item_names.txt` + `item_proto.locale_name`,GB2312)。 + 需要重启 db 和 game。备份在 `/root/backup-vip-card/<时间>/`。 +- 游戏端代码在 metin2-server(`port_premium.h`,分支 feature/vip-card)。 + +客户端:名称 `data/zh_40250/item_names_zh_overlay.txt`;占格和堆叠 `item_shapes_server.tsv`(由 +`locale_proto_patch` 写进客户端 item_proto,与服务端一致),由 `scripts/build_zh_locale.py` 打进 locale_zh。 diff --git a/tools/server/vip-card/install.sh b/tools/server/vip-card/install.sh new file mode 100644 index 00000000..dcedb68e --- /dev/null +++ b/tools/server/vip-card/install.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Installs the VIP cards (README.md): run on the server from this directory with a game built from +# metin2-server feature/vip-card (port_premium.h), then restart db + game. 40250 ships 38001/38002 as unused +# ITEM_NONE rows of size 0, which the inventory cannot hold (two of them share a cell, neither can move): this +# gives them one cell, stacking, an item log and no NPC sale, and names them on the server (item_names.txt and +# item_proto.locale_name; the client has its own names and the same size/flags, data/zh_40250). +set -eu + +SERVER=${SERVER:-/usr/metin2/server} +PROTO="$SERVER/share/conf/item_proto.txt" +NAMES="$SERVER/share/conf/item_names.txt" +BACKUP=/root/backup-vip-card/$(date +%Y%m%d-%H%M%S) +export LC_ALL=C + +mkdir -p "$BACKUP" +cp -p "$PROTO" "$NAMES" "$BACKUP/" +mysqldump -uroot player item_proto --where="vnum in (38001,38002)" > "$BACKUP/item_proto-rows.sql" +echo "backup: $BACKUP" + +# item_proto.txt: column 5 size, 6 anti flags, 7 flags. +awk -F'\t' -v OFS='\t' '$1 == 38001 || $1 == 38002 { $5 = 1; $6 = "ANTI_SELL"; $7 = "ITEM_STACKABLE | LOG"; n++ } { print } + END { if (n != 2) { print "item_proto.txt: " n " of 2 cards found" > "/dev/stderr"; exit 1 } }' \ + "$PROTO" > "$BACKUP/item_proto.new" +cat "$BACKUP/item_proto.new" > "$PROTO" + +week=$(printf 'VIP会员卡(7天)' | iconv -f UTF-8 -t GB2312) +month=$(printf 'VIP会员卡(30天)' | iconv -f UTF-8 -t GB2312) +awk -F'\t' -v OFS='\t' -v week="$week" -v month="$month" '$1 == 38001 { $2 = week; n++ } $1 == 38002 { $2 = month; n++ } { print } + END { if (n != 2) { print "item_names.txt: " n " of 2 cards found" > "/dev/stderr"; exit 1 } }' \ + "$NAMES" > "$BACKUP/item_names.new" +cat "$BACKUP/item_names.new" > "$NAMES" + +# ITEM_STACKABLE | ITEM_LOG = 4 | 8192, ITEM_ANTIFLAG_SELL = 256. +mysql -uroot player -e "UPDATE item_proto SET size = 1, flag = 8196, antiflag = 256, locale_name = '$week' WHERE vnum = 38001; + UPDATE item_proto SET size = 1, flag = 8196, antiflag = 256, locale_name = '$month' WHERE vnum = 38002" +echo "done; restart db and game"