The example maintains local lifecycle observations beside preferences.xml. It admits explicit schemas, normalizes records, retains the last 100 valid history entries in memory and attempts writes through a flushed temporary file. Recovery depends on a surviving, admissible active marker; failures can leave memory and disk different.

What we’re building

A headless local journal with one active observation marker and up to 100 retained history records in memory. Avatar entry records arena identity, bonus type, a wall-clock start and a process-local BigWorld.time() baseline. Exit or disconnect attempts to save a terminal observation. Startup can recover an admitted, persisted active marker as interrupted; that status does not identify why the previous run ended.

Final result

  • Human-readable, sorted-key journal.json; record contents vary with observations.
  • Explicit schema version 2 and a one-step v1-to-v2 migration.
  • Field reconstruction and last-100 retention, with Boolean integer values rejected.
  • Flushed temporary-file writes, parse-checked rotation and conditional restoration.
  • Owner-local terminal-event idempotence and traceback-bearing diagnostics.

Verification scope: the implementation below is an authored example. The acceptance list and test matrix are verification plans. The historical build marker retains its original scope; this wording review establishes static control flow, not new client, replay or filesystem-fault test results.

What you’ll learn

  • How storage ownership differs from account settings and server settings.
  • Why wall-clock timestamps and process-local engine-time deltas are separate domains.
  • How to dispatch schemas before enabling automatic writes.
  • Why parse-readable backup rotation is separate from schema admission.
  • How a persisted active marker supports interruption recovery without forming a transactional write-ahead log.

Difficulty and prerequisites

Level 7 — Durable state. Complete the Level 6 route trace. You should understand context events, idempotent teardown, data authority, Python 2.7 strings, and exception reporting.

Requirements and constraints

Functional

  1. Select active JSON, trying .bak on a read/size/parse exception; then admit its schema.
  2. Use explicit v1 migration only when no schemaVersion marker is present.
  3. Recover a prior admitted active marker before subscriptions.
  4. Install the entry marker in memory and attempt a save; clear it in memory on entry failure.
  5. Append and clear active state on the first terminal observation, then attempt persistence.
  6. Retain the last 100 valid history records in input/append order, without arena-ID deduplication.

Truth limitations

  • Records describe locally observed lifecycle edges, not a complete server battle ledger or account-separated history.
  • observed_exit does not establish victory, defeat, survival or delivered results.
  • Wall-clock timestamps can jump. End clamping and engine-time duration are independent.
  • The process-local baseline is not persisted. Recovery leaves end and duration unknown.
  • There is no replay exclusion or seek adjustment. Existing clock guidance records replay pause/rewind limits; a clamped engine delta is not a monotonic real-time measurement.

Acceptance criteria

Verify these targets with fixtures and the intended client; their listing is not evidence of a passing run.

  • With both files absent, startup attempts to save an empty v2 store.
  • A failed active read can select a backup, which must still pass schema admission.
  • A parseable unsupported active schema aborts startup without trying a supported backup or installing handlers.
  • Only parse/size-readable active bytes qualify for backup rotation; this is not schema validation.
  • v1 translation and final sanitization normalize memory; without a recovered active marker, existing bytes await a later save.
  • A prior admitted active marker becomes interrupted with null end and duration, and a save is attempted before subscriptions.
  • Duration uses a rounded, nonnegative BigWorld.time() delta, never wall-clock subtraction.
  • History in memory remains capped; save failures can leave memory and disk different.
  • Ordinary invalid records are dropped, while exception-raising malformed fields can abort admission.
  • Later terminal events find active state cleared; repeated entry can instead supersede it.
  • Caught unexpected failures retain traceback-bearing diagnostics.

Architecture

startup: select active JSON --read/size/parse exception--> try .bak
         admit schema / migrate / sanitize
         recover prior active? append interrupted, clear active, attempt save
         subscribe only after successful startup steps

entry: finalize existing active as superseded (may save)
       install new marker + engine baseline in memory; attempt save
       entry failure clears active/baseline, without undoing history
terminal: append record; cap history; clear active/baseline; attempt save

save: temp write -> flush -> fsync -> parse/size-check temp
      readable active? rotate to .bak : remove active, preserve existing .bak
      promote temp; on failure attempt restoration if active absent
      finally attempt stale-temp cleanup

Mutations precede saves. These steps are not one crash-atomic transaction.

Researching the implementation

