The illustrative mod counts distinct arena contexts while its account-session history is retained and attempts a garage summary when a pending exit reaches the account GUI. Its small feature surface lets us examine the engineering details that keep larger battle mods reliable.

Follow current event producers, callers and cleanup paths before adapting this example.

What we’re building

From the player’s perspective, the mod remembers distinct arena contexts observed since initialization or the latest session reset. A pending summary can be submitted after account GUI entry, with text such as Battle Session Counter: 3 unique battles this session.

The message reports local observations. This mod does not know whether a battle produced valid server results, whether the player won, or whether the arena finished normally. It counts unique arenaUniqueID values observed while the local client owned a battle avatar. Re-entering the same arena does not increment the count while the account-session seen set is retained. A disconnect or account reset starts fresh local counting, even if the next arena ID is the same. Late initialization can seed the current arena but cannot recover earlier visits. Submission does not acknowledge a visible message.

Final result

  • One self-contained Python 2.7-compatible source file.
  • No monkey patch, polling loop, timer, custom UI, network request, or persistent file.
  • Four owned lifecycle subscriptions with symmetric cleanup.
  • Battle-scoped active state, account-session state, and a lobby-delivery flag kept separate.
  • A meta.xml whose version matches the module’s __version__.

What you’ll learn

  • How to turn a player idea into claims the client can actually support.
  • How to trace an event from definition to firing site instead of trusting its name.
  • Why player-entity, battle, account-GUI, and connection lifetimes are different.
  • How to own subscriptions, reject duplicate lifecycle events, and clean up partial installation.
  • How to distinguish an observed arena visit from authoritative battle results.
  • How to build, inspect, install, test, and later re-verify a minimal .wotmod.

Difficulty and prerequisites

Level 3 — Battle. You should already understand Python functions, classes, imports, logging, init()/fini(), and the basic .wotmod layout. Read First Mod & Safe Patching, Lifecycle, and Tooling, Assets & Packaging if those are new.

You need a source snapshot matching the client you intend to support, a Python 2.7-compatible compiler for production bytecode, an archive tool that can create stored ZIP members, and access to the target client’s game.log. Source validation is possible without launching the game; runtime validation is not.

Requirements and constraints

Functional requirements

  1. Begin observing after the mod loader calls init().
  2. Count a distinct arena once when a usable avatar and arena exist.
  3. Do not count avatar re-entry to an already observed arenaUniqueID twice before an account-session reset.
  4. Set one pending flag when an owned avatar scope ends; attempt submission on account GUI entry if the flag survives session reset.
  5. Preserve the counter across normal battle-to-lobby transitions.
  6. Reset account-session state on disconnect or account identity change.
  7. Remove only this mod’s listeners during fini().

Non-functional requirements

  • Successful initialization avoids duplicate installation; repeated finalization is safe for the recorded ownership state.
  • Partial subscription failure attempts reverse removal of recorded additions; failed removals are logged and are not retained for retry.
  • No retained PlayerAvatar, Account, or arena object survives its lifecycle.
  • Unexpected caught exceptions retain traceback context through _LOGGER.exception(...).
  • The implementation is Python 2.7 compatible and has no third-party dependencies.
  • The optional Error Reporter can attribute the module through __version__, but the mod does not depend on it.

Configuration: none. Adding a settings file would create persistence and schema concerns without supporting the core lesson. A later extension can make the notification optional.

Acceptance criteria

  • After successful installation, two init() calls retain one owned subscription per event.
  • Repeated avatar-entry notification for the same active context does not increment the total.
  • Avatar exit and re-entry to the same arena keeps the total unchanged only when no disconnect or account-session reset intervenes.
  • Observing a usable arena ID absent from the retained seen set increments the total exactly once.
  • The normal avatar entry and exit handlers do not submit a system message; submission uses the account-GUI event path.
  • The next onAccountShowGUI(ctx) consumes a surviving pending flag before attempting submission; account reset can discard it.
  • Disconnect and a later known different account ID clear observed arena IDs, totals, pending output, and active battle state.
  • Two fini() calls are safe, and successful teardown leaves no owned subscription.
  • Module and package versions are both 1.0.0.

