The dashboard shows current phase/time, currently loaded vehicle entities, local-player distance, and a personal route trace. On successful finalization it copies an observational summary and retains the newest 50 records in memory. Recreating a page can produce another observation for the same arena. Every metric states its limits: entity presence is not roster truth, distance is sampled, and the summary is not an official battle result.

What we’re building

A passive 430×250 dashboard with a phase header, three observed-presence counters, sampled distance, transition totals, and an SVG route. A single battle owner coordinates the sources, publishes one versioned payload, and attempts to save the newest 50 local summaries after successful teardown.

Final result

  • One cohesive battle-scoped state machine.
  • Event-driven entity membership plus low-rate spatial sampling.
  • Derived metrics with explicit source labels.
  • One Gameface payload and panel-sized view.
  • Staged, schema-versioned observational summaries.
  • Explicit ownership and staged cleanup across events, callbacks, model, window, DOM, and persistence.

What you’ll learn

  • How to compose independently owned sources without inventing a “god object” truth.
  • How subscribe-before-seed deduplicates overlapping entity IDs, while differing seed and event filters still limit reconciliation.
  • How a single periodic tick can own countdown and position sampling.
  • How to separate live state, derived metrics, presentation state, and historical summaries.
  • How to order teardown so the final summary is copied before live state is destroyed.

Difficulty and prerequisites

Level 9 — Multi-system integration. Complete these earlier tutorials:

The code intentionally integrates only systems needed by this feature.

Requirements and constraints

Functional

  1. Create only after the current shared battle page populates.
  2. Subscribe to the exact current avatar before seeding loaded vehicles.
  3. Update phase from the four-argument period event.
  4. Resolve the current local player vehicle on each tick; request the next tick 0.25 seconds after work finishes.
  5. Attempt publication when serialized payload bytes change; this cache comparison does not acknowledge model delivery.
  6. Copy one summary before clearing battle state, then attempt to save the copied history after cleanup.

Truth limitations

  • Loaded counts describe client entity presence, not alive, spotted, or roster counts.
  • Distance sums horizontal world chords between accepted samples when a baseline exists; it does not reconstruct unsampled bends or gaps.
  • Page exit is an observed UI lifecycle boundary, not battle outcome delivery.
  • The route contains only the local player.

Acceptance criteria

These are normal-path targets to verify, rather than a completed test report. Failure guarantees are limited by the source-reviewed boundaries below; the full deterministic plan and runtime matrix remain unexecuted.

  • On normal successful admission, one page activation starts an avatar subscription pair, an initial tick chain, and window acquisition.
  • Entity seeding follows subscription and deduplicates overlapping IDs; seed and live-event admission filters differ.
  • Missing team data is classified as unknown on admission; duplicate enters do not refresh a retained side.
  • Each sample re-resolves the current player vehicle. An explicit invalid sample clears the distance baseline; direct vehicle replacement without such a sample can bridge positions.
  • Distance uses displacements of at least two world units from the last accepted baseline. Normalization admits a fractional edge tolerance before clamping, rather than rejecting every point outside the map.
  • Route memory is capped at 96 normalized points.
  • Normal history retention keeps the last 50 admitted records and never claims an official outcome; loading work and input size are uncapped.
  • Successful teardown copies the summary, advances generation, detaches resources and clears live state before appending and attempting to save the copied history. Interrupted cleanup can skip later stages.
  • A newly admitted observation seeds fresh battle state. Generation checks reject stale tick and parent work after their handle slots have already been cleared; frontend deferred work has separate limits.

Architecture

                         WoT client
             ┌──────────────┼──────────────┐
        arena period   avatar entities   player position
             │ event        │ events          │ sample
             └──────────────┼─────────────────┘
                            ▼
                 battle-scoped mod state
             ┌──────────────┼──────────────┐
       copied facts   derived analytics   bounded route
             └──────────────┼──────────────┘
                     versioned payload
                  ┌─────────┴─────────┐
             Gameface view      summary history
             (presentation)     (observations)

Researching the implementation

Levels 4–7 established each contract independently: arena-period event signature and server-time countdown; avatar enter/leave event ordering and plain-event failure behavior; X/Z bounds and UI-axis inversion; preferences-root staged JSON replacement. The dashboard history helper has its own admission and replacement limits; the earlier journal does not supply it with stronger guarantees. This capstone reuses those verified contracts but keeps their owners visible. It does not copy an existing flagship mod or depend on protected project code.

Source lookup

This capstone deliberately composes several authorities. Use the corresponding system page when changing each part:

Trace each exact symbol listed in Levels 4–7 independently, using the current callers, registrations, firing sites, payloads, and teardown as evidence. A working definition in one subsystem does not prove the other owners are ready, synchronized, or still valid.

Lifecycle and ownership

  1. Successful installation guards global listeners and composable page wrappers separately; partial installation is not rolled back universally.
  2. Avatar entry invalidates an older generation but does not create UI.
  3. Page population selects the current avatar/arena, subscribes, seeds, starts the tick, and begins view readiness.
  4. Entity events update membership synchronously and catch their own failures.
  5. The tick updates countdown and personal spatial analytics.
  6. On its successful path, page disposal copies the summary, unsubscribes, cancels, destroys and clears live state, then attempts to save copied history before native disposal. The wrapper logs deactivation failures and still calls the original disposal method.
  7. Avatar exit, disconnect, and fini request fallback finalization. A fully completed repeated deactivation adds no record until another activation; partial failure can leave resources or lose the copied record.