The related durable-JSON pattern stores user-owned state beneath getPreferencesDirPath(), validates loaded types, dispatches explicit schema versions and serializes with sorted keys. The helper delegates to the native preferences-file path and returns its directory; that establishes path construction, not write access or filesystem durability. This example flushes and fsyncs a temporary file, checks it for parse/size readability, rotates readable active bytes and conditionally attempts backup restoration. Inspect every failure boundary before reusing the pattern.

Source lookup

These references explain the storage guarantees and their limits:

Search the current client and repository patterns for getPreferencesDirPath, fsync, temporary-file promotion, backup restoration, schema-version checks, and bounded-history truncation. Treat the client names as investigation entry points; filesystem rename guarantees remain platform-sensitive.

Lifecycle and ownership

  1. Import: evaluate the preferences path and construct the owner; no journal read/write. Import/path-helper failures occur outside init()'s catch.
  2. init: load, sanitize and recover before adding the three saved handlers. Installation is sequential; no rollback is implemented for partially added subscriptions.
  3. avatar entry: accept a PlayerAvatar, finalize any existing marker as superseded, then validate identity and attempt to save the next marker. Repeated entry for the same arena ID can create another observation.
  4. avatar exit or disconnect: the first terminal handler with active state appends its status and clears active before saving; later terminal signals return. The producers do not establish a universal ordering between these signals.
  5. fini: unsubscribe, mark uninstalled, then finalize remaining active state as client_shutdown. A failed save does not rearm listeners or make repeated stop() retry.

The current avatar producer fires entry after session/component setup and exit during teardown; connection stage handling fires disconnect at stage 6. Their calls supply no arguments to these player events. The owner does not catch up an entry missed before subscription, retain an avatar object, partition by account or consume official result events.

State and data-authority model

FieldSourceMeaning
arenaIDCurrent arena unique ID copied as textCorrelation key, not result ID guarantee
bonusTypeCurrent arenaObserved mode integer when available
startedAt/endedAttime.time()Approximate Unix wall clock
durationSecondsBigWorld.time() deltaRounded, nonnegative engine-time delta; no pause/seek correction
statusMod lifecycleObserved termination condition only

Implementation walkthrough

Strict boundary validation

Each loaded record is reconstructed field by field, explicitly excluding bool from integer domains. Ordinary invalid records return None and are dropped with warnings. A terminal status that is a list or object raises during set membership instead; admission then aborts. Root/schema rejection is not converted into an empty writable store.

Migration pipeline

A present schemaVersion outranks legacy fields, including when its value is unsupported or a Boolean/float. Only an absent marker invokes the explicit v1 translator; migrated and native candidates share the v2 sanitizer. Backup selection precedes schema checking, so a parseable unsupported active document does not trigger backup fallback. Migration, dropped fields and trimming alone do not rewrite an existing file without a recovered active marker.

Write order

The writer flushes and fsyncs a temporary file, then checks parse/size readability before touching active bytes. Active validation is also parse/size based. The writer removes an existing backup before rotating readable active bytes, or removes active bytes it classifies as unreadable while preserving any existing backup. Failed promotion attempts restoration only if active is absent and a backup exists. Restoration and cleanup can fail too; these multiple operations are not crash-atomic.

Memory changes first. Entry failure clears the new marker/baseline but does not undo appended history. Failed terminal saves leave history appended and active cleared; a second terminal signal does not retry. A later successful save may include retained history; a restart instead loads surviving disk bytes. Neither is rollback or guaranteed delivery.

Complete source code

# -*- coding: utf-8 -*-
"""Persist bounded observations of local battle-session lifecycle."""
from __future__ import absolute_import, unicode_literals

# Reader map:
#   1. Validation helpers define the on-disk schema boundary.
#   2. Backup-aware I/O helpers own recovery and promotion rules.
#   3. _Journal maps avatar lifecycle observations into bounded records.

import json
import logging
import os
import threading
import time

import BigWorld
from Avatar import PlayerAvatar
from PlayerEvents import g_playerEvents
from helpers import getPreferencesDirPath

__version__ = '1.0.0'
_LOGGER = logging.getLogger(__name__)
_MOD_NAME = 'battle_session_journal'
_SCHEMA_VERSION = 2
_MAX_HISTORY = 100
_MAX_FILE_BYTES = 1024 * 1024
_PATH = os.path.join(getPreferencesDirPath(), 'mods', _MOD_NAME, 'journal.json')
_VALID_STATUSES = frozenset(('observed_exit', 'disconnected', 'interrupted',
                             'superseded', 'client_shutdown'))


