Metadata-Version: 2.5
Name: empire-core
Version: 0.30.3
Summary: Fully typed Python API for Goodgame Empire
Project-URL: Repository, https://github.com/eschnitzler/EmpireCore
Author-email: E Joseph <east1499@gmail.com>
License: MIT
Requires-Python: >=3.10
Requires-Dist: pydantic>=2.5
Requires-Dist: python-dotenv>=1.0
Requires-Dist: requests>=2.31
Requires-Dist: websocket-client>=1.9.0
Provides-Extra: dev
Requires-Dist: aiosqlite>=0.19.0; extra == 'dev'
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pre-commit>=3.5.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: python-semantic-release>=9.0.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Requires-Dist: sqlalchemy[mypy]; extra == 'dev'
Requires-Dist: sqlmodel>=0.0.14; extra == 'dev'
Requires-Dist: types-requests; extra == 'dev'
Provides-Extra: storage
Requires-Dist: aiosqlite>=0.19.0; extra == 'storage'
Requires-Dist: sqlmodel>=0.0.14; extra == 'storage'
Description-Content-Type: text/markdown

<p align="center">
  <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="Python 3.10+">
  <img src="https://img.shields.io/badge/pydantic-v2-purple.svg" alt="Pydantic v2">
  <img src="https://img.shields.io/badge/typed-py.typed-brightgreen.svg" alt="PEP 561 typed">
  <img src="https://img.shields.io/badge/tool-uv-orange.svg" alt="UV">
  <img src="https://img.shields.io/badge/status-WIP-red.svg" alt="Work in Progress">
</p>

<h1 align="center">EmpireCore</h1>

<p align="center">
  <strong>A fully typed Python client for Goodgame Empire</strong>
</p>

<p align="center">
  <a href="#installation">Installation</a> •
  <a href="#quick-start">Quick Start</a> •
  <a href="#services">Services</a> •
  <a href="#game-state">Game State</a> •
  <a href="#map-scanning">Map Scanning</a> •
  <a href="#error-handling">Errors</a> •
  <a href="#contributing">Contributing</a>
</p>

---

> [!WARNING]
> **Work in progress.** This is a `0.x` library: every **minor** release may
> break API, and breaking changes are called out in
> [CHANGELOG.md](CHANGELOG.md). Pin a minor line (`empire-core>=0.30,<0.31`).

---

## What you get

| | |
|---|---|
| **Typed end to end** | Pydantic v2 models for every command, and a `py.typed` marker so your type checker actually sees them |
| **Honest failures** | Typed exceptions from a single `EmpireError` base — no leaked pydantic or socket errors, and no empty list that secretly means "the request failed" |
| **Thread-safe state** | A background thread applies server pushes while your code reads consistent snapshots |
| **High-level services** | `client.alliance`, `client.castle`, `client.army`, `client.lords`, `client.ranking`, `client.spy` |
| **Map scanning** | BFS kingdom discovery with cheap, targeted re-scans |
| **Multi-account** | A pool that leases one logged-in client per account |

## Installation

```bash
uv add empire-core        # or: pip install empire-core
```

The experimental persistence layer needs an extra:

```bash
pip install "empire-core[storage]"
```

<details>
<summary><strong>Developing on the library itself</strong></summary>

```bash
git clone https://github.com/eschnitzler/EmpireCore.git
cd EmpireCore
uv sync --extra dev     # `dev` is an extra, not a default group:
                        # a plain `uv sync` leaves you without pytest/ruff/mypy
uv run pytest
```

</details>

## Quick Start

```python
from empire_core import EmpireClient

# The context manager disconnects and shuts the state worker down on any exit.
with EmpireClient(username="your_user", password="your_pass") as client:
    client.login()

    client.alliance.send_chat("Hello alliance!")

    for castle in client.castle.get_all():
        print(f"{castle.castle_name} at ({castle.x}, {castle.y})")
```

Without the `with` block, call `client.close()` yourself — skipping it leaks the
receive thread and the state executor for the life of the process.

## Services

Services are attached to the client automatically; there is nothing to wire up.

### `client.alliance`

```python
client.alliance.send_chat("Hello!")
client.alliance.help_all()

for entry in client.alliance.get_chat_log():
    print(f"{entry.player_name}: {entry.decoded_text}")

# Typed push subscription (detach again with remove_chat_message_callback)
client.alliance.on_chat_message(lambda msg: print(msg.decoded_text))
```

### `client.castle`

```python
castles = client.castle.get_all()

details = client.castle.get_details(castle_id=12345)
if details:                       # None when the response omits the castle
    print(f"Buildings: {len(details.buildings)}")

resources = client.castle.get_resources(castle_id=12345)
if resources:
    print(f"Wood: {resources.wood}, Stone: {resources.stone}")
```

Also available: `client.army`, `client.lords`, `client.ranking` and
`client.spy`.

## Game State

A background thread applies server pushes to `client.state` while your code
reads it. Read through the accessors rather than touching the containers: each
one takes the state lock and returns a snapshot, so nothing changes underneath
you mid-iteration.

```python
player = client.state.get_local_player()      # None until login completes
castles = client.state.get_castles()
attacks = client.state.get_incoming_attacks()
inventory = client.state.get_inventory()
```

### Knowing whether state is fresh

Not every field is refreshed by every packet. Castle resources and units are
often populated once at login and never again unless you ask — so state can be
stale without being wrong. Check before trusting it:

```python
if client.state.get_castle_last_updated(castle_id) is None:
    # Never refreshed: resources and units are defaults, not measurements.
    client.castle.get_details(castle_id)
```

