diff --git a/.github/workflows/diagnostic-build.yml b/.github/workflows/diagnostic-build.yml index 5c1425c..2b70cf4 100644 --- a/.github/workflows/diagnostic-build.yml +++ b/.github/workflows/diagnostic-build.yml @@ -12,6 +12,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Test diagnostic monitor with Python 2.7 + shell: bash + run: | + docker run --rm -v "$PWD:/work" -w /work python:2.7 \ + python -m unittest discover -s tests -v + - name: Prepare diagnostic build tree shell: bash run: | diff --git a/build.py b/build.py new file mode 100644 index 0000000..f65a514 --- /dev/null +++ b/build.py @@ -0,0 +1,68 @@ +"""Build a clean WoT archive using Python 2.7 on Windows or Linux.""" +from __future__ import print_function +import argparse +import imp +import os +import py_compile +import re +import shutil +import sys +import tempfile +import zipfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--version', required=True) + parser.add_argument('--output-dir', default='.') + args = parser.parse_args() + if sys.version_info[:2] != (2, 7): + parser.error('WoT payload must be compiled with Python 2.7') + if not re.match(r'^[A-Za-z0-9][A-Za-z0-9._-]*$', args.version): + parser.error('Invalid version') + root = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.abspath(args.output_dir) + if not os.path.isdir(output_dir): + os.makedirs(output_dir) + output = os.path.join(output_dir, 'mewt0.smartReconnect_%s.wotmod' % args.version) + if os.path.exists(output): + parser.error('Output already exists: ' + output) + staging = tempfile.mkdtemp(prefix='smartreconnect-build-') + try: + payload = [] + for directory, dirs, files in os.walk(os.path.join(root, 'res')): + dirs[:] = sorted(d for d in dirs if d != '__pycache__') + for name in sorted(files): + if not name.endswith('.py'): + continue + source = os.path.join(directory, name) + relative = os.path.relpath(source, root).replace(os.sep, '/') + dest = os.path.join(staging, *relative.split('/')) + if not os.path.isdir(os.path.dirname(dest)): + os.makedirs(os.path.dirname(dest)) + with open(source, 'rb') as stream: + data = stream.read().replace(b'{{VERSION}}', args.version.encode('ascii')) + with open(dest, 'wb') as stream: + stream.write(data) + py_compile.compile(dest, cfile=dest + 'c', dfile=relative, doraise=True) + payload.append((relative + 'c', dest + 'c')) + with open(os.path.join(root, 'meta.xml'), 'rb') as stream: + meta = stream.read().replace(b'{{VERSION}}', args.version.encode('ascii')) + archive_path = os.path.join(staging, 'payload.wotmod') + with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_STORED) as archive: + archive.writestr('meta.xml', meta) + for relative, path in payload: + archive.write(path, relative) + with zipfile.ZipFile(archive_path) as archive: + assert archive.testzip() is None + assert len(archive.namelist()) == len(payload) + 1 + for relative, unused in payload: + assert archive.read(relative)[:4] == imp.get_magic() + shutil.copyfile(archive_path, output) + print('Built %s (%d Python 2.7 modules)' % (output, len(payload))) + finally: + shutil.rmtree(staging) + + +if __name__ == '__main__': + main() diff --git a/docs/TESTING.md b/docs/TESTING.md index 2b9852a..3ec608b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -8,7 +8,16 @@ v0.1 must never intentionally disconnect the client. Its only job is to prove th Do not enable real reconnect until the acceptance criteria below are met on the exact target WoT client build. -## Expected log fields +## Offline regression checks + +Run `python -m unittest discover -s tests -v` from the repository root. +The CI workflow runs these checks with Python 2.7 before packaging. +They simulate RED/GREEN samples, unreadable samples, replay, missing arena, +disconnected state and monitor lifecycle. An unreadable sample resets the +continuous RED timer because continuity cannot be established across it. +These checks do not validate native client APIs or satisfy the live gate in #2. + +## Diagnostic log fields During a lag episode, `python.log` should include: diff --git a/res/scripts/client/gui/mods/smartReconnect/ConnectionMonitor.py b/res/scripts/client/gui/mods/smartReconnect/ConnectionMonitor.py index d868c22..c66ce72 100644 --- a/res/scripts/client/gui/mods/smartReconnect/ConnectionMonitor.py +++ b/res/scripts/client/gui/mods/smartReconnect/ConnectionMonitor.py @@ -74,6 +74,10 @@ def _tick(self): else: self._handleHealthy(ping, connected, arenaPeriod) except Exception: + # An unreadable sample cannot prove a continuous RED window. + # Keep the decision latched until GREEN or a lifecycle reset. + self._lagSince = None + self._lastLoggedSecond = -1 _logger.exception('[SmartReconnect] monitor tick failed') finally: self._schedule() @@ -88,7 +92,6 @@ def _readConnectedState(self): def _handleLag(self, now, ping, connected, arenaPeriod): if self._lagSince is None: self._lagSince = now - self._triggered = False self._lastLoggedSecond = -1 _logger.warning( '[SmartReconnect] RED started ping=%s connected=%s arenaPeriod=%s', diff --git a/res/scripts/client/gui/mods/smartReconnect/ReconnectController.py b/res/scripts/client/gui/mods/smartReconnect/ReconnectController.py index 931e949..7f98376 100644 --- a/res/scripts/client/gui/mods/smartReconnect/ReconnectController.py +++ b/res/scripts/client/gui/mods/smartReconnect/ReconnectController.py @@ -1,7 +1,17 @@ import logging +import BigWorld +import BattleReplay + +from helpers import dependency + from .Config import DIAGNOSTIC_MODE, AUTO_RECONNECT_ENABLED +try: + from skeletons.gameplay import IGameplayLogic +except Exception: + IGameplayLogic = None + _logger = logging.getLogger('SmartReconnect') @@ -22,6 +32,9 @@ def requestReconnect(self, reason, elapsed=None, ping=None): ) return False + if reason == 'manual-hotkey': + return self._requestManualReconnect(reason, elapsed, ping) + if DIAGNOSTIC_MODE or not AUTO_RECONNECT_ENABLED: _logger.warning( '[SmartReconnect] WOULD RECONNECT reason=%s elapsed=%s ping=%s', @@ -38,3 +51,119 @@ def requestReconnect(self, reason, elapsed=None, ping=None): str(reason) ) return False + + def _requestManualReconnect(self, reason, elapsed=None, ping=None): + if DIAGNOSTIC_MODE: + _logger.warning( + '[SmartReconnect] manual reconnect enabled in diagnostic build reason=%s', + str(reason) + ) + + self._busy = True + startedAt = self._now() + requested = False + try: + if not self._isActiveBattle(): + _logger.warning( + '[SmartReconnect] manual reconnect rejected; no active battle reason=%s', + str(reason) + ) + return False + + if self._isReplay(): + _logger.warning( + '[SmartReconnect] manual reconnect rejected; replay is active reason=%s', + str(reason) + ) + return False + + gameplayLogic = self._getGameplayLogic() + if gameplayLogic is None: + _logger.error( + '[SmartReconnect] manual reconnect rejected; IGameplayLogic unavailable reason=%s', + str(reason) + ) + return False + + if not hasattr(gameplayLogic, 'goToLoginByDisconnectRQ'): + _logger.error( + '[SmartReconnect] manual reconnect rejected; goToLoginByDisconnectRQ missing reason=%s', + str(reason) + ) + return False + + arenaPeriod = self._arenaPeriod() + _logger.warning( + '[SmartReconnect] manual reconnect calling goToLoginByDisconnectRQ reason=%s elapsed=%s ping=%s arenaPeriod=%s startedAt=%.3f', + str(reason), + str(elapsed), + str(ping), + str(arenaPeriod), + startedAt + ) + gameplayLogic.goToLoginByDisconnectRQ() + _logger.warning( + '[SmartReconnect] manual reconnect disconnect requested reason=%s duration=%.3fs', + str(reason), + max(0.0, self._now() - startedAt) + ) + requested = True + return True + except Exception: + _logger.exception( + '[SmartReconnect] manual reconnect failed reason=%s duration=%.3fs', + str(reason), + max(0.0, self._now() - startedAt) + ) + return False + finally: + if not requested: + self.clearBusy('manual-request-not-started') + + def clearBusy(self, reason='external-state-change'): + if self._busy: + _logger.info( + '[SmartReconnect] reconnect guard released reason=%s', + str(reason) + ) + self._busy = False + + def _getGameplayLogic(self): + if IGameplayLogic is None: + return None + try: + return dependency.instance(IGameplayLogic) + except Exception: + _logger.exception('[SmartReconnect] failed to resolve IGameplayLogic') + return None + + def _isReplay(self): + try: + return bool(BattleReplay.isPlaying()) + except Exception: + _logger.exception('[SmartReconnect] failed to read replay state') + return True + + def _isActiveBattle(self): + try: + player = BigWorld.player() + return player is not None and hasattr(player, 'arena') and player.arena is not None + except Exception: + _logger.exception('[SmartReconnect] failed to read battle state') + return False + + def _arenaPeriod(self): + try: + player = BigWorld.player() + if player is None or not hasattr(player, 'arena') or player.arena is None: + return None + return getattr(player.arena, 'period', None) + except Exception: + _logger.exception('[SmartReconnect] failed to read arena period') + return None + + def _now(self): + try: + return float(BigWorld.timeExact()) + except Exception: + return 0.0 diff --git a/res/scripts/client/gui/mods/smartReconnect/SmartReconnect.py b/res/scripts/client/gui/mods/smartReconnect/SmartReconnect.py index fdbce1f..a9d2e4b 100644 --- a/res/scripts/client/gui/mods/smartReconnect/SmartReconnect.py +++ b/res/scripts/client/gui/mods/smartReconnect/SmartReconnect.py @@ -34,6 +34,7 @@ def __init__(self): def dispose(self): self._monitor.stop() + self._controller.clearBusy('dispose') try: g_playerEvents.onAvatarBecomePlayer -= self._onAvatarBecomePlayer @@ -59,6 +60,7 @@ def _onAvatarBecomePlayer(self, *args, **kwargs): def _onAvatarBecomeNonPlayer(self, *args, **kwargs): self._inBattle = False self._monitor.stop() + self._controller.clearBusy('avatar-non-player') def _onReconnectRequested(self, reason, elapsed=None, ping=None): self._controller.requestReconnect(reason, elapsed, ping) diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..1cdeee8 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,228 @@ +"""Offline behavioral tests; no WoT installation or network access required.""" +import os +import sys +import types +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), + 'res', 'scripts', 'client', 'gui', 'mods')) + +def module(name): + value = types.ModuleType(name) + sys.modules[name] = value + return value + +bw = module('BigWorld') +replay = module('BattleReplay') +helpers = module('helpers') +module('skeletons') +module('skeletons.connection_mgr').IConnectionManager = object +module('skeletons.gameplay').IGameplayLogic = object +helpers.dependency = types.ModuleType('dependency') +helpers.dependency.instance = lambda interface: None + +from smartReconnect.ConnectionMonitor import ConnectionMonitor +from smartReconnect.ReconnectController import ReconnectController + + +class MonitorTests(unittest.TestCase): + def setUp(self): + self.requests = [] + self.pending = {} + self.serial = 0 + self.now = 0.0 + self.red = True + self.playing = False + self.failed = False + self.connected = True + self.player = type('Player', (), {'arena': type('Arena', (), {'period': 3})()})() + bw.callback = self.schedule + bw.cancelCallback = lambda token: self.pending.pop(token) + bw.player = lambda: self.player + bw.timeExact = lambda: self.now + bw.statPing = lambda: 0.1 + bw.statLagDetected = self.signal + replay.isPlaying = lambda: self.playing + self.monitor = ConnectionMonitor(lambda *args: self.requests.append(args)) + self.monitor._connectionMgr = self + self.monitor.start() + + def isConnected(self): + return self.connected + + def signal(self): + if self.failed: + raise RuntimeError('unreadable sample') + return self.red + + def schedule(self, delay, callback): + self.serial += 1 + self.pending[self.serial] = callback + return self.serial + + def tick(self, now): + self.now = now + self.assertEqual(len(self.pending), 1) + callback = self.pending.pop(next(iter(self.pending))) + callback() + + def test_sustained_red_triggers_once(self): + for now in (0, 1, 2.9): + self.tick(now) + self.assertEqual(self.requests, []) + for now in (3, 4, 10, 30): + self.tick(now) + self.assertEqual(len(self.requests), 1) + + def test_green_resets_short_spike_and_rearms(self): + self.tick(0) + self.red = False + self.tick(2.9) + self.red = True + self.tick(3) + self.tick(5.9) + self.assertEqual(self.requests, []) + self.tick(6) + self.red = False + self.tick(7) + self.red = True + self.tick(8) + self.tick(11) + self.assertEqual(len(self.requests), 2) + + def test_failed_sample_requires_fresh_window(self): + self.tick(0) + self.failed = True + self.tick(2) + self.failed = False + self.tick(3) + self.tick(5.9) + self.assertEqual(self.requests, []) + self.tick(6) + self.assertEqual(len(self.requests), 1) + + def test_replay_is_inert(self): + self.playing = True + for now in (0, 3, 10): + self.tick(now) + self.assertEqual(self.requests, []) + + def test_error_after_trigger_does_not_rearm(self): + self.tick(0) + self.tick(3) + self.failed = True + self.tick(4) + self.failed = False + self.tick(5) + self.tick(8) + self.assertEqual(len(self.requests), 1) + self.red = False + self.tick(9) + self.red = True + self.tick(10) + self.tick(13) + self.assertEqual(len(self.requests), 2) + + def test_green_at_threshold_does_not_trigger(self): + self.tick(0) + self.red = False + self.tick(3) + self.assertEqual(self.requests, []) + + def test_missing_arena_resets_window(self): + self.tick(0) + player = self.player + self.player = None + self.tick(2) + self.player = player + self.tick(3) + self.assertEqual(self.requests, []) + + def test_start_stop_and_restart(self): + self.monitor.start() + self.tick(0) + self.monitor.stop() + self.assertEqual(self.pending, {}) + self.monitor.start() + self.tick(10) + self.assertEqual(self.requests, []) + self.tick(13) + self.assertEqual(len(self.requests), 1) + + def test_disconnected_state_still_diagnostic(self): + self.connected = False + self.tick(0) + self.tick(3) + self.assertEqual(len(self.requests), 1) + + +class GameplayLogicStub(object): + def __init__(self): + self.calls = 0 + self.raise_on_call = False + + def goToLoginByDisconnectRQ(self): + self.calls += 1 + if self.raise_on_call: + raise RuntimeError('disconnect failed') + + +class ReconnectControllerTests(unittest.TestCase): + def setUp(self): + self.now = 10.0 + self.playing = False + self.player = type('Player', (), {'arena': type('Arena', (), {'period': 3})()})() + self.gameplay = GameplayLogicStub() + bw.player = lambda: self.player + bw.timeExact = lambda: self.now + replay.isPlaying = lambda: self.playing + helpers.dependency.instance = lambda interface: self.gameplay + self.controller = ReconnectController() + + def test_manual_hotkey_calls_stock_disconnect_once_and_latches_busy(self): + self.assertTrue(self.controller.requestReconnect('manual-hotkey')) + self.assertEqual(self.gameplay.calls, 1) + self.assertTrue(self.controller.busy) + + self.assertFalse(self.controller.requestReconnect('manual-hotkey')) + self.assertEqual(self.gameplay.calls, 1) + + def test_manual_guard_can_be_released_by_lifecycle(self): + self.assertTrue(self.controller.requestReconnect('manual-hotkey')) + self.controller.clearBusy('login-reached') + self.assertFalse(self.controller.busy) + self.assertTrue(self.controller.requestReconnect('manual-hotkey')) + self.assertEqual(self.gameplay.calls, 2) + + def test_manual_rejects_without_active_battle_and_releases_guard(self): + self.player = None + self.assertFalse(self.controller.requestReconnect('manual-hotkey')) + self.assertFalse(self.controller.busy) + self.assertEqual(self.gameplay.calls, 0) + + def test_manual_rejects_replay_and_releases_guard(self): + self.playing = True + self.assertFalse(self.controller.requestReconnect('manual-hotkey')) + self.assertFalse(self.controller.busy) + self.assertEqual(self.gameplay.calls, 0) + + def test_manual_rejects_missing_gameplay_logic_and_releases_guard(self): + helpers.dependency.instance = lambda interface: None + self.assertFalse(self.controller.requestReconnect('manual-hotkey')) + self.assertFalse(self.controller.busy) + self.assertEqual(self.gameplay.calls, 0) + + def test_manual_call_exception_releases_guard(self): + self.gameplay.raise_on_call = True + self.assertFalse(self.controller.requestReconnect('manual-hotkey')) + self.assertFalse(self.controller.busy) + self.assertEqual(self.gameplay.calls, 1) + + def test_auto_lag_remains_diagnostic_only(self): + self.assertFalse(self.controller.requestReconnect('auto-lag', 3.1, 999)) + self.assertFalse(self.controller.busy) + self.assertEqual(self.gameplay.calls, 0) + + +if __name__ == '__main__': + unittest.main()