def _is_int(value):
    return isinstance(value, (int, long)) and not isinstance(value, bool)


def _text(value):
    if isinstance(value, unicode):
        return value
    if isinstance(value, str):
        return value.decode('utf-8', 'replace')
    return unicode(value)


def _sanitize_record(value, allow_active):
    """Return a schema-safe copy or None; never trust loaded dictionaries."""
    if not isinstance(value, dict):
        return None
    arena_id = value.get('arenaID')
    started_at = value.get('startedAt')
    bonus_type = value.get('bonusType')
    status = value.get('status')
    ended_at = value.get('endedAt')
    duration = value.get('durationSeconds')
    if not isinstance(arena_id, (str, unicode)) or not arena_id:
        return None
    if not _is_int(started_at) or started_at < 0:
        return None
    if bonus_type is not None and not _is_int(bonus_type):
        return None
    if allow_active:
        if status != 'active' or ended_at is not None or duration is not None:
            return None
    else:
        if status not in _VALID_STATUSES:
            return None
        if status == 'interrupted':
            if ended_at is not None or duration is not None:
                return None
        elif (not _is_int(ended_at) or ended_at < started_at or
              not _is_int(duration) or duration < 0):
            return None
    return {'arenaID': _text(arena_id), 'bonusType': bonus_type,
            'startedAt': int(started_at), 'endedAt': ended_at,
            'durationSeconds': duration, 'status': status}


def _migrate_v1(data):
    """Translate the only supported legacy shape into schema version 2."""
    if (not _is_int(data.get('version')) or data['version'] != 1 or
            not isinstance(data.get('sessions'), list)):
        raise ValueError('not a supported version-1 journal')
    history = []
    for old in data['sessions']:
        if not isinstance(old, dict):
            continue
        history.append({
            'arenaID': old.get('arenaID'),
            'bonusType': old.get('bonusType'),
            'startedAt': old.get('startedAt'),
            'endedAt': old.get('endedAt'),
            'durationSeconds': old.get('durationSeconds'),
            'status': old.get('status', 'observed_exit')})
    return {'schemaVersion': 2, 'history': history, 'active': None}


def _sanitize_store(data):
    """Validate, migrate, and bound the complete journal document."""
    if not isinstance(data, dict):
        raise ValueError('journal root must be an object')
    if 'schemaVersion' in data:
        version = data['schemaVersion']
        if not _is_int(version) or version != _SCHEMA_VERSION:
            raise ValueError('unsupported journal schema')
    else:
        data = _migrate_v1(data)
    if not isinstance(data.get('history'), list):
        raise ValueError('history must be a list')
    history = []
    for candidate in data['history']:
        record = _sanitize_record(candidate, allow_active=False)
        if record is not None:
            history.append(record)
        else:
            _LOGGER.warning('%s: dropped an invalid history record', _MOD_NAME)
    active = data.get('active')
    if active is not None:
        active = _sanitize_record(active, allow_active=True)
        if active is None:
            _LOGGER.warning('%s: dropped an invalid active record', _MOD_NAME)
    return {'schemaVersion': _SCHEMA_VERSION,
            'history': history[-_MAX_HISTORY:], 'active': active}


def _read_json(path):
    if os.path.getsize(path) > _MAX_FILE_BYTES:
        raise ValueError('journal exceeds size limit')
    with open(path, 'rb') as stream:
        return json.load(stream)


def _read_with_backup(path):
    """Prefer the active file, falling back only to a valid backup."""
    try:
        return _read_json(path)
    except Exception:
        _LOGGER.exception('%s: active journal read failed', _MOD_NAME)
        backup = path + '.bak'
        if not os.path.isfile(backup):
            raise
        data = _read_json(backup)
        _LOGGER.warning('%s: loaded backup journal', _MOD_NAME)
        return data


def _ensure_parent(path):
    parent = os.path.dirname(path)
    if not os.path.isdir(parent):
        try:
            os.makedirs(parent)
        except OSError:
            if not os.path.isdir(parent):
                raise


