Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/diagnostic-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
68 changes: 68 additions & 0 deletions build.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 10 additions & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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',
Expand Down
129 changes: 129 additions & 0 deletions res/scripts/client/gui/mods/smartReconnect/ReconnectController.py
Original file line number Diff line number Diff line change
@@ -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')


Expand All @@ -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',
Expand All @@ -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
2 changes: 2 additions & 0 deletions res/scripts/client/gui/mods/smartReconnect/SmartReconnect.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(self):

def dispose(self):
self._monitor.stop()
self._controller.clearBusy('dispose')

try:
g_playerEvents.onAvatarBecomePlayer -= self._onAvatarBecomePlayer
Expand All @@ -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)
Expand Down
Loading
Loading