Architecture

gui.mods loader
    │ init() / fini()
    ▼
module-owned SessionCounter
    ├── subscriptions ──────────────── process/mod lifetime
    ├── account ID + seen arena IDs ─ account-session lifetime
    ├── active arena ID ───────────── battle-avatar lifetime
    └── pending summary ───────────── transition to account GUI
                                              │
                                              ▼
                                   SystemMessages presentation

One module-owned object is enough. It owns installation, state transitions, and teardown; event handlers only delegate into that owner. The game owns player and arena objects, so the counter copies stable scalar identifiers and never stores those objects.

RequirementOwner and signalWhy it fits
Start battle scopeg_playerEvents.onAvatarBecomePlayerThe current avatar firing site has created the client arena before emitting it.
End battle scopeonAvatarBecomeNonPlayerThe current firing site emits it during avatar teardown; retained battle state must be invalidated.
Present in lobbyonAccountShowGUI(ctx)The account GUI context exists. The feature does not require completed hangar or cache synchronization.
End login sessiononDisconnectedConnection ownership ends; counts must not leak into another login.
DeduplicatePlayerAvatar.arenaUniqueIDIt identifies the same arena across avatar re-entry; deduplication still requires the session seen set to survive.

Researching the implementation

Do not begin by searching for a method named “battle started.” Start with ownership:

Requirement: count one local participation in a distinct arena
  ↓ Which subsystem owns it? PlayerAvatar and ClientArena
  ↓ Which lifecycle owns that subsystem? Avatar player-entity lifetime
  ↓ Which signal exposes entry? g_playerEvents.onAvatarBecomePlayer
  ↓ When is the ID valid? After ClientArena creation in PlayerAvatar.onBecomePlayer()
  ↓ Authority? Observed client arena identity, not final battle results
  ↓ Verification? Event definition + firing site + current callers + game.log
  ↓ Recheck after update? event names, firing order, handler signatures, arenaUniqueID

The reset boundary is the emitted event, not the informal word “reconnect.” The current connection manager fires onDisconnected in its stage-6 path. The tutorial deliberately clears its count, seen-arena history, active state and pending output on that event; known account-identity changes and mod finalization also clear session state. A stable arena ID alone does not retain that history. Trace the target client's actual signals before classifying a reconnect path.

Evidence chain in the inspected source

  1. PlayerEvents._PlayerEvents creates onAvatarBecomePlayer, onAvatarBecomeNonPlayer, onAccountShowGUI, and onDisconnected.
  2. PlayerAvatar.onBecomePlayer() constructs ClientArena from self.arenaUniqueID before firing onAvatarBecomePlayer().
  3. PlayerAvatar.onBecomeNonPlayer() fires the matching non-player event during teardown.
  4. Account.showGUI() establishes account context and then fires onAccountShowGUI(ctx).
  5. The connection manager fires g_playerEvents.onDisconnected() when connection ownership ends.
  6. Current client and repository call sites use SystemMessages.pushMessage(text, type=SystemMessages.SM_TYPE.Information) for an informational lobby message.

This evidence proves enough for the chosen feature. It does not prove that the battle HUD, player vehicle, space, results, dossier, or post-battle notification pipeline is ready at avatar entry. We do not need any of those systems, so the design does not wait for or hook them.

Approaches considered

ApproachDecisionReason
Avatar lifecycle eventsUseComposable, scoped, and already exposes the needed ownership boundary.
Patch PlayerAvatar.onBecomePlayerRejectAdds wrapper ordering and restoration risk without providing more useful authority.
Poll BigWorld.player()RejectDuplicates a real lifecycle signal, adds repeated work, and creates timer cleanup.
Wait an arbitrary number of secondsRejectTime is not evidence of account GUI readiness.
Use battle results to countReject for this scopeResults can arrive later and require a different asynchronous data pipeline. Use them only if wins or authoritative outcomes become requirements.