### Reacting to movements

```python
def on_attack(movement):
    print(f"{movement.troop_count} troops from {movement.source_player_name}, "
          f"{movement.time_remaining}s out")

def on_arrived(movement_id, movement):
    print(f"{movement_id} arrived: {movement}")

client.state.on_incoming_attack(on_attack)
client.state.on_movement_arrived(on_arrived)
```

Arrival and recall callbacks also accept a single-argument `(movement_id)`
form, but the movement is removed from state before they run, so the id alone
can no longer be resolved — prefer the two-argument form above.

> [!TIP]
> [`docs/design/state_management.md`](docs/design/state_management.md) documents
> the object-identity and freshness rules in full.

## Map Scanning

Scan a kingdom for castles, outposts and capitals. A full scan uses BFS
discovery from your castle's position and can take a few minutes:

```python
from empire_core import Kingdom, MapItemType

result = client.scan_kingdom(Kingdom.GREEN, item_types=[MapItemType.CASTLE])
print(f"{len(result.items)} items, {len(result.failed_chunks)} failed chunks")
```

`chunk_delay` (default `0.2`s) paces the requests — the server drops
connections that sustain a high request rate, so don't lower it for
long-running scans unless you know the server tolerates it.

> [!IMPORTANT]
> Always check `failed_chunks`. A partial scan is not an empty kingdom, and
> only this field tells them apart.

**Re-scanning cheaply.** `result.content_chunks` lists the chunks that held
items. Feed it back into `scan_chunks()` to re-scan a known region without
paying for BFS discovery again (roughly a third fewer requests), and run a full
`scan_kingdom()` periodically to pick up content in previously-empty chunks:

```python
discovery = client.scan_kingdom(Kingdom.GREEN, item_types=[MapItemType.CASTLE])

fresh = client.scan_chunks(
    Kingdom.GREEN, list(discovery.content_chunks), item_types=[MapItemType.CASTLE]
)
```

For very frequent scans, split `content_chunks` across several logged-in
accounts (interleaved slices `chunks[i::n]`) and run the `scan_chunks()` calls
concurrently — per-account request rate is what the server limits.

## Multiple Accounts

`AccountPool` hands out one logged-in client per account and refuses to lease
the same account twice. Prefer `leased()`: it releases the account and closes
the client even if your code raises.

```python
from empire_core import AccountPool, PoolExhaustedError

pool = AccountPool()
try:
    with pool.leased(tag="scanning") as client:
        result = client.scan_kingdom()
except PoolExhaustedError:
    ...   # no candidate account was free
```

Accounts come from `accounts.json` plus every `EMPIRE_ACCOUNT_*` environment
variable. A `.env` file is read only if you opt in with
`accounts.load(load_env_file=True)` — importing the library never mutates your
environment. See [`examples/account_pool.py`](examples/account_pool.py).

> [!CAUTION]
> `accounts.json` holds passwords in plain text. Keep it out of version control
> and `chmod 600` it; the library warns when it is group- or world-readable.

## Protocol Models

For lower-level access, use the protocol models directly:

```python
from empire_core.protocol.models import (
    AllianceChatMessageRequest,
    GetCastlesRequest,
)

request = AllianceChatMessageRequest.create("Hello 100%!")
packet = request.to_packet()
# -> "%xt%EmpireEx_21%acm%1%{"M": "Hello 100&percnt;!"}%"

client.send(request)                        # fire and forget
response = client.send(GetCastlesRequest(), wait=True)   # or await the reply
```

## Error Handling

Calls that wait for a response raise typed exceptions instead of returning
`None`, so a timeout, a dropped connection and a server-side rejection are
distinguishable. All inherit from `EmpireError`.

```python
from empire_core import CommandError, ConnectionClosedError, EmpireTimeoutError

try:
    castles = client.castle.get_all()
except CommandError as e:
    print(f"rejected: {e.command} code {e.code}")   # non-zero server error code
except EmpireTimeoutError:
    ...                                             # no response in time
except ConnectionClosedError:
    ...                                             # dropped while waiting
```

`EmpireTimeoutError` also subclasses the builtin `TimeoutError`. Action helpers
(e.g. `client.castle.select()`) return `bool` — `False` means the server
rejected the action, while transport failures still raise.

Two more you will meet: **`NetworkError`** from `connect()` and the CDN-backed
helpers, and **`PacketError`** when a response cannot be parsed. Catching
`EmpireError` covers every one of them — the library does not leak
`pydantic.ValidationError` or raw socket exceptions past its own API.

An empty collection therefore always means "nothing there", never "the lookup
failed": `get_active_events()` and `get_troop_ids()` raise on a CDN outage
rather than return empty. Where an exact answer depends on data that may be
missing, ask first:

```python
from empire_core import troop_data_available

if not troop_data_available():
    ...   # troop counts would include equipment; treat them as approximate
```

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for adding protocol commands and
services, model conventions, and testing guidelines.

## Architecture

```
empire_core/
├── client/          # EmpireClient — main entry point, map scanner
├── network/         # WebSocket connection, receive loop, redaction
├── protocol/
│   ├── models/      # Pydantic request/response models per command
│   └── packet.py    # Low-level frame parsing
├── services/        # High-level APIs attached to the client
├── state/           # Thread-safe game state and world models
├── storage/         # Experimental persistence (optional extra)
└── utils/           # Enums, CDN-backed event and troop data
```

Design notes live in [`docs/design/`](docs/design/).

---

<p align="center">
  <sub>For educational purposes only. Use responsibly.</sub>
</p>
