Skip to content

Scout

scout

Read a scouted opponent's screen: the loot on offer and whether it can be skipped.

The scout screen only stands for 30 seconds before the game forces the battle to start and takes the 下一個 button away, and every skipped opponent would cost another call. Position, font and size are fixed by the 1600x900 layout, so the digits are matched against templates here instead of going through Gemini.

Functions:

Name Description
idle_disconnected

Whether the idle-disconnect dialog is covering the game.

attack_menu_open

Whether the 多人遊戲 menu is up with its 尋找對戰目標 button.

card_groups

Card centres in the battle row, split into the groups the game lays them out in.

counted_cards

Which of these cards show an xN count, which is to say troops or spells.

card_count

The xN on one card, or None when the artwork behind it swallows the digits.

deploy_refused

Whether the game is refusing drops for landing inside the boundary.

army_strength

Trained and total army size off the 我的軍隊 screen, or None if not on it.

freeze_cards

Which of these spell cards hold freeze, the one spell worth holding back.

live_cards

Which of these card-row positions still have something left to deploy.

read_scout

The opponent on screen, or None when this screenshot shows no opponent at all.

read_stock

The village's own storages, or None when this screenshot is not showing them.

logger

logger = logging.getLogger(__name__)

SCREEN_SIZE

SCREEN_SIZE = (1600, 900)

ROW_BOUNDS

ROW_BOUNDS = ((126, 156), (173, 203), (220, 250))

LOOT_DIGIT_TOLERANCE

LOOT_DIGIT_TOLERANCE = 35

LOOT_INK_BRIGHTNESS

LOOT_INK_BRIGHTNESS = 190

BUTTON_ORANGE

BUTTON_ORANGE = 0.2

NEXT_BUTTON_BOX

NEXT_BUTTON_BOX = (1380, 595, 1525, 668)

FIND_MATCH_BOX

FIND_MATCH_BOX = (150, 635, 400, 695)

CARD_HALF_WIDTH

CARD_HALF_WIDTH = 45

CARD_SPENT_SATURATION

CARD_SPENT_SATURATION = 10

CARD_GROUP_GAP

CARD_GROUP_GAP = 20

CARD_MIN_WIDTH

CARD_MIN_WIDTH = 80

CARD_EDGE_GAP

CARD_EDGE_GAP = 5

CARD_LIT_BRIGHTNESS

CARD_LIT_BRIGHTNESS = 60

COUNT_WHITE_RATIO

COUNT_WHITE_RATIO = 0.1

COUNT_INK_BRIGHTNESS

COUNT_INK_BRIGHTNESS = 225

COUNT_X_WIDTH

COUNT_X_WIDTH = (13, 19)

SPELL_ART_HALF_WIDTH

SPELL_ART_HALF_WIDTH = 40

FREEZE_GREEN

FREEZE_GREEN = 190

ARMY_BOX

ARMY_BOX = (700, 192, 880, 230)

ARMY_INK_BRIGHTNESS

ARMY_INK_BRIGHTNESS = 200

ARMY_DIGIT_TOLERANCE

ARMY_DIGIT_TOLERANCE = 22

REFUSED_BOX

REFUSED_BOX = (600, 238, 1010, 278)

REFUSED_RED

REFUSED_RED = 0.07

STOCK_ROW_BOUNDS

STOCK_ROW_BOUNDS = ((33, 72), (117, 156), (200, 239))

STOCK_DIGIT_TOLERANCE

STOCK_DIGIT_TOLERANCE = 22

IDLE_DIALOG_BOX

IDLE_DIALOG_BOX = (400, 340, 1200, 560)

IDLE_DIALOG_DARK

IDLE_DIALOG_DARK = 0.7

INK_BRIGHTNESS

INK_BRIGHTNESS = 200

INK_SATURATION

INK_SATURATION = 70

MIN_GLYPH_ROWS

MIN_GLYPH_ROWS = 12

TEMPLATES

TEMPLATES = {
    "0": 169458624884574198223902366912354106996856,
    "1": 696897621703158864378983005318264707480702,
    "2": 85070591100230874204667429307332765417471,
    "3": 675798784304908372152790311370407663366400,
    "4": 168945205701540461368970269115296407121948,
    "5": 694855254858101058867581815848550986670464,
    "6": 85068595901120556248302502939962514078974,
    "7": 1393795285959803196907656693197004320768480,
    "8": 82346336943585891544677200489837789444606,
    "9": 694854598050314149851625745496530502547952,
}

