Metadata-Version: 2.5
Name: memi-engine
Version: 0.7.0
Summary: Engine for building memi memory card game instances
Project-URL: Homepage, https://memi.games
Project-URL: Repository, https://github.com/filias/memi-engine
Project-URL: Issues, https://github.com/filias/memi-engine/issues
Author-email: Filipa Andrade <filipa.andrade@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: education,flask,game,memi,memory,quiz
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Education
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Education
Classifier: Topic :: Games/Entertainment
Requires-Python: >=3.10
Requires-Dist: flask>=3.0
Requires-Dist: requests>=2.31
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: server
Requires-Dist: gunicorn>=22; extra == 'server'
Description-Content-Type: text/markdown

# memi-engine

[![CI](https://github.com/filias/memi-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/filias/memi-engine/actions/workflows/ci.yml)
[![PyPI version](https://img.shields.io/pypi/v/memi-engine.svg)](https://pypi.org/project/memi-engine/)
[![Python versions](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://pypi.org/project/memi-engine/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)

> In a world where a language model will answer almost anything in an instant, the
> part of your mind that *recalls* — that retrieves what you know on its own — gets
> little exercise. **memi** is a small counterweight: a game built on *active
> recall*. You look at an image, try to name it before revealing the answer, and
> follow the *know more* link to learn more. Each round strengthens the link
> between what you see and what you know. The answer is always one tap away — the
> point is to reach for it yourself first.

`memi-engine` lets you build your own [memi](https://memi.games) game — a
tap-to-reveal flashcard trainer — from a list of names and where to find their
images.

You define **categories** (countries, animals, monuments, movies…); the engine
gives you the responsive web UI, the menu, image fetching from Wikipedia and
friends, filters, clue mode, a **"know more" link** to each item's Wikipedia
(or source) page on reveal, theming, and a reporting system.

```bash
pip install memi-engine
```

```python
from memi_engine import CategoryProvider, MemiConfig, create_app, register


class Animals(CategoryProvider):
    key = "nature:animals"
    items = ["Lion", "Tiger", "Elephant", "Aardvark"]


register(Animals())
app = create_app(MemiConfig(title="My Memi"))

if __name__ == "__main__":
    app.run(debug=True)
```

Open <http://localhost:5000>, pick **nature → animals**, and play. Images are
resolved from Wikipedia automatically from each item's name.

## Concepts

A memi game is just a set of **category providers** registered with the engine.
Each provider declares:

- **`items`** — the list of names to guess.
- **`key`** — where the category sits in the menu (see below).
- **how to get an image** for an item (default: Wikipedia), and optionally a
  **tag** (a subtitle shown on reveal) and a **clue**.

The engine handles routing, the random game loop (`/api/random`), filtering,
prefetching, and rendering.

### Keys *are* the menu

A category `key` is a colon-separated path. The engine splits it to build a
nested menu, and **renders each segment verbatim as the on-screen label** — so
the key is also your menu copy. This is why localized games keep their keys in
the game's language:

| Key                          | Menu shown to the player        |
| ---------------------------- | ------------------------------- |
| `"space"`                    | space                           |
| `"nature:animals"`           | nature → animals                |
| `"nature:plants:flowers"`    | nature → plants → flowers       |
| `"geografia:freguesias"`     | geografia → freguesias          |

Up to four levels are supported. A child labelled `all` always sorts first.

## `CategoryProvider`

Subclass it and set at least `key` and `items`. Override the methods you need.

```python
class Monuments(CategoryProvider):
    key = "culture:monuments"
    items = ["Belém Tower", "Eiffel Tower"]
    override_name = True            # show the item name, not the article title

    def get_tag(self, item):       # subtitle on the revealed card
        return PARISHES.get(item)
```

Register each provider with `register(Monuments())`, or use `@register` as a
class decorator on the definition.

**Attributes**

| Attribute        | Default | Meaning                                                             |
| ---------------- | ------- | ------------------------------------------------------------------ |
| `key`            | `""`    | Menu path (see above).                                             |
| `items`          | `[]`    | List of item names.                                                |
| `filters`        | `{}`    | `{filter_name: {value: [items]}}` — auto-generates filter UI.      |
| `single_select`  | `False` | Only one subcategory active at a time.                             |
| `light_bg`       | `False` | Light card background (good for logos).                            |
| `override_name`  | `False` | Use the item key as the display name, not the article title.       |
| `footers`        | `[]`    | Footer IDs (attribution) to show when this category is active.     |
| `tag_style`      | `None`  | `"plain"`, `"scientific"`, or `None` (auto-detect) — tag styling.  |
| `map_zoom`       | `None`  | Zoom of the details panel's map (enriched layout); `None` = no map. |
| `map_bounds`     | `None`  | `((lat_min, lat_max), (lon_min, lon_max))` — no map outside it.    |

**Methods**

| Method                 | Returns                                                        |
| ---------------------- | ------------------------------------------------------------- |
| `get_image(item)`      | `{"name": ..., "image": ..., "url": ...}` or `None`. Default: Wikipedia. |
| `get_tag(item)`        | A short subtitle for the revealed card, or `None`.            |
| `get_clue(item)`       | A clue shown *before* reveal, or `None`.                      |
| `get_details(item)`    | `{"text", "area": {"name", "url"}, "map": {"lat", "lon", "zoom"}}` for the enriched layout's panel, or `None`. Default: the Wikipedia article's intro (+ map if `map_zoom`). |
| `get_wikipedia_title(item)` | `(lang, title)` of the article the panel describes. Default: from the `url` of `get_image`. |
| `get_audio(item)`      | `{"text", "lang", "src"}` for a speaker button on reveal, or `None`. |

**The details panel.** With `MemiConfig(layout="enriched")` a panel opens beside
the card on reveal. By default it holds the opening paragraph of the card's
Wikipedia article (the one *know more* links to), in the game's `html_lang`
when that edition has the article. Set `map_zoom` on categories of places to pin
a map on the article's coordinates; override `get_wikipedia_title` for cards
whose link isn't a Wikipedia article, or `get_details` to write your own.

The optional **`url`** in the `get_image` result is the item's source page; the
engine turns it into the *"know more"* link shown on reveal (label set via
`MemiConfig.label_more`). The built-in image helpers populate it automatically —
e.g. `get_wikipedia_image` returns the Wikipedia article URL — so Wikipedia-backed
categories get the link for free.

### Scientific names

`ScientificNameProvider` tags each item with its Latin name. It ships a bundled
English database (`SCIENTIFIC_NAMES`, ~1500 species) used by default; pass your
own mapping for other languages. The tag is shown only when the Latin name
differs from the display name, in italic *scientific* style.

```python
from memi_engine import ScientificNameProvider, register

class Animals(ScientificNameProvider):
    key = "nature:animals"
    items = ["Lion", "Tiger"]      # → "Panthera leo", "Panthera tigris"

class Plantas(ScientificNameProvider):
    key = "natureza:plantas"
    items = ["Sobreiro"]
    scientific_names = {"Sobreiro": "Quercus suber"}
```

### Filters

A filter maps option values to subsets of `items`. The engine renders the filter
buttons and applies the choice via a URL parameter.

```python
class Countries(CategoryProvider):
    key = "geography:countries"
    items = ["France", "Spain", "Japan"]
    filters = {
        "continent": {"europe": ["France", "Spain"], "asia": ["Japan"]},
    }
```

## Images

`memi_engine.images` resolves item names to image URLs and caches results
in-memory. Providers call these from `get_image`:

`get_wikipedia_image`, `get_wikipedia_file_image`, `get_commons_file_image`,
`get_tmdb_image`, `get_tmdb_tv_image`, `get_fandom_image`, `get_country_shape`,
`get_album_cover`, `get_logo_image`, and more.

Some sources need configuration via environment variables:

| Variable        | Used by                         | Default                  |
| --------------- | ------------------------------- | ------------------------ |
| `TMDB_API_KEY`  | `get_tmdb_image` (movies / TV)  | _(unset — TMDB skipped)_ |
| `BONES_API_URL` | anatomy image service           | `http://127.0.0.1:8081`  |

## `MemiConfig`

Passed to `create_app`. Common fields:

| Field             | Default              | Purpose                                  |
| ----------------- | -------------------- | ---------------------------------------- |
| `title`           | `"memi"`             | Header title.                            |
| `subtitle`        | `"practise…"`        | Header subtitle.                         |
| `themes`          | 8 built-in themes    | Available colour themes.                 |
| `default_theme`   | `"light"`            | Initial theme.                           |
| `sponsor_url`     | `None`               | Sponsor link (hidden if `None`).         |
| `about_html`      | `None`               | Custom HTML for the about page.          |
| `analytics_html`  | `None`               | Analytics snippet injected on the page.  |
| `favicon_color`   | `"#b8860b"`          | Favicon background colour.               |
| `wikipedia_lang`  | `"en"`               | Wikipedia edition for default images / *know more* links. |
| `related_sites`   | `[]`                 | Sibling games to link from the about page. |
| `label_*`         | English strings      | UI labels (for localization).            |
| `html_lang`       | `"en"`               | `<html lang>` — set it for a non-English game; also the details panel's language. |
| `site_url`        | `None`               | Canonical origin; taken from the request if unset. |
| `description`     | `""`                 | Home page meta description; falls back to `about_html`. |
| `og_image`        | `None`               | Link-preview image (absolute URL).       |
| `layout`          | `"classic"`          | `"enriched"` adds the details panel and a prominent *know more*. |

For a non-English game, set `wikipedia_lang` so the default image lookup and the
*"know more"* link resolve against that language's Wikipedia (e.g. `"pt"`). It
can also be set with the `MEMI_WIKIPEDIA_LANG` environment variable.

All UI strings are `label_*` fields, so a fully localized game keeps its labels
and `about_html` in its own language while the code stays English.

### Instance static files

To serve your own logo or images, point `create_app` at a static folder; its
files take precedence over the engine's:

```python
app = create_app(config, instance_static="/path/to/static")
# served at /static/... , falling back to the engine's static files
```

## Search discoverability

One game is served from many URLs: `/`, a `/<slug>` landing page per category,
and `/about`. The category you are playing is a JavaScript variable, so nothing
in the markup distinguishes those pages — left alone they are one page repeated,
and a crawler indexes one and discards the rest.

The engine gives each of them its own metadata, derived from what the instance
already declares:

| Page | Title | Description | Canonical |
| --- | --- | --- | --- |
| `/` | `{title} — {subtitle}` | `description`, else the opening of `about_html` | `/` |
| `/<slug>` | `{category} — {title}` | `{category} — {description}` | the category's short slug |
| `/about` | `{label_about} — {title}` | as the home page | `/about` |

The title and description patterns are `home_title`, `category_title` and
`category_description` on `MemiConfig`; a category pattern can also use
`{count}`, the number of items in it. Defaults are punctuation only, so a
localized game stays in its own language without touching them.

Two more things fall out of this:

- **`/robots.txt`** opens the site, excludes `/api/` (JSON a crawler can do
  nothing with) and names the sitemap.
- **`/sitemap.xml`** lists `/`, `/about` and every category — built from the
  registry, so a new provider appears without an edit. A category whose last
  segment is unique answers at both `/food` and `/culture-food`; only the short
  one is listed, and the long one canonicalises to it.

Canonical and `og:` URLs need the site's real origin. Behind a reverse proxy
that sends `X-Forwarded-Proto` and `X-Forwarded-Host` (Caddy does by default)
the engine works it out per request. Set `site_url` — or `MEMI_SITE_URL` — to
pin it instead.

Set **`html_lang`** per instance. It defaults to `"en"`, and a Portuguese game
that tells Google it is English is a game shown to the wrong people.

## Deployment

The app is a standard WSGI Flask app. For production, install the `server`
extra and run under gunicorn:

```bash
pip install "memi-engine[server]"
gunicorn "yourgame:app"
```

Two optional data files are read from the working directory at runtime:
`excluded_items.txt` (items to hide) and `reported_items.log` (written when
players report a bad card).

## Live examples

Real games built on this engine, all linked from
[memi.games](https://memi.games): [memi](https://world.memi.games) ·
[memi portugal](https://pt.memi.games) · [memi lisboa](https://lx.memi.games) ·
[memi slovensko](https://sk.memi.games) · [memi US](https://us.memi.games).

## Development

```bash
uv sync --extra dev
pytest          # run the test suite
ruff check .    # lint
```

## License

MIT — see [LICENSE](LICENSE).