State and data-authority model

LayerStateMeaning
FactsPeriod/end time, entity enter/leave, positionCurrent client observations
DerivedCounts, remaining seconds, sampled distanceMod calculation with declared limits
PresentationJSON/model/DOM/SVGDisplay only
HistoricalExit summaryObserved session record, not result
InvalidationGenerationRejects delayed work from prior battles

Implementation walkthrough

One state owner, several authorities

The class coordinates lifecycle but does not promote every field to equal truth. Each update method is named for its producer and copies only primitives.

One periodic owner

Countdown and position both need low-rate refresh. The normal activation starts one tick chain and retains its callback handle. Each successor is requested 0.25 seconds after sampling/publication; there is no fixed four-Hz guarantee or pending-handle guard for arbitrary extra calls.

Copy before clear; save after cleanup

deactivate() creates the record from live primitives first. On its normal path it then removes subscriptions and resources, clears state, and finally attempts to write the already-copied history. A caught save failure leaves memory ahead of disk, with no rollback or scheduled retry. Fully completed repeated teardown does not add another record until reactivation. Record-copy, cancellation or destruction failures can prevent later stages; copying first does not make finalization transactional. Selected method checks with inert owners and a recording saver confirm this order and retain copied values; they perform no real file, game or native operations and do not update the historical client verification.

Source-reviewed implementation boundaries

This review describes the authored release 1.0.1, not a new client test. Its Python imports and R.mods... layout accessor are eager; the source comment about optional presentation support does not implement an import fallback. Module hooks and controller subscriptions have separate installation flags. A layout-check return does not prevent a later page hook from attempting activation. Repeated successful admission finalizes the old observation as superseded, even for the same arena; no page-instance token or arena ledger makes records unique.

Subscription precedes seeding, but the seed accepts started Vehicle objects while live enters do not repeat those filters. Retained IDs and copied side labels have no periodic refresh. Position sampling looks up the current controlled vehicle each time; an explicit missing/invalid sample resets the distance baseline, whereas an exception or direct valid replacement can preserve it. Route points have no break marker. The normalization tolerance is an inclusive [-0.001, 1.001] fraction of each map axis before clamping and four-decimal rounding; bounds admission itself does not require finite corners.

Publication caches serialized bytes before checking the model or completing its transaction. An unchanged payload can therefore suppress another attempt after failed delivery; the model constructor separately seeds initial JSON. The schema-1 payload has no arena identity, sequence or freshness token. View finalization clears the owner’s model without checking model identity. Parent/tick callbacks clear their saved handle before checking generation, and the frontend promise plus two animation frames has no unload liveness guard. Sequential frontend cleanup can stop on an exception. Ownership and generation checks do not establish universal stale-work exclusion.

History loads and validates the whole file before retaining the last 50 accepted records. A list/dictionary status can abort the load and return an empty store; a parseable unsupported schema also returns empty without disabling later writes. The writer uses a fixed temporary file, flush/fsync and parse-only readback, then removes the active file before renaming the temporary file. It has no backup or restoration, and an interruption can leave the active file absent. This is synchronous staged replacement, not crash-atomic persistence. History/payload schema 1 is separate from release 1.0.1.

The ownership graph above is conceptual: the summary copies live primitives during deactivation, not the Gameface payload. Keep the complete test plan separate from the narrow historical and inert-method evidence. Current native registration, rendered behavior, fault recovery and the full matrix still require their own validation.

Complete source code

# -*- coding: utf-8 -*-
"""Display and persist explicitly qualified personal battle analytics."""
from __future__ import absolute_import, unicode_literals

# Reader map:
#   1. History helpers validate the persistent observational summary.
#   2. Wulf classes transport one coherent payload into Gameface.
#   3. _Dashboard owns every battle fact, callback, subscription, and teardown.

import json
import logging
import math
import os
import time

import BigWorld
import constants
from Avatar import PlayerAvatar
from PlayerEvents import g_playerEvents
from Vehicle import Vehicle
from frameworks.wulf import (ViewFlags, ViewModel, ViewSettings, WindowFlags,
                             WindowLayer, WindowStatus)
from gui.Scaleform.daapi.view.battle.shared.page import SharedPage
from gui.impl.pub import ViewImpl, WindowImpl
from helpers import dependency, getPreferencesDirPath
from skeletons.gui.impl import IGuiLoader

# Optional presentation support must not make module import fatal.
from gui.impl.gen import R

__version__ = '1.0.1'
_LOGGER = logging.getLogger(__name__)
_MOD_NAME = 'personal_battle_analytics'
_LAYOUT = R.mods.tutorial.PersonalBattleAnalytics.views.PersonalBattleAnalytics
_HISTORY_PATH = os.path.join(getPreferencesDirPath(), 'mods', _MOD_NAME,
                             'history.json')
