# CharacterDataProvider plugins

How to add a module that contributes per-character data synced alongside each character refresh.

## When to use this

- Your module needs to fetch something from `/profile/wow/character/{realm}/{name}/…` and store it per-character.
- You want that fetch to run automatically on every character sync, with no extra glue.
- The data follows a catalog + junction shape (like mounts, pets, titles).

The `CharacterDataProvider` plugin with `buildPageSections()` is the only extension mechanism for the character canonical page.

## The interface

`Drupal\wow_character\CharacterDataProviderInterface`

```php
interface CharacterDataProviderInterface extends PluginInspectionInterface {
  public function getLabel(): string;
  public function syncCharacterData(Character $character, string $region): int;
  public function deleteCharacterData(Character $character): void;
  public function buildPageSections(Character $character): array;
}
```

Return an integer from `syncCharacterData()` — the row count the sync layer will log. `deleteCharacterData()` is invoked when the character entity is deleted (ToS compliance). `buildPageSections()` returns `CharacterPageSection[]` value objects for the character canonical page — the base class provides a no-op default.

## The attribute

```php
use Drupal\wow_character\Attribute\CharacterDataProvider;

#[CharacterDataProvider(
  id: 'my_thing',
  label: new TranslatableMarkup('My thing'),
)]
class MyThingData extends PluginBase implements
  CharacterDataProviderInterface,
  ContainerFactoryPluginInterface {
  // ...
}
```

Plugins live in `modules/<module>/src/Plugin/CharacterDataProvider/`.

## Reference implementation

`modules/wow_mount/src/Plugin/CharacterDataProvider/MountData.php` is the canonical example. Shape of a typical implementation:

```php
public function syncCharacterData(Character $character, string $region): int {
  $realmSlug = $character->get('realm_slug')->value;
  $name = strtolower($character->get('name')->value);

  $path = sprintf(
    '/profile/wow/character/%s/%s/collections/my-thing',
    $realmSlug,
    $name,
  );

  $data = $this->client->get(
    $region, $path, [], NULL, 'profile-' . $region,
  );
  if (!$data) {
    return 0;
  }

  $count = 0;
  foreach ($data['data']['items'] ?? [] as $entry) {
    $existing = $this->loadExisting($character->id(), $entry['id']);
    $entity = $existing ?? $this->storage->create([
      'character' => $character->id(),
      'my_thing'  => $this->resolveCatalogEntity($entry['id']),
    ]);
    // ... map fields from $entry ...
    $entity->set('last_fetched', \Drupal::time()->getRequestTime());
    $entity->save();
    $count++;
  }
  return $count;
}

public function deleteCharacterData(Character $character): void {
  $ids = $this->storage->getQuery()
    ->accessCheck(FALSE)
    ->condition('character', $character->id())
    ->execute();
  if ($ids) {
    $this->storage->delete($this->storage->loadMultiple($ids));
  }
}
```

## Required dependencies

- Inject `wow.battlenet.client` for API calls.
- Inject `entity_type.manager` for your junction storage.
- Inject `datetime.time` to stamp `last_fetched`.
- Inject `logger.channel.wow` for warnings.

Don't use `\Drupal::` inside plugin methods — the `GlobalDrupalDependencyInjectionRule` in our phpstan config will flag it.

## Junction entity requirements

Your junction entity:

- Must declare `last_fetched` as a base field (the TTL sweeper depends on it, the `TtlSweepCoverageTest` enforces it).
- Must NOT declare the `character` reference — `wow_character` projects it in via `hook_entity_base_field_info`. Same for `guild` if your junction is also guild-ownable.
- Must have `wow_character` listed in `_wow_character_owned_entity_types()` in `modules/wow_character/wow_character.install`.

See [architecture/lifecycle.md](../architecture/lifecycle.md#owner-projection) for why this matters.

## Testing expectations

Two kernel tests are table-stakes:

1. **Standalone entity test** — create, save, round-trip your junction with mock data. See `MountCollectionEntityTest`.
2. **Full plugin test** — mock BattleNetClient, swap it into the container, drive `syncCharacterData()` with canned API responses, assert rows land. See `MountDataTest`.

Tests must use `#[RunTestsInSeparateProcesses]` (Drupal 11.3+ requirement for kernel tests).

## Building page sections

Override `buildPageSections()` to contribute render content to the character canonical page:

```php
use Drupal\wow_character\CharacterPage\CharacterPageSection;
use Drupal\wow_character\CharacterPage\CharacterPageSlot;

public function buildPageSections(Character $character): array {
  $items = $this->loadItemsFor($character);
  if (empty($items)) {
    return [];
  }

  return [
    new CharacterPageSection(
      slotName: CharacterPageSlot::COLLECTIONS,
      content: [
        '#theme' => 'my_collection_summary',
        '#items' => $items,
      ],
      weight: 10,
    ),
  ];
}
```

Each `CharacterPageSection` targets a named slot. Use `CharacterPageSlot` constants for well-known positions (`TITLE_RIBBON`, `COLLECTIONS`, `ACHIEVEMENTS`, `EQUIPMENT`, `GENERIC`), or use a custom string — unknown slot names render in the generic fallback area.

The `CharacterPageRenderer` service wraps each section in the `wow_character_section` theme hook. Template suggestions are generated per plugin and slot:

- `wow_character_section__PLUGIN_ID`
- `wow_character_section__SLOT_NAME`
- `wow_character_section__SLOT_NAME__PLUGIN_ID`

Plugin failures are isolated: one plugin throwing doesn't prevent other plugins from rendering their sections.

The base class `CharacterDataProviderBase` returns an empty array by default — you only need to override this method if your module has page content.

## See also

- [Adding submodules](adding-submodules.md) — the full submodule scaffold; the character-integration checklist there lists the install-side wiring (`_wow_character_owned_entity_types()`, `TtlSweeper::SWEEP_ENTITY_TYPES`).
- [Dashboard providers](dashboard-providers.md) — if you also want an admin row for your module.
- [API compliance](api-compliance.md) — delete cascades and the 30-day TTL your junction must satisfy.