Source lookup

These pages expose the ownership and evidence behind the arena counter:

Start source searches with g_playerEvents, onAvatarBecomePlayer, onAvatarBecomeNonPlayer, PlayerAvatar.onBecomePlayer, ClientArena, and arenaUniqueID. Treat these names as investigation entry points: verify current callers, firing sites, signatures, and lifecycle order.

Lifecycle and ownership

  1. Import: define constants and construct one inert owner. No player, arena, or GUI access occurs.
  2. Mod initialization: init() installs four saved handler objects. A registration failure logs a traceback and attempts to remove recorded earlier additions.
  3. Late initialization recovery: after successful installation, the owner checks the current player. An existing arena with a usable ID can be captured once; unavailable state schedules no retry. The seed runs after installation is marked complete and outside the registration catch.
  4. Avatar entry: copy arenaUniqueID; never retain the avatar or arena object.
  5. Avatar exit: invalidate active battle state and mark a summary pending.
  6. Account GUI entry: detect account changes, then consume the pending summary through SystemMessages.
  7. Disconnect: invalidate all account-session and battle state.
  8. Mod finalization: attempt reverse removal of exact stored handlers and clear state. A popped pair whose removal fails is logged but not retained for a second call.

The counter schedules no asynchronous work of its own, so it needs no callback ID, generation counter, or cancellation token. Adding one “for safety” would create complexity with no stale completion to guard.

State and data-authority model

StateScopeSource and authorityInvalidation
_subscriptionsMod lifetimeSuccessfully recorded additions; a popped pair is not retained if removal failsfini() or failed startup rollback
_account_database_idAccount sessionCopied from the current Account at onAccountShowGUIDisconnect, finalization, or identity change
_seen_arena_idsAccount sessionObserved distinct avatar arena IDs; not a server battle historyAccount-session reset
_active_arena_idBattle avatarCopied from the current PlayerAvatarAvatar exit, disconnect, account reset, or finalization
_pending_summaryTransitionDerived: an owned battle scope endedConsumed on account GUI entry or cleared on session reset

If arenaUniqueID is unavailable, the implementation logs a warning and refuses to count. Silently inventing a replacement key from an avatar object address, vehicle ID, map name, or timestamp would weaken the acceptance criteria.

Implementation walkthrough

Metadata and owner

__version__ supports diagnostics and must match meta.xml. The module-level _COUNTER is not an unrestricted bag of globals: it is one explicit owner with private state and lifecycle methods.

Exact handler identity

Bound methods are saved once in __init__. The same callable objects are passed to addition and removal. A list records successful registrations so partial startup can remove only what this owner installed.

Battle-entry deduplication

An active-state guard handles repeated entry notification. A session-scoped set handles later avatar re-entry to the same arena while that set is retained. Both checks are necessary because “handler fired twice now” and “same arena observed again later” are different failure modes.

Lobby presentation boundary

Avatar exit only marks data pending. The account GUI event owns presentation. The pending flag is cleared before calling the UI API, preventing a failing presentation path from repeatedly spamming on later GUI notifications; the client event boundary supplies traceback logging for an uncaught message failure.

Complete source code

Save this complete implementation as mod_battle_session_counter.py. It contains no placeholders or omitted handlers.

# -*- coding: utf-8 -*-
"""Count distinct arena contexts observed during the current account session."""
from __future__ import absolute_import

# Reader map:
#   1. _SessionCounter owns account and avatar lifecycle state.
#   2. Arena IDs deduplicate repeated observations of one battle context.
#   3. Lobby readiness gates informational summary presentation.

import logging

import BigWorld
from PlayerEvents import g_playerEvents
from gui import SystemMessages


__version__ = '1.0.0'

_MOD_NAME = 'battle_session_counter'
_LOGGER = logging.getLogger(__name__)