_WIDTH, _HEIGHT = 430, 250
_MAX_POINTS, _MAX_HISTORY = 96, 50
_MIN_WORLD_DISTANCE_SQ = 4.0
_MAX_PARENT_RETRIES = 80
_HISTORY_STATUSES = frozenset(('observed_page_exit', 'observed_avatar_exit',
                               'disconnected', 'superseded',
                               'activation_failed', 'client_shutdown'))
_PERIOD_NAMES = {constants.ARENA_PERIOD.IDLE: 'Idle',
                 constants.ARENA_PERIOD.WAITING: 'Waiting',
                 constants.ARENA_PERIOD.PREBATTLE: 'Prebattle',
                 constants.ARENA_PERIOD.BATTLE: 'Battle',
                 constants.ARENA_PERIOD.AFTERBATTLE: 'After battle'}


def _load_history():
    """Load only schema-safe bounded records from user-owned storage."""
    try:
        with open(_HISTORY_PATH, 'rb') as stream:
            data = json.load(stream)
        if (isinstance(data, dict) and data.get('schemaVersion') == 1 and
                isinstance(data.get('records'), list)):
            records = []
            for value in data['records']:
                record = _sanitize_history_record(value)
                if record is not None:
                    records.append(record)
            return {'schemaVersion': 1, 'records': records[-_MAX_HISTORY:]}
    except IOError:
        pass
    except Exception:
        _LOGGER.exception('%s: history read failed', _MOD_NAME)
    return {'schemaVersion': 1, 'records': []}


def _sanitize_history_record(value):
    """Copy the supported summary fields without trusting disk values."""
    if not isinstance(value, dict):
        return None
    integer_fields = ('startedAt', 'endedAt', 'durationSeconds',
                      'sampledDistanceMeters', 'observedEnters',
                      'observedLeaves')
    if (not isinstance(value.get('arenaID'), (str, unicode)) or
            value.get('status') not in _HISTORY_STATUSES):
        return None
    for name in integer_fields:
        field = value.get(name)
        if (not isinstance(field, (int, long)) or isinstance(field, bool) or
                field < 0):
            return None
    if value['endedAt'] < value['startedAt']:
        return None
    return dict((name, value[name]) for name in (
        'arenaID', 'startedAt', 'endedAt', 'durationSeconds',
        'sampledDistanceMeters', 'observedEnters', 'observedLeaves', 'status'))


def _save_history(store):
    """Persist deterministic JSON through a flushed temporary file."""
    parent = os.path.dirname(_HISTORY_PATH)
    temp_path = _HISTORY_PATH + '.tmp'
    try:
        if not os.path.isdir(parent):
            os.makedirs(parent)
        with open(temp_path, 'wb') as stream:
            payload = json.dumps(store, indent=2, sort_keys=True).encode('utf-8')
            stream.write(payload)
            stream.flush()
            os.fsync(stream.fileno())
        with open(temp_path, 'rb') as stream:
            json.load(stream)
        if os.path.isfile(_HISTORY_PATH):
            os.remove(_HISTORY_PATH)
        os.rename(temp_path, _HISTORY_PATH)
    except Exception:
        _LOGGER.exception('%s: history save failed', _MOD_NAME)
    finally:
        if os.path.isfile(temp_path):
            try:
                os.remove(temp_path)
            except Exception:
                _LOGGER.exception('%s: temp cleanup failed', _MOD_NAME)


class _DashboardModel(ViewModel):
    """Expose one coherent JSON payload plus the frontend-ready command."""
    def __init__(self, payload):
        self._payload = payload
        super(_DashboardModel, self).__init__(properties=1, commands=1)

    def _initialize(self):
        super(_DashboardModel, self)._initialize()
        self._addStringProperty('payload', self._payload)
        self.onReady = self._addCommand('onReady')

    def setPayload(self, value):
        self._setString(0, value)


class _DashboardView(ViewImpl):
    """Bind the Gameface command to the battle-scoped dashboard owner."""
    def __init__(self, owner):
        self._owner = owner
        model = _DashboardModel(owner.payload)
        owner.attach_model(model)
        super(_DashboardView, self).__init__(ViewSettings(
            layoutID=_LAYOUT(), flags=ViewFlags.VIEW, model=model))

    def _getEvents(self):
        return ((self.getViewModel().onReady, self._owner.on_content_ready),)

    def _finalize(self):
        self._owner.attach_model(None)
        super(_DashboardView, self)._finalize()


class _DashboardWindow(WindowImpl):
    """Notify the owner when Wulf has loaded the native view surface."""
    def __init__(self, content, parent, owner):
        self._owner = owner
        super(_DashboardWindow, self).__init__(
            WindowFlags.WINDOW, content=content, layer=WindowLayer.WINDOW,
            name='PersonalBattleAnalytics', parent=parent)

    def _onReady(self):
        self._owner.on_window_ready()