def _atomic_write(path, data):
    """Flush a temporary file before rotating valid state into place."""
    _ensure_parent(path)
    temp_path = '%s.%s.%s.tmp' % (
        path, os.getpid(), threading.current_thread().ident)
    backup_path = path + '.bak'
    try:
        with open(temp_path, 'wb') as stream:
            encoded = json.dumps(data, indent=2, sort_keys=True,
                                 ensure_ascii=False).encode('utf-8')
            stream.write(encoded)
            stream.flush()
            os.fsync(stream.fileno())
        _read_json(temp_path)
        if os.path.isfile(path):
            current_valid = False
            try:
                _read_json(path)
                current_valid = True
            except Exception:
                _LOGGER.exception('%s: refusing to back up corrupt active journal',
                                  _MOD_NAME)
            if current_valid:
                if os.path.isfile(backup_path):
                    os.remove(backup_path)
                os.rename(path, backup_path)
            else:
                os.remove(path)
        try:
            os.rename(temp_path, path)
        except Exception:
            if not os.path.isfile(path) and os.path.isfile(backup_path):
                os.rename(backup_path, path)
            raise
    finally:
        if os.path.isfile(temp_path):
            try:
                os.remove(temp_path)
            except Exception:
                _LOGGER.exception('%s: stale temp cleanup failed', _MOD_NAME)


class _Journal(object):
    """Own journal state and turn client lifecycle edges into observations."""
    def __init__(self):
        self._installed = False
        self._store = {'schemaVersion': _SCHEMA_VERSION,
                       'history': [], 'active': None}
        self._runtime_started = None
        self._enter_handler = self._on_avatar_enter
        self._exit_handler = self._on_avatar_exit
        self._disconnect_handler = self._on_disconnect

    def start(self):
        """Recover storage before subscribing to new lifecycle events."""
        if self._installed:
            return
        self._load()
        self._recover_interrupted()
        g_playerEvents.onAvatarBecomePlayer += self._enter_handler
        g_playerEvents.onAvatarBecomeNonPlayer += self._exit_handler
        g_playerEvents.onDisconnected += self._disconnect_handler
        self._installed = True
        _LOGGER.info('%s %s: initialized with %d records',
                     _MOD_NAME, __version__, len(self._store['history']))

    def stop(self):
        if not self._installed:
            return
        g_playerEvents.onAvatarBecomePlayer -= self._enter_handler
        g_playerEvents.onAvatarBecomeNonPlayer -= self._exit_handler
        g_playerEvents.onDisconnected -= self._disconnect_handler
        self._installed = False
        self._finalize('client_shutdown')

    def _load(self):
        if not os.path.isfile(_PATH) and not os.path.isfile(_PATH + '.bak'):
            self._save()
            return
        # Reject before recovery or subscriptions can enable later writes.
        # init() owns the traceback-bearing initialization failure log.
        self._store = _sanitize_store(_read_with_backup(_PATH))

    def _save(self):
        try:
            _atomic_write(_PATH, self._store)
        except Exception:
            _LOGGER.exception('%s: journal save failed', _MOD_NAME)
            raise

    def _recover_interrupted(self):
        """Finalize a prior active record that survived an unclean shutdown."""
        active = self._store['active']
        if active is None:
            return
        recovered = dict(active)
        recovered.update(status='interrupted', endedAt=None,
                         durationSeconds=None)
        self._append(recovered)
        self._store['active'] = None
        self._save()

    def _on_avatar_enter(self):
        """Persist the active marker before treating the session as started."""
        try:
            avatar = BigWorld.player()
            if not isinstance(avatar, PlayerAvatar):
                return
            if self._store['active'] is not None:
                self._finalize('superseded')
            arena = getattr(avatar, 'arena', None)
            arena_id = getattr(avatar, 'arenaUniqueID', None)
            if arena_id is None:
                _LOGGER.warning('%s: arena identity unavailable', _MOD_NAME)
                return
            bonus_type = getattr(avatar, 'arenaBonusType', None)
            if not _is_int(bonus_type):
                bonus_type = None
            self._store['active'] = {
                'arenaID': _text(arena_id), 'bonusType': bonus_type,
                'startedAt': int(time.time()), 'endedAt': None,
                'durationSeconds': None, 'status': 'active'}
            self._runtime_started = BigWorld.time()
            self._save()
        except Exception:
            self._runtime_started = None
            self._store['active'] = None
            _LOGGER.exception('%s: avatar-entry recording failed', _MOD_NAME)

    def _on_avatar_exit(self):
        try:
            self._finalize('observed_exit')
        except Exception:
            _LOGGER.exception('%s: avatar-exit recording failed', _MOD_NAME)

    def _on_disconnect(self):
        try:
            self._finalize('disconnected')
        except Exception:
            _LOGGER.exception('%s: disconnect recording failed', _MOD_NAME)

    def _finalize(self, status):
        """Copy and persist one terminal record, then clear active state."""
        active = self._store['active']
        if active is None:
            return
        record = dict(active)
        elapsed = 0 if self._runtime_started is None else max(
            0, int(round(BigWorld.time() - self._runtime_started)))
        record.update(status=status, endedAt=max(
            int(time.time()), record['startedAt']), durationSeconds=elapsed)
        self._append(record)
        self._store['active'] = None
        self._runtime_started = None
        self._save()

    def _append(self, record):
        self._store['history'].append(record)
        self._store['history'] = self._store['history'][-_MAX_HISTORY:]