idle_disconnected

idle_disconnected(png: bytes) -> bool

Whether the idle-disconnect dialog is covering the game.

Source code in src/ai_coc/parsers/scout.py
def idle_disconnected(png: bytes) -> bool:
    """Whether the idle-disconnect dialog is covering the game."""
    data = Image.open(io.BytesIO(png)).convert("RGB").crop(IDLE_DIALOG_BOX).tobytes()
    panel = sum(
        max(data[i], data[i + 1], data[i + 2]) < 95
        and max(data[i], data[i + 1], data[i + 2]) - min(data[i], data[i + 1], data[i + 2]) < 30
        for i in range(0, len(data), 3)
    )
    return panel / (len(data) // 3) >= IDLE_DIALOG_DARK

attack_menu_open

attack_menu_open(png: bytes) -> bool

Whether the 多人遊戲 menu is up with its 尋找對戰目標 button.

The attack loop opens with taps that only mean anything on the home village. An agent job that finished somewhere else would otherwise send them into whatever screen was left showing.

Source code in src/ai_coc/parsers/scout.py
def attack_menu_open(png: bytes) -> bool:
    """Whether the 多人遊戲 menu is up with its 尋找對戰目標 button.

    The attack loop opens with taps that only mean anything on the home village.
    An agent job that finished somewhere else would otherwise send them into
    whatever screen was left showing.
    """
    image = Image.open(io.BytesIO(png)).convert("RGB")
    return _orange_ratio(image, FIND_MATCH_BOX) >= BUTTON_ORANGE

card_groups

card_groups(png: bytes) -> list[list[int]]

Card centres in the battle row, split into the groups the game lays them out in.

How many cards fall in each group depends on the army, so callers are meant to read the first group as the main troops and the rest as one-off drops, rather than trying to name which group is which.

Only valid on a full row. A spent card greys out below the detection floor and the row fragments, at which point the battlefield visible past its ends reads as a card too.

Source code in src/ai_coc/parsers/scout.py
def card_groups(png: bytes) -> list[list[int]]:
    """Card centres in the battle row, split into the groups the game lays them out in.

    How many cards fall in each group depends on the army, so callers are meant
    to read the first group as the main troops and the rest as one-off drops,
    rather than trying to name which group is which.

    Only valid on a full row. A spent card greys out below the detection floor
    and the row fragments, at which point the battlefield visible past its ends
    reads as a card too.
    """
    image = Image.open(io.BytesIO(png)).convert("RGB")
    strip = image.crop((0, CARD_TOP, image.width, CARD_BOTTOM)).convert("L")
    columns = strip.resize((image.width, 1), Image.Resampling.BILINEAR).tobytes()
    spans: list[tuple[int, int]] = []
    start: int | None = None
    for x in range(image.width + 1):
        lit = x < image.width and columns[x] > CARD_LIT_BRIGHTNESS
        if lit and start is None:
            start = x
        elif not lit and start is not None:
            if x - start >= CARD_MIN_WIDTH:
                spans.append((start, x))
            start = None
    if len(spans) > 1 and spans[1][0] - spans[0][1] < CARD_EDGE_GAP:
        spans = spans[1:]
    groups: list[list[int]] = []
    for index, (left, right) in enumerate(spans):
        if index == 0 or left - spans[index - 1][1] > CARD_GROUP_GAP:
            groups.append([])
        groups[-1].append((left + right) // 2)
    return groups

counted_cards

counted_cards(png: bytes, slots: Sequence[int]) -> list[int]

Which of these cards show an xN count, which is to say troops or spells.

Heroes and the siege machine are the ones without it, which is what lets the attack loop drop those and leave spells alone.

Source code in src/ai_coc/parsers/scout.py
def counted_cards(png: bytes, slots: Sequence[int]) -> list[int]:
    """Which of these cards show an `xN` count, which is to say troops or spells.

    Heroes and the siege machine are the ones without it, which is what lets the
    attack loop drop those and leave spells alone.
    """
    image = Image.open(io.BytesIO(png)).convert("RGB")
    counted: list[int] = []
    for centre in slots:
        data = image.crop((
            centre + COUNT_LEFT,
            COUNT_TOP,
            centre + COUNT_RIGHT,
            COUNT_BOTTOM,
        )).tobytes()
        white = sum(
            max(data[i], data[i + 1], data[i + 2]) > INK_BRIGHTNESS + 25
            and max(data[i], data[i + 1], data[i + 2]) - min(data[i], data[i + 1], data[i + 2])
            < 60
            for i in range(0, len(data), 3)
        )
        if white / (len(data) // 3) >= COUNT_WHITE_RATIO:
            counted.append(centre)
    return counted

card_count

card_count(png: bytes, slot: int) -> int | None

The xN on one card, or None when the artwork behind it swallows the digits.

Lets a one-off drop be tapped as many times as the card actually holds instead of a fixed guess. A pale illustration merges into the count, and that shows up as an x glyph of the wrong width, so it reports the failure.

Source code in src/ai_coc/parsers/scout.py
def card_count(png: bytes, slot: int) -> int | None:
    """The `xN` on one card, or None when the artwork behind it swallows the digits.

    Lets a one-off drop be tapped as many times as the card actually holds
    instead of a fixed guess. A pale illustration merges into the count, and
    that shows up as an `x` glyph of the wrong width, so it reports the failure.
    """
    image = Image.open(io.BytesIO(png)).convert("RGB")
    band = image.crop((slot + COUNT_LEFT, COUNT_TOP, slot + COUNT_RIGHT, COUNT_BOTTOM))
    mask = _ink_mask(band, COUNT_INK_BRIGHTNESS)
    spans = [(a, b) for a, b in _glyph_columns(mask) if b - a > 3]
    if not spans or not COUNT_X_WIDTH[0] <= spans[0][1] - spans[0][0] <= COUNT_X_WIDTH[1]:
        return None
    digits = ""
    for left, right in spans[1:]:
        signature = _signature(mask, left, right)
        if signature is None:
            continue
        digits += min(TEMPLATES, key=lambda d: (TEMPLATES[d] ^ signature).bit_count())
    return int(digits) if digits else None

deploy_refused

deploy_refused(png: bytes) -> bool

Whether the game is refusing drops for landing inside the boundary.

Source code in src/ai_coc/parsers/scout.py
def deploy_refused(png: bytes) -> bool:
    """Whether the game is refusing drops for landing inside the boundary."""
    data = Image.open(io.BytesIO(png)).convert("RGB").crop(REFUSED_BOX).tobytes()
    red = sum(
        data[i] > 170 and data[i] - data[i + 1] > 80 and data[i] - data[i + 2] > 80
        for i in range(0, len(data), 3)
    )
    return red / (len(data) // 3) >= REFUSED_RED

army_strength

army_strength(png: bytes) -> tuple[int, int] | None

Trained and total army size off the 我的軍隊 screen, or None if not on it.

Checked before the attack is confirmed, because the search fee is charged after that and an army still being trained is not worth paying it for.

Source code in src/ai_coc/parsers/scout.py
def army_strength(png: bytes) -> tuple[int, int] | None:
    """Trained and total army size off the 我的軍隊 screen, or None if not on it.

    Checked before the attack is confirmed, because the search fee is charged
    after that and an army still being trained is not worth paying it for.
    """
    image = Image.open(io.BytesIO(png)).convert("RGB")
    mask = _ink_mask(image.crop(ARMY_BOX), ARMY_INK_BRIGHTNESS)
    numbers: list[str] = [""]
    for left, right in _glyph_columns(mask):
        signature = _signature(mask, left, right)
        if signature is None:
            continue
        digit = min(TEMPLATES, key=lambda d: (TEMPLATES[d] ^ signature).bit_count())
        if (TEMPLATES[digit] ^ signature).bit_count() > ARMY_DIGIT_TOLERANCE:
            numbers.append("")
            continue
        numbers[-1] += digit
    found = [value for value in numbers if value]
    if len(found) != 2:
        return None
    return int(found[0]), int(found[1])

freeze_cards

freeze_cards(png: bytes, slots: Sequence[int]) -> list[int]

Which of these spell cards hold freeze, the one spell worth holding back.

Source code in src/ai_coc/parsers/scout.py
def freeze_cards(png: bytes, slots: Sequence[int]) -> list[int]:
    """Which of these spell cards hold freeze, the one spell worth holding back."""
    image = Image.open(io.BytesIO(png)).convert("RGB")
    frozen: list[int] = []
    for centre in slots:
        data = image.crop((
            centre - SPELL_ART_HALF_WIDTH,
            SPELL_ART_TOP,
            centre + SPELL_ART_HALF_WIDTH,
            SPELL_ART_BOTTOM,
        )).tobytes()
        green = sum(data[i + 1] for i in range(0, len(data), 3)) / (len(data) // 3)
        if green > FREEZE_GREEN:
            frozen.append(centre)
    return frozen

live_cards

live_cards(png: bytes, slots: Sequence[int]) -> list[int]

Which of these card-row positions still have something left to deploy.

Lets the attack loop keep emptying only the cards that are not done yet, rather than guessing a tap count that a bulk troop card would outlast.

Source code in src/ai_coc/parsers/scout.py
def live_cards(png: bytes, slots: Sequence[int]) -> list[int]:
    """Which of these card-row positions still have something left to deploy.

    Lets the attack loop keep emptying only the cards that are not done yet,
    rather than guessing a tap count that a bulk troop card would outlast.
    """
    image = Image.open(io.BytesIO(png)).convert("RGB")
    live: list[int] = []
    for centre in slots:
        data = image.crop((
            centre - CARD_HALF_WIDTH,
            CARD_TOP,
            centre + CARD_HALF_WIDTH,
            CARD_BOTTOM,
        )).tobytes()
        spread = sum(
            max(data[i], data[i + 1], data[i + 2]) - min(data[i], data[i + 1], data[i + 2])
            for i in range(0, len(data), 3)
        )
        if spread / (len(data) // 3) > CARD_SPENT_SATURATION:
            live.append(centre)
    return live

read_scout

read_scout(png: bytes) -> ScoutView | None

The opponent on screen, or None when this screenshot shows no opponent at all.

Tapping 下一個 leaves the game on 正在搜尋對手 for a moment, and that screen has no digits where the panel would be, so a failed read is how the caller learns to keep waiting rather than a separate screen classifier.

Source code in src/ai_coc/parsers/scout.py
def read_scout(png: bytes) -> ScoutView | None:
    """The opponent on screen, or None when this screenshot shows no opponent at all.

    Tapping 下一個 leaves the game on 正在搜尋對手 for a moment, and that screen
    has no digits where the panel would be, so a failed read is how the caller
    learns to keep waiting rather than a separate screen classifier.
    """
    image = Image.open(io.BytesIO(png)).convert("RGB")
    if image.size != SCREEN_SIZE:
        raise ValueError(f"戰利品面板座標只適用 1600x900,收到 {image.size[0]}x{image.size[1]}")
    gold, elixir, dark = (
        _read_loot_row(image, (PANEL_LEFT, top, PANEL_RIGHT, bottom)) for top, bottom in ROW_BOUNDS
    )
    if gold is None or elixir is None or dark is None:
        return None
    view = ScoutView(
        loot=LootOffer(gold=gold, elixir=elixir, dark=dark),
        can_skip=_orange_ratio(image, NEXT_BUTTON_BOX) >= BUTTON_ORANGE,
    )
    logger.info(
        "Scouted gold=%d elixir=%d dark=%d skippable=%s",
        view.loot.gold,
        view.loot.elixir,
        view.loot.dark,
        view.can_skip,
    )
    return view

read_stock

read_stock(png: bytes) -> VillageStock | None

The village's own storages, or None when this screenshot is not showing them.

Anything other than the home village reads as None, as does a home village with a panel over the bars, so a caller is meant to treat it as "not now" rather than as an empty village. Three rows all resolving into digits is itself the evidence that the home screen is up.

Source code in src/ai_coc/parsers/scout.py
def read_stock(png: bytes) -> VillageStock | None:
    """The village's own storages, or None when this screenshot is not showing them.

    Anything other than the home village reads as None, as does a home village
    with a panel over the bars, so a caller is meant to treat it as "not now"
    rather than as an empty village. Three rows all resolving into digits is
    itself the evidence that the home screen is up.
    """
    image = Image.open(io.BytesIO(png)).convert("RGB")
    if image.size != SCREEN_SIZE:
        raise ValueError(f"儲量條座標只適用 1600x900,收到 {image.size[0]}x{image.size[1]}")
    gold, elixir, dark = (
        _read_row(image, (STOCK_LEFT, top, STOCK_RIGHT, bottom), STOCK_DIGIT_TOLERANCE)
        for top, bottom in STOCK_ROW_BOUNDS
    )
    if gold is None or elixir is None or dark is None:
        return None
    stock = VillageStock(gold=gold, elixir=elixir, dark=dark)
    logger.info("Village holds gold=%d elixir=%d dark=%d", stock.gold, stock.elixir, stock.dark)
    return stock