class _Dashboard(object):
    """Compose independent battle sources without blurring their authority."""
    def __init__(self):
        self._installed = self._active = False
        self._avatar = self._bounds = None
        self._avatar_events_bound = False
        self._entities = {}
        self._entered = self._left = 0
        self._period = constants.ARENA_PERIOD.IDLE
        self._period_end = 0.0
        self._arena_id = None
        self._started_at = None
        self._runtime_started = None
        self._route = []
        self._last_world = None
        self._distance = 0.0
        self._generation = 0
        self._tick_id = self._parent_id = None
        self._window = self._model = None
        self._window_ready = self._content_ready = False
        self._parent_size = self._last_payload = None
        self._history = {'schemaVersion': 1, 'records': []}
        self._enter_handler = self._on_avatar_enter
        self._exit_handler = self._on_avatar_exit
        self._disconnect_handler = self._on_disconnect
        self._period_handler = self._on_period
        self._vehicle_enter_handler = self._on_vehicle_enter
        self._vehicle_leave_handler = self._on_vehicle_leave

    @property
    def payload(self):
        counts = {'ally': 0, 'enemy': 0, 'unknown': 0}
        for side in self._entities.values():
            counts[side] += 1
        seconds = max(0, int(math.ceil(
            self._period_end - BigWorld.serverTime()))) if self._period_end else 0
        return json.dumps({'schemaVersion': 1,
            'phase': _PERIOD_NAMES.get(self._period, 'Unknown'),
            'seconds': seconds, 'counts': counts,
            'entered': self._entered, 'left': self._left,
            'distanceMeters': int(round(self._distance)),
            'route': self._route}, separators=(',', ':'))

    def start(self):
        if self._installed:
            return
        try:
            layout_id = _LAYOUT()
        except Exception:
            _LOGGER.exception('%s: native layout lookup failed', _MOD_NAME)
            return
        if layout_id < 0:
            _LOGGER.error('%s: native layout declaration is missing', _MOD_NAME)
            return
        self._history = _load_history()
        g_playerEvents.onAvatarBecomePlayer += self._enter_handler
        g_playerEvents.onAvatarBecomeNonPlayer += self._exit_handler
        g_playerEvents.onDisconnected += self._disconnect_handler
        g_playerEvents.onArenaPeriodChange += self._period_handler
        self._installed = True
        _LOGGER.info('%s %s: initialized', _MOD_NAME, __version__)

    def stop(self):
        if self._installed:
            g_playerEvents.onAvatarBecomePlayer -= self._enter_handler
            g_playerEvents.onAvatarBecomeNonPlayer -= self._exit_handler
            g_playerEvents.onDisconnected -= self._disconnect_handler
            g_playerEvents.onArenaPeriodChange -= self._period_handler
            self._installed = False
        self.deactivate('client_shutdown')

    def _on_avatar_enter(self):
        self.deactivate('superseded')

    def _on_avatar_exit(self):
        self.deactivate('observed_avatar_exit')

    def _on_disconnect(self):
        self.deactivate('disconnected')

    def _on_period(self, period, end_time, _length, _additional):
        self._period, self._period_end = period, end_time
        self.publish()

    def on_page_ready(self):
        """Activate all battle-owned sources as one cleanup transaction."""
        avatar = BigWorld.player()
        arena = getattr(avatar, 'arena', None)
        bounds = getattr(getattr(arena, 'arenaType', None), 'boundingBox', None)
        if not isinstance(avatar, PlayerAvatar) or not self._valid_bounds(bounds):
            return
        arena_id = getattr(avatar, 'arenaUniqueID', None)
        if arena_id is None:
            _LOGGER.warning('%s: arenaUniqueID unavailable', _MOD_NAME)
            return
        self.deactivate('superseded')
        self._active, self._avatar, self._bounds = True, avatar, bounds
        self._arena_id = arena_id
        self._period = getattr(arena, 'period', constants.ARENA_PERIOD.IDLE)
        self._period_end = getattr(arena, 'periodEndTime', 0.0)
        self._started_at, self._runtime_started = int(time.time()), BigWorld.time()
        try:
            avatar.onVehicleEnterWorld += self._vehicle_enter_handler
            self._avatar_events_bound = True
            avatar.onVehicleLeaveWorld += self._vehicle_leave_handler
        except Exception:
            _LOGGER.exception('%s: avatar subscription failed', _MOD_NAME)
            self.deactivate('activation_failed')
            return
        for entity in BigWorld.entities.values():
            if isinstance(entity, Vehicle) and getattr(entity, 'isStarted', False):
                self._remember(entity, False)
        generation = self._generation
        self._tick(generation)
        self._ensure_window(generation)
        self.publish()

    @staticmethod
    def _valid_bounds(bounds):
        try:
            return (float(bounds[1][0]) > float(bounds[0][0]) and
                    float(bounds[1][1]) > float(bounds[0][1]))
        except (AttributeError, IndexError, TypeError, ValueError):
            return False

    def _on_vehicle_enter(self, vehicle):
        try:
            if self._remember(vehicle, True):
                self.publish()
        except Exception:
            _LOGGER.exception('%s: vehicle-enter handling failed', _MOD_NAME)

    def _on_vehicle_leave(self, vehicle):
        try:
            entity_id = getattr(vehicle, 'id', None)
            if entity_id in self._entities:
                del self._entities[entity_id]
                self._left += 1
                self.publish()
        except Exception:
            _LOGGER.exception('%s: vehicle-leave handling failed', _MOD_NAME)

    def _remember(self, vehicle, transition):
        """Store copied entity identity and side, never the Vehicle object."""
        entity_id = getattr(vehicle, 'id', None)
        if entity_id is None or entity_id in self._entities:
            return False
        info = getattr(vehicle, 'publicInfo', None)
        team = getattr(info, 'team', None)
        if team is None and info is not None:
            try:
                team = info['team']
            except (KeyError, TypeError):
                team = None
        avatar_team = getattr(self._avatar, 'team', None)
        side = 'unknown' if team is None or avatar_team is None else (
            'ally' if team == avatar_team else 'enemy')
        self._entities[entity_id] = side
        if transition:
            self._entered += 1
        return True

    def _tick(self, generation):
        self._tick_id = None
        if generation != self._generation or not self._active:
            return
        try:
            self._sample_position()
            self.publish()
        except Exception:
            _LOGGER.exception('%s: analytics tick failed', _MOD_NAME)
        if generation == self._generation and self._active:
            self._tick_id = BigWorld.callback(0.25,
                                              lambda: self._tick(generation))

    def _sample_position(self):
        """Sample local movement without bridging missing-vehicle gaps."""
        vehicle_id = getattr(self._avatar, 'playerVehicleID', None)
        vehicle = BigWorld.entity(vehicle_id) if vehicle_id else None
        if (vehicle is None or not getattr(vehicle, 'isStarted', False) or
                getattr(vehicle, 'spaceID', None) != getattr(self._avatar, 'spaceID', None)):
            self._last_world = None
            return
        x, z = float(vehicle.position.x), float(vehicle.position.z)
        bl, tr = self._bounds
        nx = (x - float(bl[0])) / (float(tr[0]) - float(bl[0]))
        nz = (z - float(bl[1])) / (float(tr[1]) - float(bl[1]))
        if not (-0.001 <= nx <= 1.001 and -0.001 <= nz <= 1.001):
            self._last_world = None
            return
        if self._last_world is not None:
            dx, dz = x - self._last_world[0], z - self._last_world[1]
            if dx * dx + dz * dz < _MIN_WORLD_DISTANCE_SQ:
                return
            self._distance += math.sqrt(dx * dx + dz * dz)
        self._last_world = (x, z)
        self._route.append([round(min(1.0, max(0.0, nx)), 4),
                            round(1.0 - min(1.0, max(0.0, nz)), 4)])
        # The visual route is a rolling window; total distance remains cumulative.
        self._route = self._route[-_MAX_POINTS:]

    def _ensure_window(self, generation):
        self._load_parent(generation, 0)

    def _load_parent(self, generation, retry):
        """Bound readiness retries and reject callbacks from old battles."""
        self._parent_id = None
        if generation != self._generation or not self._active or self._window:
            return
        parent = dependency.instance(IGuiLoader).windowsManager.getMainWindow()
        if (parent is None or parent.proxy is None or
                parent.windowStatus != WindowStatus.LOADED):
            if retry < _MAX_PARENT_RETRIES:
                self._parent_id = BigWorld.callback(
                    0.1, lambda: self._load_parent(generation, retry + 1))
            return
        self._parent_size = tuple(parent.size)
        self._window = _DashboardWindow(_DashboardView(self), parent, self)
        self._window.load()

    def attach_model(self, model):
        self._model = model
        if model is not None:
            self.publish()

    def on_window_ready(self):
        self._window_ready = True
        self._show()

    def on_content_ready(self):
        self._content_ready = True
        self._show()

    def _show(self):
        if self._window and self._window_ready and self._content_ready and self._active:
            self._window.move(24, 120)
            if self._window.isHidden():
                self._window.show(False)

    def publish(self):
        if not self._active:
            return
        payload = self.payload
        if payload != self._last_payload:
            self._last_payload = payload
            if self._model is not None:
                with self._model.transaction() as model:
                    model.setPayload(payload)

    def deactivate(self, status):
        """Copy final history first, then release every live owner."""
        record = None
        if self._active and self._started_at is not None:
            record = {'arenaID': unicode(self._arena_id),
                      'startedAt': self._started_at,
                      'endedAt': max(self._started_at, int(time.time())),
                      'durationSeconds': max(0, int(BigWorld.time() - self._runtime_started)),
                      'sampledDistanceMeters': int(round(self._distance)),
                      'observedEnters': self._entered,
                      'observedLeaves': self._left,
                      'status': status}
        self._active = False
        self._generation += 1
        if self._avatar_events_bound and self._avatar is not None:
            try:
                self._avatar.onVehicleEnterWorld -= self._vehicle_enter_handler
                self._avatar.onVehicleLeaveWorld -= self._vehicle_leave_handler
            except Exception:
                _LOGGER.exception('%s: avatar unsubscription failed', _MOD_NAME)
        self._avatar_events_bound = False
        for name in ('_tick_id', '_parent_id'):
            callback_id = getattr(self, name)
            if callback_id is not None:
                BigWorld.cancelCallback(callback_id)
                setattr(self, name, None)
        if self._window is not None:
            self._window.destroy()
        self._window = self._model = self._avatar = self._bounds = None
        self._window_ready = self._content_ready = False
        self._parent_size = self._last_payload = None
        self._entities.clear()
        self._entered = self._left = 0
        self._route, self._last_world, self._distance = [], None, 0.0
        self._started_at = self._runtime_started = self._arena_id = None
        if record is not None:
            self._history['records'].append(record)
            self._history['records'] = self._history['records'][-_MAX_HISTORY:]
            _save_history(self._history)