_JOURNAL = _Journal()


def init():
    """World of Tanks mod-loader entry point."""
    try:
        _JOURNAL.start()
    except Exception:
        _LOGGER.exception('%s: initialization failed', _MOD_NAME)


def fini():
    """Finalize any active observation and remove subscriptions."""
    try:
        _JOURNAL.stop()
    except Exception:
        _LOGGER.exception('%s: finalization failed', _MOD_NAME)
<root><id>tutorial.battle_session_journal</id><version>1.0.0</version><name>Battle Session Journal</name><description>Persists bounded observations of local battle-session lifecycle.</description></root>
{
  "active": null,
  "history": [
    {
      "arenaID": "123456789",
      "bonusType": 1,
      "durationSeconds": 437,
      "endedAt": 1787760437,
      "startedAt": 1787760000,
      "status": "observed_exit"
    }
  ],
  "schemaVersion": 2
}

Startup admits storage before subscribing to lifecycle events. An explicit schemaVersion selects the current-schema check and cannot be bypassed by a legacy version field. Version markers must be non-boolean integers. Only a document without the current marker can take the supported legacy migration. If loading or root-schema validation fails, the error reaches init() and startup stops; it does not publish an empty replacement or register later event-driven writers. A missing active file with an existing backup still takes the read-and-validate path. Only absence of both files is first use.

Selected startup and schema controls use in-memory documents, event sinks and recording writers. They do not establish real filesystem recovery, event ordering, client runtime or Python 2 execution.

Journal field guide: schemaVersion selects migration/validation rules, active holds at most one recoverable in-progress observation, and history contains bounded terminal records. Each record separates approximate wall-clock fields, a process-local engine-time delta, arena correlation, and the observed lifecycle status. The sample values are illustrative, not observed runtime evidence; wall-clock span and duration need not match. The document stays strict JSON.

Minimal project structure

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

preferences directory (created at runtime):
└── mods/battle_session_journal/
    ├── journal.json
    └── journal.json.bak

The runtime journal is user-owned data and is not packaged into the mod archive. This tree uses a symbolic preferences location; the backup is optional and temporary files are transient. Schema version 2 is separate from the mod release version 1.0.0.

Packaging and installation

  1. Compile with the target-compatible Python 2.7 compiler.
  2. Stage only bytecode and aligned meta.xml.
  3. Create and inspect a stored-member battle_session_journal_1.0.0.wotmod.
  4. Do not ship, overwrite, or remove the runtime journal during upgrades.

Testing and debugging

Deterministic tests

The following are unexecuted verification targets for this review. Inspect both owner memory and disk bytes after each boundary.

  • Separate missing/backup-only, valid v2/v1, unsupported markers with legacy fields, Boolean/float versions, invalid roots and oversized input. Rejected startup must leave bytes unchanged and install no handlers.
  • Place a parseable unsupported active document beside a supported backup: schema rejection must not become backup fallback.
  • Distinguish warning-only invalid fields from a list/object terminal status that aborts admission.
  • Check migration/trimming in memory separately from delayed on-disk rewriting.
  • Fault-inject temp open/write, flush, fsync, temp/active validation, backup removal, rotation, corrupt-active removal, promotion, restoration and cleanup. Assert the actual surviving files and raised/logged error at each stage; do not assume every failure preserves a prior valid copy.
  • Include a read failure that classifies a previously readable active file as corrupt, followed by removal and failed promotion with no backup.
  • Recover a saved active marker with unknown end/duration. Exercise save failure before subscriptions.
  • Use 130 valid records to verify retention of the last 100 in list order, and reject Boolean integer fields under Python 2.7.
  • Exercise entry, supersession, exit/disconnect and shutdown save failures. Verify cleared markers, retained history and lack of automatic terminal retry separately from later successful writes.