# ============================================================
# State and ownership
# ============================================================

class _SessionCounter(object):
    """Own lifecycle subscriptions and all session/battle state."""

    def __init__(self):
        self._installed = False
        self._subscriptions = []

        self._account_database_id = None
        self._seen_arena_ids = set()
        self._battle_count = 0

        self._in_battle = False
        self._active_arena_id = None
        self._pending_summary = False

        # Preserve exact callable identities for removal.
        self._avatar_enter_handler = self._on_avatar_become_player
        self._avatar_exit_handler = self._on_avatar_become_non_player
        self._account_gui_handler = self._on_account_show_gui
        self._disconnect_handler = self._on_disconnected

    # ========================================================
    # Lifecycle
    # ========================================================

    def start(self):
        """Install lifecycle handlers transactionally and reconcile hot load."""
        if self._installed:
            return

        try:
            self._subscribe(
                g_playerEvents.onAvatarBecomePlayer,
                self._avatar_enter_handler)
            self._subscribe(
                g_playerEvents.onAvatarBecomeNonPlayer,
                self._avatar_exit_handler)
            self._subscribe(
                g_playerEvents.onAccountShowGUI,
                self._account_gui_handler)
            self._subscribe(
                g_playerEvents.onDisconnected,
                self._disconnect_handler)
        except Exception:
            _LOGGER.exception('%s: lifecycle subscription failed', _MOD_NAME)
            self._unsubscribe_all()
            return

        self._installed = True
        self._capture_current_battle(expect_battle=False)
        _LOGGER.info('%s %s: initialized', _MOD_NAME, __version__)

    def stop(self):
        if not self._installed and not self._subscriptions:
            return

        self._installed = False
        self._unsubscribe_all()
        self._reset_session()
        _LOGGER.info('%s %s: finalized', _MOD_NAME, __version__)

    def _subscribe(self, event, handler):
        event += handler
        self._subscriptions.append((event, handler))

    def _unsubscribe_all(self):
        while self._subscriptions:
            event, handler = self._subscriptions.pop()
            try:
                event -= handler
            except Exception:
                _LOGGER.exception(
                    '%s: lifecycle unsubscription failed', _MOD_NAME)

    # ========================================================
    # Event handling
    # ========================================================

    def _on_avatar_become_player(self):
        self._capture_current_battle(expect_battle=True)

    def _on_avatar_become_non_player(self):
        if not self._in_battle:
            return

        arena_id = self._active_arena_id
        self._in_battle = False
        self._active_arena_id = None
        self._pending_summary = True
        _LOGGER.info('%s: left arena %s', _MOD_NAME, arena_id)

    def _on_account_show_gui(self, _ctx):
        player = BigWorld.player()
        account_database_id = getattr(player, 'databaseID', None)

        if self._account_database_id is None:
            self._account_database_id = account_database_id
        elif (account_database_id is not None and
              account_database_id != self._account_database_id):
            _LOGGER.info('%s: account changed; resetting session', _MOD_NAME)
            self._reset_session()
            self._account_database_id = account_database_id

        self._show_pending_summary()

    def _on_disconnected(self):
        if self._battle_count or self._in_battle:
            _LOGGER.info('%s: disconnected; resetting session', _MOD_NAME)
        self._reset_session()

    # ========================================================
    # Battle and session state
    # ========================================================

    def _capture_current_battle(self, expect_battle):
        """Copy the current arena identity without assuming HUD readiness."""
        player = BigWorld.player()
        arena = getattr(player, 'arena', None) if player is not None else None
        arena_id = getattr(player, 'arenaUniqueID', None)

        if arena is None:
            if expect_battle:
                _LOGGER.warning(
                    '%s: avatar entry arrived without an arena; not counting',
                    _MOD_NAME)
            return
        if arena_id is None:
            _LOGGER.warning(
                '%s: avatar entry has no arenaUniqueID; not counting',
                _MOD_NAME)
            return
        if self._in_battle and arena_id == self._active_arena_id:
            return

        if self._in_battle:
            _LOGGER.warning(
                '%s: arena changed from %s to %s without avatar exit',
                _MOD_NAME, self._active_arena_id, arena_id)

        self._in_battle = True
        self._active_arena_id = arena_id

        # Re-entry within the retained session must not increment twice.
        if arena_id not in self._seen_arena_ids:
            self._seen_arena_ids.add(arena_id)
            self._battle_count += 1
            _LOGGER.info(
                '%s: entered new arena %s; session total=%d',
                _MOD_NAME, arena_id, self._battle_count)
        else:
            _LOGGER.info(
                '%s: re-entered arena %s; session total remains %d',
                _MOD_NAME, arena_id, self._battle_count)

    def _reset_session(self):
        self._account_database_id = None
        self._seen_arena_ids.clear()
        self._battle_count = 0
        self._in_battle = False
        self._active_arena_id = None
        self._pending_summary = False

    # ========================================================
    # UI boundary
    # ========================================================

    def _show_pending_summary(self):
        """Present only after the account GUI owns SystemMessages again."""
        if not self._pending_summary:
            return

        self._pending_summary = False
        suffix = '' if self._battle_count == 1 else 's'
        text = ('Battle Session Counter: %d unique battle%s this session.' %
                (self._battle_count, suffix))
        SystemMessages.pushMessage(
            text,
            type=SystemMessages.SM_TYPE.Information)
        _LOGGER.info('%s: displayed session total=%d',
                     _MOD_NAME, self._battle_count)