_DASHBOARD = _Dashboard()
_INSTALLED = False
_ORIGINAL_POPULATE = _ORIGINAL_DISPOSE = None


def _page_populate(self, *args, **kwargs):
    """Activate after native population has established the battle page."""
    result = _ORIGINAL_POPULATE(self, *args, **kwargs)
    try:
        _DASHBOARD.on_page_ready()
    except Exception:
        _LOGGER.exception('%s: page-populate handling failed', _MOD_NAME)
    return result


def _page_dispose(self, *args, **kwargs):
    """Persist and tear down before native owners disappear."""
    try:
        _DASHBOARD.deactivate('observed_page_exit')
    except Exception:
        _LOGGER.exception('%s: page-dispose handling failed', _MOD_NAME)
    return _ORIGINAL_DISPOSE(self, *args, **kwargs)


def init():
    global _INSTALLED, _ORIGINAL_POPULATE, _ORIGINAL_DISPOSE
    if _INSTALLED:
        return
    try:
        _ORIGINAL_POPULATE, _ORIGINAL_DISPOSE = SharedPage._populate, SharedPage._dispose
        SharedPage._populate, SharedPage._dispose = _page_populate, _page_dispose
        _DASHBOARD.start()
        _INSTALLED = True
    except Exception:
        _LOGGER.exception('%s: initialization failed', _MOD_NAME)
        fini()