Runtime matrix

These conditional expectations require client verification; static review does not establish that the scenarios passed.

ScenarioExpected if the stated conditions hold
Avatar exit is first terminal signal; save succeedsOne observed_exit observation
Disconnect is first terminal signal; save succeedsOne disconnected observation
Process ends with a persisted, admissible active markerNext successful recovery saves interrupted with unknown end/duration
Active read fails; backup passes schema admissionBackup loads; active bytes are repaired only by a later successful save
Another terminal signal after active was clearedNo new final record and no automatic retry of a failed terminal save
Repeated entry with an existing marker, even for the same arena IDAttempt superseded finalization, then a new marker; either save can fail
Replay pause or seek between entry and finalizationNo replay correction is implemented; inspect engine-time and wall-clock fields separately

Failure modes and edge cases

  • Future schema: reject before startup subscriptions. An explicit current-format marker outranks legacy fields; parseable schema rejection does not try the backup.
  • Partial record: ordinary invalid candidates are dropped, but an unhashable terminal status raises and aborts admission.
  • Clock changes: end is clamped to start; engine-time duration is computed separately and can differ from the wall-clock span. Pause/rewind is not corrected.
  • Power loss or write failure: flush/fsync, rotation and conditional restoration reduce particular risks, without guaranteeing crash-atomic replacement or a valid surviving copy after every failure.
  • Repeated signals: later terminal handlers return after active is cleared. Repeated entry is not deduplicated by arena ID.
  • Save failure: no in-memory rollback or automatic retry. Entry clears active/baseline; failed finalization retains appended history with active cleared. Disk may still contain an older marker.
  • Shutdown failure: listeners are already removed before the final save attempt. Abrupt process termination may never call fini().

Performance considerations

JSON and filesystem work runs synchronously in startup and lifecycle handlers, including fsync. There is no frame/polling loop, but a superseding entry can save twice. The 1 MiB check runs before opening a file; it is neither a lock against concurrent growth nor a parse-time or memory deadline. Sanitization visits candidates before retaining the last 100 and does not cap each text field. Output can exceed the byte threshold and fail temp validation. Measure callback latency on the target filesystem before treating this pattern as suitable for battle-time I/O.

Compatibility and version sensitivity

DependencyRecheck
Player context eventsSignature and disconnect/exit ordering
Arena identity/bonus typeField availability and meaning by mode
Preferences pathWrite access and installer preservation
Rename/fsyncTarget filesystem semantics
Python 2 JSON/unicodeEncoding and integer domains

Why this architecture?

  • Why external JSON? History is user-owned local state, shared at its preferences path rather than partitioned by account.
  • Why an active marker? A previously saved observation can be recovered with unknown termination; an unsaved entry cannot.
  • Why no battle outcome? Lifecycle observations do not carry authoritative results.
  • Why a backup? An earlier parse-readable copy can support recovery under specific writer paths.
  • Why whole-file rewrites? They simplify inspection and retention in this example, at the cost of synchronous serialization and replacement. Sorted keys do not make observations identical across runs or provide a transaction, lock or interprocess merge.

Possible extensions

These are future exercises, absent from this implementation; each needs its own verified contracts.

  • Add migration 2→3 with a documented new semantic field.
  • Expose a read-only garage summary after verified account synchronization.
  • Export CSV through an explicit user action outside the battle hot path.
  • Correlate official results only through a separately verified result-delivery pipeline.
  • Advance to Level 8 with an authenticated local companion-service boundary.

Maintenance checklist

  • Recheck player-context event names/signatures, preferences-helper import, arena identity, and bonus-type fields.
  • Trace context ordering on normal exit, disconnect, and shutdown.
  • Run migration and corruption fixtures.
  • Fault-test every replacement stage.
  • Check Python 2.7 syntax, Unicode, long integers, and bool exclusion.
  • Align __version__ and manifest version.
  • Confirm traceback-bearing logs remain inside active exception handlers.
  • Runtime-test interruption recovery without claiming battle-result correctness.

What you learned

Persistence requires separate contracts for schema admission, in-memory ownership, attempted writes and recovery. This example demonstrates explicit version dispatch, local lifecycle observations and retained-history controls. Its failure boundaries also show why successful serialization, a backup file and caught exceptions alone do not establish transactional state, replay-safe timing or crash durability.