_COUNTER = _SessionCounter()


# ============================================================
# Loader boundary
# ============================================================

def init():
    """World of Tanks mod-loader entry point."""
    _COUNTER.start()


def fini():
    """World of Tanks mod-loader cleanup entry point."""
    _COUNTER.stop()

Minimal project structure

battle-session-counter/
├── mod_battle_session_counter.py
└── stage/
    ├── meta.xml
    └── res/
        └── scripts/
            └── client/
                └── gui/
                    └── mods/
                        └── mod_battle_session_counter.pyc

The source remains one Python file. meta.xml is not runtime application code; the package format requires it. No Gameface, Scaleform, image, localization, configuration, or manifest-registration resource is needed.

<root>
    <id>tutorial.battle_session_counter</id>
    <version>1.0.0</version>
    <name>Battle Session Counter</name>
    <description>Counts distinct arena contexts observed during the current account session.</description>
</root>

Packaging and installation

  1. Keep the editable mod_battle_session_counter.py outside the staging tree.
  2. Compile it with the Python 2.7-compatible compiler used by the target client generation.
  3. Place the resulting mod_battle_session_counter.pyc at res/scripts/client/gui/mods/ under a clean staging root.
  4. Place meta.xml at that staging root.
  5. Create a ZIP-format archive named battle_session_counter_1.0.0.wotmod, preserving paths and using stored members (ZIP_STORED / no compression) as established by this guide’s packaging workflow.
  6. Open the archive and verify that its top-level entries are meta.xml and res/, not an extra project directory.
  7. Copy the package to <World_of_Tanks>/mods/<target-client-version>/. The folder name must match the installed client; do not substitute the documentation date.
C:\Python27\python.exe -m py_compile .\mod_battle_session_counter.py
Copy-Item .\mod_battle_session_counter.pyc .\stage\res\scripts\client\gui\mods\
Push-Location .\stage
7z a -tzip -mx=0 ..\battle_session_counter_1.0.0.wotmod .\meta.xml .\res
Pop-Location

For development, rebuild into a fresh staging directory so stale bytecode cannot survive. For production, automate compilation and archive inspection, fail on any compiler or archiver error, and compare the manifest version with __version__. This tutorial does not compile, package, install, or modify a client for you.

Remove or roll back by closing the client and removing this mod’s single .wotmod from the matching version folder. It creates no persistent data, so no preferences cleanup is required.