def fini():
    global _INSTALLED
    try:
        _DASHBOARD.stop()
    except Exception:
        _LOGGER.exception('%s: finalization failed', _MOD_NAME)
    if SharedPage._populate is _page_populate:
        SharedPage._populate = _ORIGINAL_POPULATE
    if SharedPage._dispose is _page_dispose:
        SharedPage._dispose = _ORIGINAL_DISPOSE
    _INSTALLED = False
[{"id":"R.mods.tutorial.PersonalBattleAnalytics.views.PersonalBattleAnalytics","type":"Layout","path":"coui://gui/gameface/mods/tutorial/PersonalBattleAnalytics/PersonalBattleAnalytics.html","parameters":{"entrance":"PersonalBattleAnalytics","impl":"gameface"}}]

Native declaration field guide: id must exactly match R.mods.tutorial.PersonalBattleAnalytics.views.PersonalBattleAnalytics in Python. The path resolves the packaged HTML, and parameters selects its entrance and implementation. Mount this strict JSON list at res/gui/gen_resources/tutorial_personal_battle_analytics.json; do not add legacy itemID or extension fields.

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="PersonalBattleAnalytics.css">
</head>
<body>
  <section id="panel">
    <!-- Phase and countdown summarize the current arena-period payload. -->
    <header><b id="phase">—</b><span id="seconds">0s</span></header>
    <div class="grid">
      <!-- The SVG renders only the normalized local-player route. -->
      <svg viewBox="0 0 100 100">
        <rect x="1" y="1" width="98" height="98"/>
        <polyline id="route"/>
      </svg>
      <!-- This right-hand column is intentional, live dashboard content. -->
      <div class="metrics">
        <p>Sampled distance <b id="distance">0 m</b></p>
        <p>Loaded allies <b id="ally">0</b></p>
        <p>Loaded enemies <b id="enemy">0</b></p>
        <p>Unknown <b id="unknown">0</b></p>
        <p>Enters / leaves <b id="transitions">0 / 0</b></p>
      </div>
    </div>
    <footer>Local observations · not roster, result, or exact odometer</footer>
  </section>
  <script src="PersonalBattleAnalytics.js"></script>
