Add feature queries, dungeon information and event calendar UI
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
using namespace mtnet::classic;
|
||||
|
||||
@@ -911,6 +912,48 @@ bool FakeLoginServer::ServeGame(Connection& c, int index)
|
||||
if (!c.Send(chat) || !c.Send(cmd.c_str(), cmd.size() + 1)) return Fail(name + ": biolog reply");
|
||||
}
|
||||
}
|
||||
else if (text.rfind("/mt_features ",0)==0) {
|
||||
std::istringstream in(text);std::string command,kind;unsigned version=0,request=0,subject=0,offset=0;
|
||||
in>>command>>version>>request>>kind>>subject>>offset;
|
||||
auto reply=[&](const std::string& cmd) {
|
||||
GCChatHead chat={HDR_GC_CHAT,static_cast<std::uint16_t>(sizeof(GCChatHead)+cmd.size()+1),5,0,1};
|
||||
return c.Send(chat)&&c.Send(cmd.c_str(),cmd.size()+1);
|
||||
};
|
||||
const std::string r=std::to_string(request);
|
||||
if(kind=="cap") {if(!reply("MTF1 cap "+r+" 1 63 1700000000"))return Fail(name+": feature cap");}
|
||||
else if(kind=="calendar") {
|
||||
if(!reply("MTF1 begin "+r+" calendar 1 0 2 0 1700000000") ||
|
||||
!reply("MTF1 event "+r+" 0 1 1 1700000000 1700003600 0 0 1") ||
|
||||
!reply("MTF1 event "+r+" 1 2 2 1700086400 1700090000 2 0 0") ||
|
||||
!reply("MTF1 end "+r+" 2"))return Fail(name+": calendar");
|
||||
}
|
||||
else if(kind=="dungeons") {
|
||||
const std::pair<const char*,long long> values[]={
|
||||
{"lair_end",1700003600},{"lair_join_end",1700000300},{"lair_phase",1},
|
||||
{"lair_ticket",30179},{"lair_tickets",3},{"lair_boss",2430},{"lair_owned",7},
|
||||
{"lair_alive",1},{"lair_npc",30121},{"lair_map",208},
|
||||
{"weekly_end",1700561600},{"weekly_hunt_end",1700007200},{"weekly_active",1},
|
||||
{"weekly_npc",30122},{"weekly_map",73}};
|
||||
if(!reply("MTF1 begin "+r+" dungeons 0 0 15 0 1700000000"))return Fail(name+": dungeon begin");
|
||||
for(unsigned i=0;i<15;++i)
|
||||
if(!reply("MTF1 stat "+r+" "+std::to_string(i)+" "+values[i].first+" "+std::to_string(values[i].second)))return Fail(name+": dungeon stat");
|
||||
if(!reply("MTF1 end "+r+" 15"))return Fail(name+": dungeon end");
|
||||
}
|
||||
else if(kind=="stats") {
|
||||
if(!reply("MTF1 begin "+r+" stats 0 0 3 0 1700000000") ||
|
||||
!reply("MTF1 stat "+r+" 0 maxhp 1500") || !reply("MTF1 stat "+r+" 1 stone 10") ||
|
||||
!reply("MTF1 stat "+r+" 2 resist_human 4") || !reply("MTF1 end "+r+" 3"))return Fail(name+": feature stats");
|
||||
} else {
|
||||
unsigned total=kind=="mob"?10:kind=="chest"?2:1;
|
||||
if(!reply("MTF1 begin "+r+" "+kind+" "+std::to_string(subject)+" "+std::to_string(offset)+" "+std::to_string(total)+" "+(kind=="chest"?"10":"0")+" 1700000000"))return Fail(name+": feature begin");
|
||||
unsigned sent=0;
|
||||
for(unsigned i=offset;i<total && sent<8;++i,++sent) {
|
||||
std::string row=kind=="mob"?"30006 1 1 0 0 100 0 0":kind=="sources"?"101 1 0 1 30 100 0 0":i==0?"19 1 10 0 0 80 100 0":"1 500 10 0 0 20 100 0";
|
||||
if(!reply("MTF1 row "+r+" "+std::to_string(i)+" "+row))return Fail(name+": feature row");
|
||||
}
|
||||
if(!reply("MTF1 end "+r+" "+std::to_string(sent)))return Fail(name+": feature end");
|
||||
}
|
||||
}
|
||||
else if (text == "/server_bonuses") {
|
||||
const std::string cmd = "ServerBonuses 200 200 200 200 200 0 200 0";
|
||||
GCChatHead chat = {HDR_GC_CHAT, static_cast<std::uint16_t>(sizeof(GCChatHead) + cmd.size() + 1), 5, 0, 1};
|
||||
|
||||
@@ -560,7 +560,7 @@ int main(int argc, char** argv)
|
||||
if (!live && std::getenv("MT_TEST_WIKI")) {
|
||||
CHECK(py("import mt_wiki, mt_wiki_data, mt_biolog, player\n"
|
||||
"assert mt_wiki.g_window and mt_wiki.g_entry\n"
|
||||
"assert not mt_wiki.CAPABILITIES['live_drops']\n"
|
||||
"assert mt_wiki.ServerCapabilities()['live_drops'] is None\n"
|
||||
"mt_wiki.g_entry.OnUpdate()"));
|
||||
std::string xy;
|
||||
CHECK(PythonBoot::Evaluate("'%d %d' % mt_wiki.g_entry.GetGlobalPosition()", &xy, nullptr));
|
||||
@@ -605,6 +605,41 @@ int main(int argc, char** argv)
|
||||
"_missing.Open()\nassert _missing.error and not _missing.catalogue\n"
|
||||
"_missing.Destroy()\nmt_wiki.PATH=_saved_path"));
|
||||
PythonBoot::SetUISize(800,600);
|
||||
CHECK(py("_wiki.Open(30006)\n_wiki.QueryRemote('sources',30006)"));
|
||||
CHECK(pump_until(7,[]{return py_true("_wiki.remoteData is not None");}));
|
||||
CHECK(py("assert mt_wiki.ServerCapabilities()['live_drops']\nassert _wiki.remoteData['rows'][0][0]==101\n"
|
||||
"_wiki.Follow(next(i for i,p in enumerate(_wiki.links) if isinstance(p[1],tuple) and p[1][0]=='mobs'))\n"
|
||||
"assert _wiki.selected==('mobs',101)\n_wiki.QueryRemote('mob',101)"));
|
||||
CHECK(pump_until(5,[]{return py_true("_wiki.remoteData is not None");}));
|
||||
CHECK(py("assert _wiki.remoteData['total']==10 and len(_wiki.remoteData['rows'])==8\n_wiki.QueryRemote('mob',101,8)"));
|
||||
CHECK(pump_until(5,[]{return py_true("_wiki.remoteData is not None and _wiki.remoteData['offset']==8");}));
|
||||
CHECK(py("assert len(_wiki.remoteData['rows'])==2\n_wiki.Back()\n_wiki.QueryRemote('chest',50011)"));
|
||||
CHECK(pump_until(5,[]{return py_true("_wiki.remoteData is not None and _wiki.remoteData['kind']=='chest'");}));
|
||||
CHECK(py("assert _wiki.remoteData['mode']==10\nassert _wiki.remoteData['rows'][1][0]==1\n"
|
||||
"assert any(p[0].startswith(u'金币') for p in _wiki.links)\n_wiki.Hide()\n"
|
||||
"import mt_details\nmt_details.Toggle()"));
|
||||
CHECK(pump_until(5,[]{return py_true("mt_details.g_window.values is not None");}));
|
||||
CHECK(py("assert mt_details.g_window.values['stone']==10\n"
|
||||
"assert mt_details.g_window.values['maxhp']==1500\n"
|
||||
"assert mt_details.g_window.values['resist_human']==4\nmt_details.g_window.Hide()"));
|
||||
CHECK(count_syserr("mt_feature_service: install failed")==0);
|
||||
CHECK(py("import mt_dungeons\nmt_dungeons.Toggle()"));
|
||||
CHECK(pump_until(5,[]{return py_true("mt_dungeons.g_window.values is not None");}));
|
||||
CHECK(py("assert mt_wiki.ServerCapabilities()['dungeons']\n"
|
||||
"_dungeon=mt_dungeons.g_window\nassert _dungeon.values['lair_tickets']==3\n"
|
||||
"assert _dungeon.values['weekly_end']>_dungeon.values['lair_end']\n"
|
||||
"_dungeon.Remind()\nassert _dungeon.reminder[0]==0\n"
|
||||
"_dungeon.Select(1)\n_dungeon.Entry()\n_dungeon.Hide()"));
|
||||
CHECK(py("import mt_calendar\nmt_calendar.Toggle()"));
|
||||
CHECK(pump_until(5,[]{return py_true("mt_calendar.g_window.data is not None");}));
|
||||
CHECK(py("assert mt_wiki.ServerCapabilities()['calendar']\n"
|
||||
"_calendar=mt_calendar.g_window\nassert _calendar.data['rows'][0][6]==1\n"
|
||||
"_calendar.Watch(1)\nassert _calendar.watch['id']==2\n"
|
||||
"_calendar.Remind()\nassert _calendar.watch is None\n_calendar.Hide()"));
|
||||
CHECK(count_syserr("mt_calendar: install failed")==0);
|
||||
CHECK(count_syserr("mt_dungeons: install failed")==0);
|
||||
CHECK(count_syserr("mt_details: install failed")==0);
|
||||
CHECK(count_syserr("mt_wiki_entries: install failed")==0);
|
||||
CHECK(count_syserr("mt_wiki: install failed")==0);
|
||||
CHECK(count_syserr("mt_wiki_data: install failed")==0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import sys
|
||||
import ast
|
||||
import re
|
||||
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 ServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.time=1.;self.sent=[];self.replies=[]
|
||||
app=types.ModuleType('app');app.GetTime=lambda:self.time
|
||||
net=types.ModuleType('net');net.SendChatPacket=self.sent.append
|
||||
game=types.ModuleType('game');game.GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,BINARY_ServerCommand_Run=lambda s,line:7))
|
||||
ui=types.ModuleType('ui');ui.Window=object
|
||||
data=types.ModuleType('mt_wiki_data');exec(script('WikiDataScript.inc'),data.__dict__)
|
||||
self.previous={n:sys.modules.get(n) for n in ('app','net','game','ui','mt_wiki_data')}
|
||||
sys.modules.update(app=app,net=net,game=game,ui=ui,mt_wiki_data=data)
|
||||
self.ns={};exec(script('FeatureServiceScript.inc'),self.ns)
|
||||
self.s=self.ns['Service']()
|
||||
|
||||
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 request(self,kind='mob',subject=101,offset=0):
|
||||
rid=self.s.Request(kind,subject,offset,lambda result,error:self.replies.append((result,error)))
|
||||
self.time+=.6;self.s.Tick()
|
||||
if self.s.pending['kind']=='cap':
|
||||
cap=self.s.pending['id'];self.s.Receive('MTF1 cap %d 1 15 1700000000'%cap)
|
||||
self.time+=.4;self.s.Tick()
|
||||
return rid
|
||||
|
||||
def test_capability_handshake_atomic_page_and_stale_replies(self):
|
||||
rid=self.request()
|
||||
self.assertEqual(len(self.sent),2)
|
||||
self.s.Receive('MTF1 begin %d mob 101 0 1 0 1700000000'%rid)
|
||||
self.s.Receive('MTF1 row %d 0 30006 1 1 0 0 100 0 0'%(rid+100))
|
||||
self.assertFalse(self.replies)
|
||||
self.s.Receive('MTF1 row %d 0 30006 1 1 0 0 100 0 0'%rid)
|
||||
self.assertFalse(self.replies)
|
||||
self.s.Receive('MTF1 end %d 1'%rid)
|
||||
self.assertEqual(self.replies[0][0]['rows'][0][0],30006)
|
||||
self.assertIsNone(self.replies[0][1])
|
||||
|
||||
def test_old_server_timeout_is_unsupported_not_empty_results(self):
|
||||
self.s.Request('mob',101,0,lambda d,e:self.replies.append((d,e)))
|
||||
self.time+=1;self.s.Tick();self.time+=5;self.s.Tick();self.time+=1;self.s.Tick()
|
||||
self.assertEqual(self.replies,[(None,'unsupported')])
|
||||
self.assertEqual(len(self.sent),1)
|
||||
|
||||
def test_partial_duplicate_and_bad_numeric_rejected(self):
|
||||
for bad in ('MTF1 end {r} 1','MTF1 row {r} 1 19 1 1 0 0 1 0 0','MTF1 row {r} 0 19 -1 1 0 0 1 0 0'):
|
||||
rid=self.request()
|
||||
self.s.Receive('MTF1 begin %d mob 101 0 1 0 1700000000'%rid)
|
||||
self.s.Receive(bad.format(r=rid))
|
||||
self.assertEqual(self.replies[-1],(None,'bad_reply'))
|
||||
|
||||
def test_stats_signed_values_and_complete_snapshot(self):
|
||||
rid=self.request('stats',0)
|
||||
self.s.Receive('MTF1 begin %d stats 0 0 2 0 1700000000'%rid)
|
||||
self.s.Receive('MTF1 stat %d 0 stone -10'%rid);self.s.Receive('MTF1 stat %d 1 maxhp 1500'%rid)
|
||||
self.s.Receive('MTF1 end %d 2'%rid)
|
||||
self.assertEqual(dict(self.replies[-1][0]['rows']),dict(stone=-10,maxhp=1500))
|
||||
|
||||
def test_rate_retries_bounded_and_close_cancels_callbacks(self):
|
||||
rid=self.request()
|
||||
for i in range(3):
|
||||
self.s.Receive('MTF1 error %d rate'%rid)
|
||||
self.time+=1;self.s.Tick()
|
||||
self.assertEqual(self.replies[-1],(None,'rate'))
|
||||
rid=self.request();self.s.Close();self.s.Receive('MTF1 end %d 0'%rid)
|
||||
self.assertEqual(len(self.replies),1)
|
||||
|
||||
def test_stats_keys_match_current_server_not_ava2_point_numbers(self):
|
||||
path=ROOT.parent/'metin2-server/src/server/game/src/port_features.h'
|
||||
if not path.exists():self.skipTest('server checkout not installed')
|
||||
tree=ast.parse(script('DetailsScript.inc'))
|
||||
layout=next(ast.literal_eval(n.value) for n in tree.body if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='STAT_LAYOUT' for t in n.targets))
|
||||
keys=re.findall(r'\{"([a-z_]+)",POINT_[A-Z_]+\}',path.read_text())
|
||||
self.assertEqual(set(keys),{r[1] for r in layout})
|
||||
self.assertEqual(len(keys),len(set(keys)))
|
||||
|
||||
def test_dungeon_capability_snapshot_and_partial_rejection(self):
|
||||
self.s.mask=31
|
||||
rows=dict(lair_end=1700003600,lair_join_end=1700000300,lair_phase=1,lair_ticket=30179,
|
||||
lair_tickets=3,lair_boss=2430,lair_owned=7,lair_alive=1,lair_npc=30121,lair_map=208)
|
||||
for valid in (True,False):
|
||||
rid=self.request('dungeons',0)
|
||||
self.assertTrue(self.sent[-1].endswith(' dungeons'))
|
||||
values=list(rows.items()) if valid else list(rows.items())[:-1]
|
||||
self.s.Receive('MTF1 begin %d dungeons 0 0 %d 0 1700000000'%(rid,len(values)))
|
||||
for i,(k,v) in enumerate(values):self.s.Receive('MTF1 stat %d %d %s %d'%(rid,i,k,v))
|
||||
self.s.Receive('MTF1 end %d %d'%(rid,len(values)))
|
||||
if valid:self.assertEqual(dict(self.replies[-1][0]['rows']),rows)
|
||||
else:self.assertEqual(self.replies[-1],(None,'bad_reply'))
|
||||
|
||||
def test_calendar_real_interval_rows_and_invalid_scope(self):
|
||||
self.s.mask=63
|
||||
for scope,expected in ((0,None),(256,'bad_reply')):
|
||||
rid=self.request('calendar',1)
|
||||
self.s.Receive('MTF1 begin %d calendar 1 0 1 0 1700000000'%rid)
|
||||
self.s.Receive('MTF1 event %d 0 1 1 1700000000 1700003600 %d 0 1'%(rid,scope))
|
||||
self.s.Receive('MTF1 end %d 1'%rid)
|
||||
self.assertEqual(self.replies[-1][1],expected)
|
||||
if expected is None:self.assertEqual(self.replies[-1][0]['rows'][0][1],1)
|
||||
|
||||
def test_future_capability_bits_do_not_disable_known_features(self):
|
||||
self.s.Request('stats',0,0,lambda d,e:None)
|
||||
self.time+=1;self.s.Tick();rid=self.s.pending['id']
|
||||
self.s.Receive('MTF1 cap %d 1 65551 1700000000'%rid)
|
||||
self.assertEqual(self.s.mask,65551)
|
||||
self.time+=1;self.s.Tick()
|
||||
self.assertTrue(self.sent[-1].endswith(' stats'))
|
||||
|
||||
def test_empty_page_distinct_from_unsupported_and_invalid_requests(self):
|
||||
rid=self.request()
|
||||
self.s.Receive('MTF1 begin %d mob 101 0 0 0 1700000000'%rid)
|
||||
self.s.Receive('MTF1 end %d 0'%rid)
|
||||
self.assertEqual(self.replies[-1][0]['rows'],[])
|
||||
self.assertIsNone(self.replies[-1][1])
|
||||
self.s.Request('delete',0,0,lambda d,e:self.replies.append((d,e)))
|
||||
self.assertEqual(self.replies[-1],(None,'invalid'))
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Test the actual embedded UI timer methods with a deterministic clock (no rendering mocks)."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import types
|
||||
import unittest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def window_class(filename,name,clock,messages):
|
||||
text=(ROOT/'src/platform/ScriptLib'/filename).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
|
||||
tree=ast.parse(text)
|
||||
tree.body=[n for n in tree.body if isinstance(n,(ast.ClassDef,ast.FunctionDef))]
|
||||
ns=dict(ui=types.SimpleNamespace(Window=object),app=types.SimpleNamespace(GetTime=lambda:clock[0]),
|
||||
chat=types.SimpleNamespace(CHAT_TYPE_INFO=1,AppendChat=lambda kind,text:messages.append(text)),
|
||||
L=lambda text:text,NAMES={1:'moon',2:'sock'})
|
||||
exec(compile(tree,filename,'exec'),ns)
|
||||
return ns[name]
|
||||
|
||||
|
||||
class TimerTests(unittest.TestCase):
|
||||
def setUp(self):self.clock=[10.];self.messages=[]
|
||||
|
||||
def test_dungeon_reminder_only_confirmed_snapshot_and_extended_deadline(self):
|
||||
cls=window_class('DungeonScript.inc','DungeonWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.reminder=(0,100);w.stale=True;w.values={'lair_end':100};w.serverTime=101;w.received=10.
|
||||
w.CheckReminder();self.assertFalse(self.messages)
|
||||
w.stale=False;w.values['lair_end']=200;w.CheckReminder()
|
||||
self.assertEqual(w.reminder,(0,200));self.assertFalse(self.messages)
|
||||
w.serverTime=200;w.CheckReminder();self.assertFalse(self.messages) # strict original deadline
|
||||
w.serverTime=201;w.CheckReminder();w.CheckReminder()
|
||||
self.assertEqual(len(self.messages),1);self.assertIsNone(w.reminder)
|
||||
|
||||
def test_dungeon_sleep_invalidates_display_and_requeries_before_notification(self):
|
||||
cls=window_class('DungeonScript.inc','DungeonWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.lastTick=1.;w.stale=False;w.nextQuery=999;w.pending=False;w.values={};w.reminder=(0,5)
|
||||
w.IsShow=lambda:False;calls=[];w.Query=lambda:calls.append('query')
|
||||
w.Now=lambda:1000
|
||||
w.OnUpdate()
|
||||
self.assertTrue(w.stale);self.assertTrue(calls);self.assertFalse(self.messages)
|
||||
|
||||
def test_calendar_actual_scope_and_start_end_notifications_once(self):
|
||||
cls=window_class('CalendarScript.inc','CalendarWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.offset=0;w.watch=dict(id=1,began=False,offset=0)
|
||||
w.data=dict(time=100,rows=[[1,1,100,200,2,0,0]])
|
||||
w.Notify();self.assertFalse(self.messages) # other channel cannot trigger start
|
||||
w.data['rows'][0][-1]=1;w.Notify();w.Notify();self.assertEqual(len(self.messages),1)
|
||||
w.data['time']=200;w.Notify();w.Notify();self.assertEqual(len(self.messages),2);self.assertIsNone(w.watch)
|
||||
|
||||
def test_short_screen_two_row_pages_do_not_skip_server_rows(self):
|
||||
cls=window_class('CalendarScript.inc','CalendarWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.pending=False;w.offset=0;w.rowPage=0;w.pageSize=2;w.data=dict(total=10)
|
||||
calls=[];w.Refresh=lambda:None;w.Query=lambda:calls.append(w.offset)
|
||||
seen=[]
|
||||
for i in range(5):
|
||||
seen.append(w.offset+w.rowPage*w.pageSize)
|
||||
w.Page(1)
|
||||
if w.data is None:w.data=dict(total=10)
|
||||
self.assertEqual(seen,[0,2,4,6,8]);self.assertEqual(calls,[8])
|
||||
|
||||
def test_calendar_removed_event_cancels_without_fake_end(self):
|
||||
cls=window_class('CalendarScript.inc','CalendarWindow',self.clock,self.messages)
|
||||
w=cls.__new__(cls);w.offset=0;w.watch=dict(id=1,began=True,offset=0);w.data=dict(time=500,rows=[])
|
||||
w.Notify();self.assertIsNone(w.watch);self.assertFalse(self.messages)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in New Issue
Block a user