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:
- Level 4: Battle Phase Monitor
- Level 5: Battle Entity Census
- Level 6: Personal Route Trace
- Level 7: Battle Session Journal
- Level 8: Local Battle Snapshot Bridge
The code intentionally integrates only systems needed by this feature.
Requirements and constraints
Functional
- Create only after the current shared battle page populates.
- Subscribe to the exact current avatar before seeding loaded vehicles.
- Update phase from the four-argument period event.
- Resolve the current local player vehicle on each tick; request the next tick 0.25 seconds after work finishes.
- Attempt publication when serialized payload bytes change; this cache comparison does not acknowledge model delivery.
- 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:
- Arena periods and participant/entity layers — phase and loaded-presence inputs.
- Arena normalization and entity positions — route and sampled-distance inputs.
- Python/Gameface model contract and frontend data updates — the dashboard payload and render boundary.
- Durable writes and bounded JSON stores — the observational summary history.
- Teardown order and Gameface cleanup ownership — staged finalization and its failure boundaries.
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
- Successful installation guards global listeners and composable page wrappers separately; partial installation is not rolled back universally.
- Avatar entry invalidates an older generation but does not create UI.
- Page population selects the current avatar/arena, subscribes, seeds, starts the tick, and begins view readiness.
- Entity events update membership synchronously and catch their own failures.
- The tick updates countdown and personal spatial analytics.
- 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.
- Avatar exit, disconnect, and
finirequest 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
| Layer | State | Meaning |
|---|---|---|
| Facts | Period/end time, entity enter/leave, position | Current client observations |
| Derived | Counts, remaining seconds, sampled distance | Mod calculation with declared limits |
| Presentation | JSON/model/DOM/SVG | Display only |
| Historical | Exit summary | Observed session record, not result |
| Invalidation | Generation | Rejects 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 # runtimePackaging and installation
- Target WG 2.4.1.0 and validate the native declaration before packaging.
- Compile with the target-compatible Python 2.7 compiler.
- Stage bytecode, native declaration, frontend, and aligned manifest.
- Create a stored-member
personal_battle_analytics_1.0.1.wotmod. - Do not package or overwrite runtime history.
- 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.
| Scenario | Expected |
|---|---|
| Normal battle | Phase, observations, distance, and route update with visible qualifications |
| AOI churn | Loaded counts change without being labeled deaths or spotting |
| Respawn | An explicit invalid sample clears the distance baseline; direct replacement without a gap can bridge positions, and the flat route still joins points |
| Early page exit | Successful cleanup appends one observation for that activation; inject cleanup failures separately and check for retained resources |
| Second battle | Verify 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
IOErrorcases 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
| System | Recheck |
|---|---|
| Period event/server time | Signature, fields, timer domain |
| Avatar vehicle events | Creation, firing, teardown, respawn |
| Entity fields/bounds | Readiness, schema, coordinate semantics |
| SharedPage/Gameface | Hooks, parent, resources, sizing, listener names |
| Filesystem | Preferences 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.