</body>
</html>
/* Keep dimensions aligned with the Python window and JavaScript resize. */
html, body { width: 430px; height: 250px; margin: 0; overflow: hidden; background: transparent; }
* { box-sizing: border-box; }
#panel { width: 430px; height: 250px; padding: 12px; color: #dbe6ed; background: rgba(8,13,19,.86); border: 1px solid rgba(142,198,226,.55); border-radius: 5px; font: 13px/20px Arial, sans-serif; }
header { height: 28px; color: #9bd3ed; letter-spacing: 1px; }
header span { float: right; color: #fff; font-size: 18px; }

/* The second grid column is reserved for the five live metric rows. */
.grid { display: grid; grid-template-columns: 194px 1fr; gap: 14px; }
svg { width: 194px; height: 174px; background: rgba(10,27,34,.72); }
rect { fill: none; stroke: #526f7c; stroke-width: .6; }
polyline { fill: none; stroke: #71d3ef; stroke-width: 1.2; stroke-linecap: round; stroke-linejoin: round; }
.metrics p { margin: 4px 0; border-bottom: 1px solid rgba(255,255,255,.08); }
.metrics b { float: right; color: #fff; }
footer { color: #85939e; font-size: 10px; text-align: right; }
(function () {
    'use strict';
    var W = 430,
        H = 250,
        panel = document.getElementById('panel'),
        domHandler = null,
        dataChangedCallbackId = null,
        initialized = false;

    /* Wulf scalar properties can arrive wrapped as { value: ... }. */
    function unwrap(value) {
        return value && typeof value === 'object' && value.value !== undefined
            ? value.value : value;
    }

    /* Keep DOM writes centralized so the HTML IDs remain an explicit contract. */
    function text(id, value) {
        document.getElementById(id).textContent = String(value);
    }

    /* Render one coherent copied payload; no field reads live game state. */
    function render() {
        var d = {counts: {}, route: []};
        try { d = JSON.parse(String(unwrap(window.model.payload) || '{}')); }
        catch (error) {}

        text('phase', d.phase || '—');
        text('seconds', (d.seconds || 0) + 's');
        text('distance', (d.distanceMeters || 0) + ' m');
        text('ally', d.counts.ally || 0);
        text('enemy', d.counts.enemy || 0);
        text('unknown', d.counts.unknown || 0);
        text('transitions', (d.entered || 0) + ' / ' + (d.left || 0));

        var route = Array.isArray(d.route) ? d.route : [], points = [];
        for (var i = 0; i < route.length; i += 1) {
            var p = route[i];
            if (Array.isArray(p) && p.length === 2 &&
                    isFinite(p[0]) && isFinite(p[1]))
                points.push((p[0] * 100).toFixed(2) + ',' +
                            (p[1] * 100).toFixed(2));
        }
        document.getElementById('route').setAttribute('points', points.join(' '));
    }

    /* Scale DOM pixels and the native surface as one operation. */
    function scale() {
        var s = Number(viewEnv.getScale());
        if (!isFinite(s) || s <= 0) s = 1;
        var w = Math.max(1, Math.round(W * s)),
            h = Math.max(1, Math.round(H * s));
        panel.style.transform = 'scale(' + s + ')';
        panel.style.transformOrigin = '0 0';
        document.documentElement.style.width = document.body.style.width = w + 'px';
        document.documentElement.style.height = document.body.style.height = h + 'px';
        viewEnv.resizeViewPx(w, h);
        if (window.model && typeof window.model.onReady === 'function') window.model.onReady();
    }

    /* Idempotence prevents duplicate update and scale handlers. */
    function init() {
        if (initialized) return;
        initialized = true;
        render();
        engine.on('viewEnv.onDataChanged', render);
        dataChangedCallbackId = viewEnv.addDataChangedCallback('model', 0, true);
        engine.on('self.onScaleUpdated', scale);
        scale();
    }

    /* Remove exact registrations when Gameface unloads this document. */
    function cleanup() {
        if (initialized) {
            engine.off('viewEnv.onDataChanged', render);
            engine.off('self.onScaleUpdated', scale);
            if (dataChangedCallbackId !== null) {
                viewEnv.removeDataChangedCallback(dataChangedCallbackId, 0);
                dataChangedCallbackId = null;
            }
            initialized = false;
        }
        if (domHandler) {
            engine.off('self.onDomBuilt', domHandler);
            domHandler = null;
        }
    }

    /* Engine and DOM readiness are independent; layout needs two more frames. */
    var dom = window.isDomBuilt ? Promise.resolve() :
        new Promise(function (resolve) {
            domHandler = function () {
                engine.off('self.onDomBuilt', domHandler);
                domHandler = null;
                resolve();
            };
            engine.on('self.onDomBuilt', domHandler);
        });
    window.addEventListener('unload', cleanup);
    Promise.all([engine.whenReady, dom]).then(function () {
        requestAnimationFrame(function () { requestAnimationFrame(init); });
    });
}());

The root Wulf string property is a Gameface wrapper in this client generation. Unwrap its value before JSON parsing and register the root-model callback explicitly, then remove that callback during unload.

<root><id>tutorial.personal_battle_analytics</id><version>1.0.1</version><name>Personal Battle Analytics</name><description>Displays explicitly qualified local battle observations.</description></root>

Minimal project structure

personal-battle-analytics/
├── mod_personal_battle_analytics.py
└── stage/
    ├── meta.xml
    └── res/
        ├── scripts/client/gui/mods/mod_personal_battle_analytics.pyc
        ├── gui/gen_resources/tutorial_personal_battle_analytics.json
        └── gui/gameface/mods/tutorial/PersonalBattleAnalytics/
            ├── PersonalBattleAnalytics.html
            ├── PersonalBattleAnalytics.css
            └── PersonalBattleAnalytics.js

preferences/mods/personal_battle_analytics/history.json  # runtime

Packaging and installation

  1. Target WG 2.4.1.0 and validate the native declaration before packaging.
  2. Compile with the target-compatible Python 2.7 compiler.
  3. Stage bytecode, native declaration, frontend, and aligned manifest.
  4. Create a stored-member personal_battle_analytics_1.0.1.wotmod.
  5. Do not package or overwrite runtime history.
  6. Inspect/extract-test before user-side installation.

Window-layer scope: WindowLayer.WINDOW keeps native menus and queued popups above these passive panels. The minimal example does not add TAB/full-statistics hiding; that surface belongs to the battle page below the panel. Verify this limitation in the target client and follow window-layer and keyboard-routing guidance when adding visibility controls.

Testing and debugging

Deterministic tests

This is an unexecuted full-plan checklist. The selected inert teardown checks above establish only their stated ordering and copied-value scope.

  • Drive fake period, entity, vehicle, bounds, clock, model, and window objects through the complete lifecycle.
  • Inject an entity enter during seeding and assert ID deduplication.
  • Run stale tick and parent callbacks after cleanup.
  • Test route corners, the movement threshold, explicit invalid-sample gaps, direct vehicle changes, the 96-point cap, and sampled distance.
  • Test malformed, missing and unsupported history, unhashable status values, whole-file loading, 50-record retention, and replacement failures.
  • Compare payload keys, DOM IDs, dimensions, resource paths, and versions.

Runtime matrix

Current-client scenarios to execute and record; these rows do not extend the historical runtime evidence.

ScenarioExpected
Normal battlePhase, observations, distance, and route update with visible qualifications
AOI churnLoaded counts change without being labeled deaths or spotting
RespawnAn explicit invalid sample clears the distance baseline; direct replacement without a gap can bridge positions, and the flat route still joins points
Early page exitSuccessful cleanup appends one observation for that activation; inject cleanup failures separately and check for retained resources
Second battleVerify fresh admitted state, retained-owner cleanup, stale handles and page reconstruction; one arena can have multiple observations

Failure modes and edge cases

  • Partial subscription: activation requests cleanup on the retained avatar, but both removals share one catch; failure of the first skips the second and the bound flag is cleared. Partial global installation has no per-handler rollback mask.
  • Plain event exception: each entity handler contains its own reaction and logs a traceback.
  • Position gap: explicit invalid samples clear the distance baseline. Unexpected sample errors retain it; direct vehicle changes, teleports or seeks can bridge distance. The flat SVG route has no gap marker.
  • History failure: a caught save error occurs after normal live cleanup and logs a traceback, but earlier cleanup failures can prevent append/save. All reader IOError cases silently return an empty store.
  • Resource failure: parent polling can exhaust without a window; a load failure can retain a truthy window that blocks recreation until cleanup. Eager client imports and the layout accessor have no optional-import fallback.
  • Later wrapper: finalization restores only wrappers still owned by this mod.

Performance considerations

Entity work is event-driven after a seed on each admitted page; there is no periodic membership reconciliation or explicit entity-count cap. A tick requests its successor 0.25 seconds after work, rather than at a guaranteed four Hz. Python retains 96 route points and the last 50 admitted history records, but JSON size, whole-file parsing/validation and synchronous file latency have no explicit bound. Parent readiness has at most 81 checks on one normal retry chain, not an eight-second deadline. Sampling errors skip publication on that tick; scheduling itself is outside the local catch.

Compatibility and version sensitivity

SystemRecheck
Period event/server timeSignature, fields, timer domain
Avatar vehicle eventsCreation, firing, teardown, respawn
Entity fields/boundsReadiness, schema, coordinate semantics
SharedPage/GamefaceHooks, parent, resources, sizing, listener names
FilesystemPreferences path, rename/fsync behavior

Why this architecture?

  • Why one owner? One admitted page selects the observation owner and staged cleanup order; repeated admission and partial failures do not form a rollback transaction.
  • Why not one source? Phase, entity presence, and position have different authorities.
  • Why one payload? One serialized projection groups local values for display; it is not an atomic snapshot of all producers or proof of delivery.
  • Why persist summaries only? High-frequency route history would increase disk cost without serving the stated feature.
  • Why no external bridge? Level 8 remains optional; the dashboard has no external consumer requirement.

Possible extensions

  • Add localized labels while preserving stable payload keys.
  • Add a user-controlled reset command with explicit frontend validation.
  • Correlate official result delivery in a separate source layer.
  • Expose the same copied payload through the Level 8 bridge.
  • Advance to Level 10 with replay-aware event recording and asynchronous file I/O.

Maintenance checklist

  • Recheck event names/signatures, entity attributes, arena bounds, service registration, and teardown order.
  • Re-test seed/event overlap, AoI changes, and respawn.
  • Verify X/Z normalization and one UI-axis inversion.
  • Exercise stale callbacks and consecutive battles.
  • Validate history schema, preferences path, and failure recovery.
  • Match view/resource registration and Python/model/JS payload fields.
  • Check Python 2.7 and Error Reporter compatibility.
  • Runtime-test without interpreting observations as results.

What you learned

A flagship architecture is not a pile of APIs. It is a set of explicit authorities coordinated by one lifecycle owner. Facts remain qualified, derived metrics remain reproducible, UI remains presentation, history remains observational, and cleanup ownership remains explicit. Static review exposes the partial-failure and deferred-work limits that must be tested at battle boundaries.