Testing and debugging

1. Static and source validation

  • Compile with Python 2.7 and reject syntax errors.
  • Confirm imports, event names, firing sites, and callback signatures in the target source.
  • Check that every successful += registration is represented in _subscriptions and removed by -=.
  • Check that no player/arena objects, callbacks, threads or monkey patches are retained. The seen-ID set grows with distinct observed arenas until reset; verify its session-reset boundaries rather than assuming a fixed-size history.
  • Confirm __version__ equals the package version.

2. Packaging validation

  • List archive members and verify meta.xml plus exactly one mod bytecode path.
  • Confirm the members are stored and paths use forward-slash archive separators.
  • Extract to a temporary directory and compare the bytecode hash with the staged input.
  • Install only in the exact target-version directory.

Selected tutorial-class controls checked same-arena exit/re-entry before reset, a second arena, explicit disconnect clearing the state, and same-arena entry after reset. The algorithm is unchanged; these controls used recording player/logger substitutes and do not establish live reconnect ordering, client event delivery or persistent history.

3. In-client runtime test matrix

ScenarioActionExpected evidenceFailure symptom
Clean startupStart client and log inOne initialized line with version; no summaryImport traceback, duplicate initialization, or premature message
Normal battleEnter one battle, then returnOne new-arena line, one leave line, one “1 unique battle” messageNo message, message during battle, or total of 2
Consecutive battlesPlay three distinct arenasTotals 1, 2, and 3 after each returnState resets on ordinary lobby transition
Retained-session re-entryControl avatar exit/re-entry with the same arena ID and no disconnect/account-reset event; runtime-test only if the client offers that pathRe-entered-arena log; total unchangedSame arena counted twice within retained session state
Early exitLeave before normal battle completionObserved arena still counted; wording never claims completionMessage claims a win/result or stale active state remains
Rapid transitionReturn to lobby and immediately queue againPrior summary appears once; new arena owns new active stateLost or duplicated summary
Disconnect resetObserve or control onDisconnected, then enter an arena, including the previously seen IDSeen IDs and old total cleared; next valid observation starts at 1Old account state survives the declared reset
Account switchLog into a different account where supportedIdentity-change reset before any summaryPrevious account’s count appears
Repeated initializationUse a controlled development reload path to call init() twiceOne handler effect per eventDuplicate count/message
Repeated cleanupCall fini() twice in a controlled testNo exception; one finalized lineMissing-listener error or later callbacks
Another lifecycle modRun with a mod subscribing to the same eventsBoth work; no overwritten method existsOrdering dependency or removed foreign listener

Inspect game.log in the client’s profile/log directory for the named logger output and full tracebacks. Exact log paths vary by installation and launcher configuration; locate the active file from the client instance you actually launched.

Failure modes and edge cases

  • Partial initialization: the owner catches the failing subscription, logs a traceback, removes earlier registrations, and remains uninstalled.
  • Duplicate avatar entry: active arena identity makes the handler a no-op.
  • Missing arena or ID: the owner logs a warning and refuses to invent a key. This requires target-version investigation if it occurs in a normal battle.
  • Avatar exit never arrives: disconnect and finalization still invalidate active state. A direct different-arena entry logs the unexpected transition and moves ownership.
  • Account GUI never arrives: the pending message remains in session memory until a valid account GUI event or session reset.
  • Notification failure: the pending flag was consumed, so later account events do not spam. The event boundary should log the uncaught failure; verify that behavior in the target build.
  • Event removal failure: teardown logs a traceback. Treat this as a client-contract regression and inspect the target event implementation.
  • Client update: an import, event, signature, or ordering change can stop initialization or shift the validity boundary. Static compilation alone cannot detect that.

Performance considerations

The mod uses four low-frequency lifecycle subscriptions. Routine set membership and insertion are effectively constant-time on average. The set retains one scalar per distinct observed arena until reset; clearing it takes work proportional to the retained IDs. The counter schedules no per-frame work, scans no entities, and performs no serialization, counter-file I/O or mod-owned network request. These lifecycle signals need no polling. This describes the inspected control flow, not a performance benchmark.

Compatibility and version sensitivity

DependencyClassificationRecheck
Python 2.7 language subsetRuntime constraintCompiler version and bytecode format
gui.mods filename discovery and init()/fini()Internal loader contractPrefix, extension, call behavior, exception handling
g_playerEvents event attributesInternal and version-sensitiveDefinitions, signatures, firing sites, ordering
PlayerAvatar.arena and arenaUniqueIDGenerated/entity and version-sensitiveAvailability before avatar-entry firing and identity semantics
Account.databaseIDEntity field and version-sensitiveAvailability at onAccountShowGUI(ctx)
SystemMessages.pushMessage and SM_TYPE.InformationLobby presentation contractImport path, signature, and readiness at account GUI entry
meta.xml, archive paths, stored members, install directoryPackaging contractCurrent client and tooling guide

No private API is promised permanently. After every client update, trace definitions and firing sites again; do not stop after confirming that imports still resolve.

Why this architecture?

  • Why not initialize at import? Import proves only that the loader found the module. Explicit loader lifecycle is diagnosable and reversible.
  • Why not retain BigWorld.player()? The player entity changes between login, account, and avatar. Retaining it crosses ownership boundaries.
  • Why not count avatar objects? A reconnect can create another avatar for the same arena. Arena identity better matches the requirement, but the counter discards prior IDs at its declared account-session reset.
  • Why not show the message during avatar exit? Battle teardown is not the presentation owner. Account GUI entry is a real lobby boundary.
  • Why not patch a method? Events already compose among multiple subscribers and need no wrapper restoration.
  • Why not catch every handler exception? Most handlers have no meaningful fallback. Catch only partial installation and cleanup boundaries where recovery exists.
  • Why not persist the count? Persistence would contradict the deliberately session-scoped feature and add schema, migration, and write-failure responsibilities.
  • Why one source file? The responsibilities are small and cohesive. Splitting them would add package/import boundaries without improving ownership.

Possible extensions

  • Add a validated preferences setting that disables lobby messages while retaining logs.
  • Add observed avatar-context duration, clearly labeled as client presence rather than official battle duration.
  • Move to the battle-results service if wins, losses, damage, or authoritative completion become requirements; that is a new data pipeline, not a small counter tweak.
  • Add a Gameface presentation only when a persistent visible panel becomes a real requirement. Keep the counter as source truth and the UI as a projection.

Each extension changes ownership. Re-run the requirement → subsystem → lifecycle → API → validity → authority → verification chain before adding it.

Maintenance checklist

  • Confirm target client version and region before release.
  • Re-open PlayerEvents.py, Avatar.py, Account.py, the connection manager, and SystemMessages. The links follow the latest public source; reverify the complete contract for the client you package.
  • Verify event definitions, argument counts, and firing order from current call sites.
  • Verify arenaUniqueID remains valid for retained-session re-entry, and trace whether the actual reconnect path emits a reset event before expecting deduplication.
  • Verify account identity is populated at account GUI entry.
  • Compile with the target-compatible Python toolchain.
  • Compare __version__, meta.xml, and output filename.
  • Inspect every archive member and install only in the matching client-version folder.
  • Run at least clean startup, two consecutive battles, early exit, reconnect, disconnect, and cleanup tests.
  • Review game.log for duplicate lifecycle messages, warnings, and tracebacks.

What you learned

A reliable battle mod begins with a narrow claim. We asked the client for an observed arena identity, not information it could not prove. We mapped that claim to the avatar lifecycle, copied only scalar identity into mod-owned state, waited for account GUI ownership before presenting output, and made every registration reversible.

The reusable lesson is larger than this counter: identify who owns the fact, who owns its lifetime, and who owns its presentation. Then make cleanup follow those same boundaries.