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 numbers go through parsers.glyphs rather than through Gemini.

What is left here after that engine moved out is this file's real subject: which of the game's screens is on show, and what the ones a battle passes through are holding — the loot panel, the army bar, the card row, the storage bars.

Functions:

Name Description
battle_over

Whether the battle result screen is up with its 回營 button waiting.

welcome_back

Whether the raid report the game opens on is covering the village.

shield_sheet

Whether the 魔法護盾 sheet is covering the village.

idle_disconnected

Whether a dropped-session dialog is covering the game.

session_taken

Whether another device has logged in to this account and taken the session.

loading_screen

Whether the game is on 正在載入, which is where it waits for the server.

skip_offered

Whether 下一個 is on screen, which takes none of the loot digits to answer.

panel_peak

How bright the brightest pixel of the loot panel is, over its three rows.

panel_drawn

Whether the scout screen has finished fading in, read off the loot panel's own peak.

attack_menu_open

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

night_attack_menu

Whether the builder base's 開始進攻 dialog is up with its 立即尋找 button.

loot_cart_open

Whether the builder base's 聖水車 sheet is up, whatever its button says.

loot_cart_ready

Whether that sheet's 收集 is live, which is not the same as the sheet being up.

loot_cart_load

What the cart's sheet says it holds and holds at most, or None where it will not read.

in_battle

Whether a battle is on screen, by the red plate that leaves one.

battle_speed

The speed the battle's speed button says is playing, or None with no button up.

searching_opponent

Whether the builder base matchmaker is still looking, by 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.

card_drained

Which of these counted cards actually put something on the field.

field_units

Which of these cards have their unit alive on the field, by its health bar.

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.

selected_cards

Which of these cards the game is drawing selected, by the white border.

read_scout

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

read_builder_stock

The builder base's storages, which are its first two rows and nothing else.

storage_capacity

What this row's tooltip says the storage holds, or None where none is open.

read_stock

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

logger

logger = logging.getLogger(__name__)

ROW_BOUNDS

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

LOOT_DIGIT_TOLERANCE

LOOT_DIGIT_TOLERANCE = 35

LOOT_INK_BRIGHTNESS

LOOT_INK_BRIGHTNESS = 190

DIM_INK_RATIO

DIM_INK_RATIO = 0.85

PANEL_DRAWN_BRIGHTNESS

PANEL_DRAWN_BRIGHTNESS = 209

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)

SEARCHING_BOX

SEARCHING_BOX = (720, 768, 880, 806)

SEARCHING_RED

SEARCHING_RED = 0.5

NIGHT_FIND_BOX

NIGHT_FIND_BOX = (1100, 570, 1280, 615)

NIGHT_FIND_GREEN

NIGHT_FIND_GREEN = 0.45

NIGHT_PANEL_BOX

NIGHT_PANEL_BOX = (300, 465, 900, 500)

NIGHT_PANEL_PALE

NIGHT_PANEL_PALE = 0.8

CART_PLANK_ABOVE

CART_PLANK_ABOVE = (1075, 686, 1290, 712)

CART_PLANK_BESIDE

CART_PLANK_BESIDE = (575, 686, 1020, 704)

CART_PLANK

CART_PLANK = 0.55

CART_COLLECT_BOX

CART_COLLECT_BOX = (1110, 740, 1245, 782)

CART_COLLECT_GREEN

CART_COLLECT_GREEN = 0.4

CART_HELD_BOX

CART_HELD_BOX = (575, 740, 1060, 775)

CART_SPACE

CART_SPACE = 6

CART_ONE_WIDTH

CART_ONE_WIDTH = 9

CART_HELD_TOLERANCE

CART_HELD_TOLERANCE = 24

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_SPAN

CARD_SPAN = (100, 116)

CARD_SELECTED_SPAN

CARD_SELECTED_SPAN = 120

CARD_SELECTED_EDGE

CARD_SELECTED_EDGE = 200

CARD_SLIVER

CARD_SLIVER = 4

CARD_SEAM

CARD_SEAM = 2

CARD_LIT_BRIGHTNESS

CARD_LIT_BRIGHTNESS = 60

BADGE_BRIGHTNESS

BADGE_BRIGHTNESS = 175

BADGE_LIT

BADGE_LIT = 0.05

COUNT_WHITE_RATIO

COUNT_WHITE_RATIO = 0.16

NIGHT_COUNT_WHITE_RATIO

NIGHT_COUNT_WHITE_RATIO = 0.06

COUNT_INK_BRIGHTNESS

COUNT_INK_BRIGHTNESS = 225

COUNT_X_WIDTH

COUNT_X_WIDTH = (13, 19)

COUNT_DIGIT_TOLERANCE

COUNT_DIGIT_TOLERANCE = 30

SPELL_ART_HALF_WIDTH

SPELL_ART_HALF_WIDTH = 40

FREEZE_GREEN

FREEZE_GREEN = 190

FREEZE_BLUE

FREEZE_BLUE = 220

ARMY_BOX

ARMY_BOX = (700, 192, 880, 230)

ARMY_INK_BRIGHTNESS

ARMY_INK_BRIGHTNESS = 200

ARMY_DIGIT_TOLERANCE

ARMY_DIGIT_TOLERANCE = 22

CARD_CORNER_INK

CARD_CORNER_INK = 40

CARD_CORNER_PIXELS

CARD_CORNER_PIXELS = 20

HERO_BAR_HALF_WIDTH

HERO_BAR_HALF_WIDTH = 50

HERO_BAR_GREEN

HERO_BAR_GREEN = 0.15

HERO_BAR_MIN_GREEN

HERO_BAR_MIN_GREEN = 200

HERO_BAR_MAX_BLUE

HERO_BAR_MAX_BLUE = 30

STOCK_DARK_LEFT

STOCK_DARK_LEFT = 1348

STOCK_ROW_BOUNDS

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

STOCK_DIGIT_TOLERANCE

STOCK_DIGIT_TOLERANCE = 30

STOCK_TRIMMED_TOLERANCE

STOCK_TRIMMED_TOLERANCE = 20

STOCK_LEADING_TOLERANCE

STOCK_LEADING_TOLERANCE = 23

STOCK_INK_SATURATION

STOCK_INK_SATURATION = 45

STOCK_BAR_SATURATION

STOCK_BAR_SATURATION = 14

STOCK_BAR_CORE

STOCK_BAR_CORE = 245

CAPACITY_BOX

CAPACITY_BOX = (1340, 103, 1585, 132)

CAPACITY_PITCH

CAPACITY_PITCH = 84

CAPACITY_TOLERANCE

CAPACITY_TOLERANCE = 23

RETURN_HOME_BOX

RETURN_HOME_BOX = (690, 738, 910, 796)

RETURN_HOME_GREEN

RETURN_HOME_GREEN = 0.23

ABANDON_BOX

ABANDON_BOX = (20, 636, 200, 660)

ABANDON_RED

ABANDON_RED = 0.45

SPEED_BOX

SPEED_BOX = (1488, 462, 1580, 560)

SPEED_GREEN

SPEED_GREEN = 0.3

SPEED_INK_BRIGHTNESS

SPEED_INK_BRIGHTNESS = 230

SPEED_LABEL_INK

SPEED_LABEL_INK = 500

SPEED_FAST_INK

SPEED_FAST_INK = 740

IDLE_DIALOG_BOX

IDLE_DIALOG_BOX = (400, 340, 1200, 560)

IDLE_DIALOG_DARK

IDLE_DIALOG_DARK = 0.7

SESSION_TITLE_BOX

SESSION_TITLE_BOX = (400, 360, 1200, 405)

SESSION_TITLE_INK

SESSION_TITLE_INK = 170

SESSION_TAKEN_REACH

SESSION_TAKEN_REACH = (613, 700)

LOADING_PLATE

LOADING_PLATE = (860, 763, 985, 772)

LOADING_FILL

LOADING_FILL = (602, 760, 612, 776)

LOADING_PLATE_FLAT

LOADING_PLATE_FLAT = 0.9

LOADING_FILL_PURPLE

LOADING_FILL_PURPLE = 0.5

WELCOME_RIBBON_BOX

WELCOME_RIBBON_BOX = (240, 80, 620, 145)

WELCOME_SHEET_BOX

WELCOME_SHEET_BOX = (270, 705, 700, 800)

WELCOME_RIBBON_RED

WELCOME_RIBBON_RED = 0.5

WELCOME_SHEET_PALE

WELCOME_SHEET_PALE = 0.9

SHIELD_RIBBON_BOX

SHIELD_RIBBON_BOX = (620, 255, 980, 315)

SHIELD_RIBBON_PURPLE

SHIELD_RIBBON_PURPLE = 0.6

battle_over

battle_over(png: bytes) -> bool

Whether the battle result screen is up with its 回營 button waiting.

Source code in src/ai_coc/parsers/scout.py
def battle_over(png: bytes) -> bool:
    """Whether the battle result screen is up with its 回營 button waiting."""
    data = open_frame(png).crop(RETURN_HOME_BOX).tobytes()
    green = sum(
        data[i + 1] > 150 and data[i + 1] - data[i] > 45 and data[i + 1] - data[i + 2] > 60
        for i in range(0, len(data), 3)
    )
    if green / (len(data) // 3) < RETURN_HOME_GREEN:
        return False
    return not welcome_back(png) and not shield_sheet(png)

welcome_back

welcome_back(png: bytes) -> bool

Whether the raid report the game opens on is covering the village.

Source code in src/ai_coc/parsers/scout.py
def welcome_back(png: bytes) -> bool:
    """Whether the raid report the game opens on is covering the village."""
    image = open_frame(png)
    ribbon = image.crop(WELCOME_RIBBON_BOX).tobytes()
    red = sum(
        ribbon[i] > 130 and ribbon[i] - ribbon[i + 1] > 80 and ribbon[i] - ribbon[i + 2] > 90
        for i in range(0, len(ribbon), 3)
    )
    if red / (len(ribbon) // 3) < WELCOME_RIBBON_RED:
        return False
    sheet = image.crop(WELCOME_SHEET_BOX).tobytes()
    pale = sum(
        min(sheet[i], sheet[i + 1], sheet[i + 2]) > 200
        and max(sheet[i], sheet[i + 1], sheet[i + 2]) - min(sheet[i], sheet[i + 1], sheet[i + 2])
        < 20
        for i in range(0, len(sheet), 3)
    )
    return pale / (len(sheet) // 3) >= WELCOME_SHEET_PALE

shield_sheet

shield_sheet(png: bytes) -> bool

Whether the 魔法護盾 sheet is covering the village.

Source code in src/ai_coc/parsers/scout.py
def shield_sheet(png: bytes) -> bool:
    """Whether the 魔法護盾 sheet is covering the village."""
    ribbon = open_frame(png).crop(SHIELD_RIBBON_BOX).tobytes()
    purple = sum(
        ribbon[i + 2] > 140
        and ribbon[i + 1] < 60
        and 60 < ribbon[i] < 140
        and ribbon[i + 2] - ribbon[i] > 50
        for i in range(0, len(ribbon), 3)
    )
    return purple / (len(ribbon) // 3) >= SHIELD_RIBBON_PURPLE

idle_disconnected

idle_disconnected(png: bytes) -> bool

Whether a dropped-session dialog is covering the game.

Two dialogs read here and both mean the same thing to a caller: the idle one (還在嗎, with 重新登入遊戲) and the lost-connection one (連線已中斷, with 再試一次). Either way the session is gone and restarting the game is what gets it back. A third reads here too and must not be restarted out of: see session_taken.

Source code in src/ai_coc/parsers/scout.py
def idle_disconnected(png: bytes) -> bool:
    """Whether a dropped-session dialog is covering the game.

    Two dialogs read here and both mean the same thing to a caller: the idle
    one (還在嗎, with 重新登入遊戲) and the lost-connection one (連線已中斷, with
    再試一次). Either way the session is gone and restarting the game is what
    gets it back. A third reads here too and must not be restarted out of: see
    `session_taken`.
    """
    data = open_frame(png).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

session_taken

session_taken(png: bytes) -> bool

Whether another device has logged in to this account and taken the session.

Answered by standing down, never by a restart: logging in again here is what logs the player out of their phone. See SESSION_TITLE_BOX.

Source code in src/ai_coc/parsers/scout.py
def session_taken(png: bytes) -> bool:
    """Whether another device has logged in to this account and taken the session.

    Answered by standing down, never by a restart: logging in again here is
    what logs the player out of their phone. See `SESSION_TITLE_BOX`.
    """
    crop = open_frame(png).crop(SESSION_TITLE_BOX)
    data = crop.tobytes()
    reach = max(
        (
            i // 3 % crop.width
            for i in range(0, len(data), 3)
            if min(data[i], data[i + 1], data[i + 2]) > SESSION_TITLE_INK
        ),
        default=0,
    )
    low, high = SESSION_TAKEN_REACH
    return low < SESSION_TITLE_BOX[0] + reach < high and idle_disconnected(png)

loading_screen

loading_screen(png: bytes) -> bool

Whether the game is on 正在載入, which is where it waits for the server.

Nothing on this screen answers a tap, so a loop that reads it has nothing to press and nothing to open: the only thing to do is wait. See the constants above for what is measured and why both halves are needed.

Source code in src/ai_coc/parsers/scout.py
def loading_screen(png: bytes) -> bool:
    """Whether the game is on 正在載入, which is where it waits for the server.

    Nothing on this screen answers a tap, so a loop that reads it has nothing
    to press and nothing to open: the only thing to do is wait. See the
    constants above for what is measured and why both halves are needed.
    """
    image = open_frame(png)
    plate = image.crop(LOADING_PLATE).tobytes()
    flat = sum(
        _purple(plate[i], plate[i + 1], plate[i + 2])
        or (
            max(plate[i], plate[i + 1], plate[i + 2]) - min(plate[i], plate[i + 1], plate[i + 2])
            < 25
            and 60 < max(plate[i], plate[i + 1], plate[i + 2]) < 230
        )
        for i in range(0, len(plate), 3)
    )
    fill = image.crop(LOADING_FILL).tobytes()
    purple = sum(_purple(fill[i], fill[i + 1], fill[i + 2]) for i in range(0, len(fill), 3))
    return (
        flat / (len(plate) // 3) >= LOADING_PLATE_FLAT
        and purple / (len(fill) // 3) >= LOADING_FILL_PURPLE
    )

skip_offered

skip_offered(png: bytes) -> bool

Whether 下一個 is on screen, which takes none of the loot digits to answer.

read_scout says None both for 正在搜尋對手 and for an opponent whose loot panel this frame cannot read, and those want opposite things from a caller: the first is worth waiting out and the second is worth leaving. This is the only part of that screen that separates them, because it is read off one saturated orange rather than off the digits — the same test can_skip already uses, asked without needing a whole ScoutView to exist first.

Source code in src/ai_coc/parsers/scout.py
def skip_offered(png: bytes) -> bool:
    """Whether 下一個 is on screen, which takes none of the loot digits to answer.

    `read_scout` says None both for 正在搜尋對手 and for an opponent whose loot
    panel this frame cannot read, and those want opposite things from a caller:
    the first is worth waiting out and the second is worth leaving. This is the
    only part of that screen that separates them, because it is read off one
    saturated orange rather than off the digits — the same test `can_skip`
    already uses, asked without needing a whole `ScoutView` to exist first.
    """
    image = open_frame(png)
    return _orange_ratio(image, NEXT_BUTTON_BOX) >= BUTTON_ORANGE

panel_peak

panel_peak(png: bytes) -> int

How bright the brightest pixel of the loot panel is, over its three rows.

The measurement panel_drawn decides on, handed back as the number so a caller can put it in the log. A refusal that says only that it refused leaves whoever reads the run unable to tell a screen caught mid-fade from one this threshold is wrong about.

Source code in src/ai_coc/parsers/scout.py
def panel_peak(png: bytes) -> int:
    """How bright the brightest pixel of the loot panel is, over its three rows.

    The measurement `panel_drawn` decides on, handed back as the number so a
    caller can put it in the log. A refusal that says only that it refused
    leaves whoever reads the run unable to tell a screen caught mid-fade from
    one this threshold is wrong about.
    """
    image = open_frame(png)
    return max(
        max(image.crop((PANEL_LEFT, top, PANEL_RIGHT, bottom)).convert("L").tobytes())
        for top, bottom in ROW_BOUNDS
    )

panel_drawn

panel_drawn(png: bytes) -> bool

Whether the scout screen has finished fading in, read off the loot panel's own peak.

can_skip means two opposite things on a frame that has not, and only one of them is safe to act on. The 下一個 button being absent is how the countdown having expired is recognised, and a caller reads that as a battle it has no choice but to play; a button that has simply not painted yet reads exactly the same. Measured over 56 recorded scout frames, every reading of can_skip=False came from the second kind, and two rounds of one evening sent an army at an opponent nobody had evaluated because of it.

Only the search loop asks this, and only about a frame read_scout has already answered on. _wait_out_battle polls the same panel through that reader while a battle runs, and there a dim panel is an event popup over settled numbers — which DIM_INK_RATIO is built to read and this would refuse.

Source code in src/ai_coc/parsers/scout.py
def panel_drawn(png: bytes) -> bool:
    """Whether the scout screen has finished fading in, read off the loot panel's own peak.

    **`can_skip` means two opposite things on a frame that has not**, and only
    one of them is safe to act on. The 下一個 button being absent is how the
    countdown having expired is recognised, and a caller reads that as a battle
    it has no choice but to play; a button that has simply not painted yet reads
    exactly the same. Measured over 56 recorded scout frames, every reading of
    `can_skip=False` came from the second kind, and two rounds of one evening
    sent an army at an opponent nobody had evaluated because of it.

    Only the search loop asks this, and only about a frame `read_scout` has
    already answered on. `_wait_out_battle` polls the same panel through that
    reader while a battle runs, and there a dim panel is an event popup over
    settled numbers — which `DIM_INK_RATIO` is built to read and this would
    refuse.
    """
    return panel_peak(png) >= PANEL_DRAWN_BRIGHTNESS

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 = open_frame(png)
    return _orange_ratio(image, FIND_MATCH_BOX) >= BUTTON_ORANGE

night_attack_menu

night_attack_menu(png: bytes) -> bool

Whether the builder base's 開始進攻 dialog is up with its 立即尋找 button.

The builder base's own attack button sits in the same corner as the home village's, so the tap that opens this is the tap that opens the other; only what comes up separates them, and attack_menu_open does not recognise this one. Without this, a loop pointed at the builder base spends every attempt tapping a dialog it cannot see and reports the game as stuck.

Two features rather than one, because a village is mostly grass and the button is green; see the constants for the frames each half lets through.

Source code in src/ai_coc/parsers/scout.py
def night_attack_menu(png: bytes) -> bool:
    """Whether the builder base's 開始進攻 dialog is up with its 立即尋找 button.

    The builder base's own attack button sits in the same corner as the home
    village's, so the tap that opens this is the tap that opens the other; only
    what comes up separates them, and `attack_menu_open` does not recognise this
    one. Without this, a loop pointed at the builder base spends every attempt
    tapping a dialog it cannot see and reports the game as stuck.

    Two features rather than one, because a village is mostly grass and the
    button is green; see the constants for the frames each half lets through.
    """
    image = open_frame(png)
    return (
        _button_ratio(image, NIGHT_FIND_BOX, "green") >= NIGHT_FIND_GREEN
        and _panel_ratio(image, NIGHT_PANEL_BOX) >= NIGHT_PANEL_PALE
    )

loot_cart_open

loot_cart_open(png: bytes) -> bool

Whether the builder base's 聖水車 sheet is up, whatever its button says.

Tapping the cart opens this rather than collecting outright, so a caller that stopped at the tap has collected nothing at all — which is what the storage bars said the first time this was tried.

Read off the sheet rather than off its 收集 button, because the game greys that button and a grey one used to read as no sheet at all: the run then reported that it could not find the cart and left the sheet standing over the village. loot_cart_ready is the button's own question.

Source code in src/ai_coc/parsers/scout.py
def loot_cart_open(png: bytes) -> bool:
    """Whether the builder base's 聖水車 sheet is up, whatever its button says.

    Tapping the cart opens this rather than collecting outright, so a caller
    that stopped at the tap has collected nothing at all — which is what the
    storage bars said the first time this was tried.

    **Read off the sheet rather than off its 收集 button**, because the game
    greys that button and a grey one used to read as no sheet at all: the run
    then reported that it could not find the cart and left the sheet standing
    over the village. `loot_cart_ready` is the button's own question.
    """
    image = open_frame(png)
    return (
        _plank_ratio(image, CART_PLANK_ABOVE) >= CART_PLANK
        and _plank_ratio(image, CART_PLANK_BESIDE) >= CART_PLANK
    )

loot_cart_ready

loot_cart_ready(png: bytes) -> bool

Whether that sheet's 收集 is live, which is not the same as the sheet being up.

Measured live, a builder base with both storages exactly at capacity draws it a flat (178, 178, 178) — no button green at all — over a cart holding 135 843 elixir.

Nothing here knows why the game locked it, and the sheet offers two reasons at once. That frame's own body reads 暫無新的防禦獎勵, so a cart with nothing new in it is as good a candidate as a storage with no room — and a cart the loop emptied minutes ago is the commoner of the two. So this answers whether pressing it would buy anything and stops there. What separates the two is the line beside the button, which loot_cart_load reads; this said that number needed an ink rule of its own, and it does not.

Source code in src/ai_coc/parsers/scout.py
def loot_cart_ready(png: bytes) -> bool:
    """Whether that sheet's 收集 is live, which is not the same as the sheet being up.

    Measured live, a builder base with both storages exactly at capacity draws
    it a flat (178, 178, 178) — no button green at all — over a cart holding
    135 843 elixir.

    **Nothing here knows why the game locked it, and the sheet offers two
    reasons at once.** That frame's own body reads 暫無新的防禦獎勵, so a cart
    with nothing new in it is as good a candidate as a storage with no room —
    and a cart the loop emptied minutes ago is the commoner of the two. So this
    answers whether pressing it would buy anything and stops there. What
    separates the two is the line beside the button, which `loot_cart_load`
    reads; this said that number needed an ink rule of its own, and it does not.
    """
    return _button_ratio(open_frame(png), CART_COLLECT_BOX, "green") >= CART_COLLECT_GREEN

loot_cart_load

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

What the cart's sheet says it holds and holds at most, or None where it will not read.

This is what tells a locked cart the loop just emptied from one filling behind a storage with no room: the first is nothing to act on and the second is a village banking elixir in its cart until that fills too. loot_cart_ready reads the button and so cannot say which.

The ceiling is the second half of the same line, and it is what lets the cart count as a storage of its own: the builder base keeps elixir in it past what the storages take, so a run filling that village fills this too, and how full it is is a share of the number written here rather than of one anybody typed in. It grows with the village like every other ceiling — 1 000 000 on the first sheet this loop opened, 1 600 000 on the committed ones.

Gated on the sheet being up, and that gate is load-bearing rather than tidy. The builder base's own card row writes its xN corners at y 742-760 where these digits sit at y 747-767, so no box can separate them: ungated, this answers on 315 of the 4 304 frames recorded here and on four of the committed ones — night_cards.png reads 4 — and two of those 315 read 0, which is exactly the answer a caller would believe as an empty cart.

Two numbers with the / between them, or nothing. The line is cut at the spaces either side of the / rather than at a glyph that matches badly (see CART_SPACE), so a digit that fails leaves its number unread instead of splitting it, and a line the spaces cut into anything but three words is unread too. A glyph lost off either end still leaves a number, so 135 843 can come back as 35 843, and 1 600 000 as 160 000. The second is the one that costs now that the share is read, since a ceiling a tenth of the real one makes a cart holding 150 000 look full. What rules most of it out is that a cart cannot hold more than it takes: past 160 000 the pair is unread rather than believed. Under that nothing on this line can rule it out, so it is named here rather than guarded: no sheet recorded on this machine has lost a glyph off either end.

Source code in src/ai_coc/parsers/scout.py
def loot_cart_load(png: bytes) -> tuple[int, int] | None:
    """What the cart's sheet says it holds and holds at most, or None where it will not read.

    **This is what tells a locked cart the loop just emptied from one filling
    behind a storage with no room**: the first is nothing to act on and the
    second is a village banking elixir in its cart until that fills too.
    `loot_cart_ready` reads the button and so cannot say which.

    **The ceiling is the second half of the same line**, and it is what lets the
    cart count as a storage of its own: the builder base keeps elixir in it past
    what the storages take, so a run filling that village fills this too, and
    how full it is is a share of the number written here rather than of one
    anybody typed in. It grows with the village like every other ceiling —
    1 000 000 on the first sheet this loop opened, 1 600 000 on the committed ones.

    **Gated on the sheet being up, and that gate is load-bearing rather than
    tidy.** The builder base's own card row writes its `xN` corners at y 742-760
    where these digits sit at y 747-767, so no box can separate them: ungated,
    this answers on 315 of the 4 304 frames recorded here and on four of the
    committed ones — `night_cards.png` reads 4 — and two of those 315 read
    **0**, which is exactly the answer a caller would believe as an empty cart.

    Two numbers with the `/` between them, or nothing. The line is cut at the
    spaces either side of the `/` rather than at a glyph that matches badly
    (see `CART_SPACE`), so a digit that fails leaves its number unread instead
    of splitting it, and a line the spaces cut into anything but three words is
    unread too. A glyph lost off **either** end still leaves a number, so
    135 843 can come back as 35 843, and 1 600 000 as 160 000. **The second is the one that
    costs now that the share is read**, since a ceiling a tenth of the real one
    makes a cart holding 150 000 look full. What rules most of it out is that a
    cart cannot hold more than it takes: past 160 000 the pair is unread rather
    than believed. Under that nothing on this line can rule it out, so it is
    named here rather than guarded: no sheet recorded on this machine has lost
    a glyph off either end.
    """
    if not loot_cart_open(png):
        return None
    mask = ink_mask(open_frame(png).crop(CART_HELD_BOX), saturation=STOCK_INK_SATURATION)
    words: list[str] = []
    end: int | None = None
    for left, right in glyph_columns(mask):
        # Two digits can touch and come through as one span: the line would not
        # read on five looks in a row while the cart held 749 000, and the run
        # stood down on the storages alone.
        if right - left > MAX_GLYPH_WIDTH and (
            halves := _split(mask, left, right, MIN_GLYPH_ROWS)
        ):
            read = "".join(
                digit if distance <= CART_HELD_TOLERANCE else "?" for digit, distance in halves
            )
        elif (pattern := signature(mask, left, right)) is None:
            continue
        elif right - left <= CART_ONE_WIDTH:
            read = "1"
        else:
            digit, distance = nearest(pattern)
            read = digit if distance <= CART_HELD_TOLERANCE else "?"
        # Only ink that read as something moves the spacing, so a scrap too
        # short to measure cannot open a word of its own.
        if end is None or left - end > CART_SPACE:
            words.append("")
        end = right
        words[-1] += read
    if len(words) != 3 or len(words[1]) != 1 or not (words[0] + words[2]).isdigit():
        return None
    held, capacity = int(words[0]), int(words[2])
    if held > capacity:
        return None
    return held, capacity

in_battle

in_battle(png: bytes) -> bool

Whether a battle is on screen, by the red plate that leaves one.

The question card_groups was standing in for, and could not answer: a card row says something card-shaped is along the bottom of the frame, which the game's own panels have as readily as a battle does. The plate in the corner — 放棄 in the home village, 結束戰鬥 in the builder base and on the scout screen — is on none of those panels.

False does not mean the battle is over, and a caller reusing this on its own has to know it: the game dims the whole screen behind its own popups, which takes the plate under the red floor as readily as it takes the loot digits (battle_dimmed_by_popup.png, a home battle at 66% with two minutes left, reads 0.0000), and the builder base draws no plate at all until its countdown ends. What this answers is "a battle is definitely on screen", which is what a filter guarding a back press wants; "the battle has ended" is battle_over's question and stays with it.

Source code in src/ai_coc/parsers/scout.py
def in_battle(png: bytes) -> bool:
    """Whether a battle is on screen, by the red plate that leaves one.

    The question `card_groups` was standing in for, and could not answer: a card
    row says something card-shaped is along the bottom of the frame, which the
    game's own panels have as readily as a battle does. The plate in the corner
    — 放棄 in the home village, 結束戰鬥 in the builder base and on the scout
    screen — is on none of those panels.

    **False does not mean the battle is over**, and a caller reusing this on its
    own has to know it: the game dims the whole screen behind its own popups,
    which takes the plate under the red floor as readily as it takes the loot
    digits (`battle_dimmed_by_popup.png`, a home battle at 66% with two minutes
    left, reads 0.0000), and the builder base draws no plate at all until its
    countdown ends. What this answers is "a battle is definitely on screen",
    which is what a filter guarding a `back` press wants; "the battle has ended"
    is `battle_over`'s question and stays with it.
    """
    return _button_ratio(open_frame(png), ABANDON_BOX, "red") >= ABANDON_RED

battle_speed

battle_speed(png: bytes) -> Literal[1, 4] | None

The speed the battle's speed button says is playing, or None with no button up.

Source code in src/ai_coc/parsers/scout.py
def battle_speed(png: bytes) -> Literal[1, 4] | None:
    """The speed the battle's speed button says is playing, or None with no button up."""
    image = open_frame(png)
    if _button_ratio(image, SPEED_BOX, "green") < SPEED_GREEN:
        return None
    data = image.crop(SPEED_BOX).tobytes()
    ink = sum(min(data[i : i + 3]) > SPEED_INK_BRIGHTNESS for i in range(0, len(data), 3))
    if ink < SPEED_LABEL_INK:
        return None
    return 4 if ink >= SPEED_FAST_INK else 1

searching_opponent

searching_opponent(png: bytes) -> bool

Whether the builder base matchmaker is still looking, by its 取消 button.

This screen has no timer on it and no other feature to read: it is a pale field, the word 正在搜尋對手, and one red button. The button is what says the search is still running, and therefore what a caller waits on — and what it taps when the wait has gone on long enough to be worth restarting.

Source code in src/ai_coc/parsers/scout.py
def searching_opponent(png: bytes) -> bool:
    """Whether the builder base matchmaker is still looking, by its 取消 button.

    This screen has no timer on it and no other feature to read: it is a pale
    field, the word 正在搜尋對手, and one red button. The button is what says the
    search is still running, and therefore what a caller waits on — and what it
    taps when the wait has gone on long enough to be worth restarting.
    """
    image = open_frame(png)
    return _button_ratio(image, SEARCHING_BOX, "red") >= SEARCHING_RED

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.

A card whose own artwork is dark enough to break the strip is put back together before anything is measured, because a piece narrow enough to be dropped takes the whole card with it; CARD_SPAN is what that costs and how it is judged.

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.

    A card whose own artwork is dark enough to break the strip is put back
    together before anything is measured, because a piece narrow enough to be
    dropped takes the whole card with it; `CARD_SPAN` is what that costs and how
    it is judged.
    """
    image = open_frame(png)
    strip = image.crop((0, CARD_TOP, image.width, CARD_BOTTOM)).convert("L")
    columns = strip.resize((image.width, 1), Image.Resampling.BILINEAR).tobytes()
    pieces: 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:
            pieces.append((start, x))
            start = None
    spans = [span for span in _rejoined(pieces, columns) if span[1] - span[0] >= CARD_MIN_WIDTH]
    if len(spans) > 1 and spans[1][0] - spans[0][1] < CARD_EDGE_GAP:
        spans = spans[1:]
    spans = [span for span in spans if _badged(image, (span[0] + span[1]) // 2)]
    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], floor: float = COUNT_WHITE_RATIO) -> 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. floor is the builder base's own line there; see NIGHT_COUNT_WHITE_RATIO.

Source code in src/ai_coc/parsers/scout.py
def counted_cards(png: bytes, slots: Sequence[int], floor: float = COUNT_WHITE_RATIO) -> 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. `floor` is the builder
    base's own line there; see `NIGHT_COUNT_WHITE_RATIO`.
    """
    image = open_frame(png)
    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) >= floor:
            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.

The x's width was the only check here, and it is not enough: what follows it was matched against the templates with no tolerance at all, so any scrap of card art left standing became a digit. Measured, a four-pixel sliver off the x matched a 1 at 31 bits and turned a card of twelve into one of a hundred and twenty-one.

This reads the home village only, and reports the builder base as unreadable rather than wrongly. The two write the count differently — x4 there against 4x here — and the builder base draws it half as big again, 18 px against 9 to 15. Both of those were measured while trying to make one reader serve both, and both say not to: finding the x by which end matches no digit template breaks a home card whose own digit is as wide as its x (cards_full's third card reads 2 and would stop reading at all), and the builder base's digits miss every template so far that its 4 comes back as a 9, 23 bits off — inside COUNT_DIGIT_TOLERANCE, so it would be believed. A count nobody can read costs the fallback tap count; a count read as more than twice what the card holds costs the burst that follows it.

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.

    The `x`'s width was the only check here, and it is not enough: what follows
    it was matched against the templates with no tolerance at all, so any scrap
    of card art left standing became a digit. Measured, a four-pixel sliver off
    the `x` matched a 1 at 31 bits and turned a card of twelve into one of a
    hundred and twenty-one.

    **This reads the home village only, and reports the builder base as
    unreadable rather than wrongly.** The two write the count differently — `x4`
    there against `4x` here — and the builder base draws it half as big again,
    18 px against 9 to 15. Both of those were measured while trying to make one
    reader serve both, and both say not to: finding the `x` by which end matches
    no digit template breaks a home card whose own digit is as wide as its `x`
    (`cards_full`'s third card reads 2 and would stop reading at all), and the
    builder base's digits miss every template so far that its 4 comes back as a
    9, 23 bits off — inside `COUNT_DIGIT_TOLERANCE`, so it would be believed. A
    count nobody can read costs the fallback tap count; a count read as more than
    twice what the card holds costs the burst that follows it.
    """
    image = open_frame(png)
    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:]:
        pattern = signature(mask, left, right)
        if pattern is None:
            continue
        digit, distance = nearest(pattern)
        if distance > COUNT_DIGIT_TOLERANCE:
            return None
        digits += digit
    return int(digits) if digits else None

card_drained

card_drained(before: bytes, after: bytes, slots: Sequence[int]) -> list[int]

Which of these counted cards actually put something on the field.

The xN corner is repainted whenever a card loses one, which answers the question the loop keeps asking — did that drop land — without needing to read the number, and without believing a banner that means four different things.

Source code in src/ai_coc/parsers/scout.py
def card_drained(before: bytes, after: bytes, slots: Sequence[int]) -> list[int]:
    """Which of these counted cards actually put something on the field.

    The `xN` corner is repainted whenever a card loses one, which answers the
    question the loop keeps asking — did that drop land — without needing to read
    the number, and without believing a banner that means four different things.
    """
    first, second = open_frame(before), open_frame(after)
    drained: list[int] = []
    for centre in slots:
        moved = sum(
            abs(a - b) > CARD_CORNER_INK
            for a, b in zip(_corner(first, centre), _corner(second, centre), strict=False)
        )
        if moved >= CARD_CORNER_PIXELS:
            drained.append(centre)
    return drained

field_units

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

Which of these cards have their unit alive on the field, by its health bar.

This is the only thing such a card says. It stays lit and stays counted once the unit is down — a hero's has become the ability button — so nothing else on it moves.

A unit, not a hero: measured on a recorded run, the game draws the same bar over a siege machine, so the caller cannot use this to tell the two apart.

Source code in src/ai_coc/parsers/scout.py
def field_units(png: bytes, slots: Sequence[int]) -> list[int]:
    """Which of these cards have their unit alive on the field, by its health bar.

    This is the only thing such a card says. It stays lit and stays counted
    once the unit is down — a hero's has become the ability button — so nothing
    else on it moves.

    A unit, not a hero: measured on a recorded run, the game draws the same bar
    over a siege machine, so the caller cannot use this to tell the two apart.
    """
    image = open_frame(png)
    down: list[int] = []
    for centre in slots:
        data = image.crop((
            centre - HERO_BAR_HALF_WIDTH,
            HERO_BAR_TOP,
            centre + HERO_BAR_HALF_WIDTH,
            HERO_BAR_BOTTOM,
        )).tobytes()
        green = sum(
            data[i + 1] > HERO_BAR_MIN_GREEN
            and data[i + 2] < HERO_BAR_MAX_BLUE
            and data[i + 1] - data[i] > 60
            for i in range(0, len(data), 3)
        )
        if green / (len(data) // 3) >= HERO_BAR_GREEN:
            down.append(centre)
    return down

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 = open_frame(png)
    mask = ink_mask(image.crop(ARMY_BOX), ARMY_INK_BRIGHTNESS)
    found = split_numbers(mask, ARMY_DIGIT_TOLERANCE)
    if len(found) != 2:
        return None
    return found[0], 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.

Both halves of cyan are asked for; see FREEZE_BLUE for what the green one alone lets through.

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.

    Both halves of cyan are asked for; see `FREEZE_BLUE` for what the green one
    alone lets through.
    """
    image = open_frame(png)
    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()
        pixels = len(data) // 3
        green = sum(data[i + 1] for i in range(0, len(data), 3)) / pixels
        blue = sum(data[i + 2] for i in range(0, len(data), 3)) / pixels
        if green > FREEZE_GREEN and blue > FREEZE_BLUE:
            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 = open_frame(png)
    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

selected_cards

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

Which of these cards the game is drawing selected, by the white border.

A selected card is the one the next tap on the field deploys from, and the builder base's second stage opens with the surviving machine's card in that state. The border is what CARD_SELECTED_EDGE measures: 249 to 255 bright at both edges of the card against at most 130 for one at rest, read off the same averaged strip card_groups cuts the row on. A three-column window either side covers the pixel or two a centre moves between frames.

Source code in src/ai_coc/parsers/scout.py
def selected_cards(png: bytes, slots: Sequence[int]) -> list[int]:
    """Which of these cards the game is drawing selected, by the white border.

    A selected card is the one the next tap on the field deploys from, and the
    builder base's second stage opens with the surviving machine's card in that
    state. The border is what `CARD_SELECTED_EDGE` measures: 249 to 255 bright
    at both edges of the card against at most 130 for one at rest, read off the
    same averaged strip `card_groups` cuts the row on. A three-column window
    either side covers the pixel or two a centre moves between frames.
    """
    image = open_frame(png)
    strip = image.crop((0, CARD_TOP, image.width, CARD_BOTTOM)).convert("L")
    columns = strip.resize((image.width, 1), Image.Resampling.BILINEAR).tobytes()
    half = CARD_SELECTED_SPAN // 2
    selected: list[int] = []
    for centre in slots:
        left = columns[centre - half - 1 : centre - half + 2]
        right = columns[centre + half - 2 : centre + half + 1]
        if max(left) >= CARD_SELECTED_EDGE and max(right) >= CARD_SELECTED_EDGE:
            selected.append(centre)
    return selected

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 = open_frame(png)
    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_builder_stock

read_builder_stock(png: bytes) -> VillageStock | None

The builder base's storages, which are its first two rows and nothing else.

read_stock cannot be used there. That village has no dark elixir, and its gems bar sits at exactly the y the dark row is read from — measured, a builder base holding 10 152 gems reports dark=10152, the gem count read as if it were dark elixir. A number that wrong travelling as a resource is how a limit ends up checked against a bar belonging to something else, so it is not read at all: dark comes back 0, and that village's StorageCapacity carries no dark ceiling either, so nothing compares them.

Source code in src/ai_coc/parsers/scout.py
def read_builder_stock(png: bytes) -> VillageStock | None:
    """The builder base's storages, which are its first two rows and nothing else.

    **`read_stock` cannot be used there.** That village has no dark elixir, and
    its gems bar sits at exactly the y the dark row is read from — measured, a
    builder base holding 10 152 gems reports `dark=10152`, the gem count read as
    if it were dark elixir. A number that wrong travelling as a
    resource is how a limit ends up checked against a bar belonging to something
    else, so it is not read at all: `dark` comes back 0, and that village's
    `StorageCapacity` carries no dark ceiling either, so nothing compares them.
    """
    image = open_frame(png)
    gold, elixir = (
        _read_row(image, (STOCK_LEFT, top, STOCK_RIGHT, bottom), STOCK_DIGIT_TOLERANCE)
        for top, bottom in STOCK_ROW_BOUNDS[:2]
    )
    if gold is None or elixir is None:
        return None
    logger.info("Builder base holds gold=%d elixir=%d", gold, elixir)
    return VillageStock(gold=gold, elixir=elixir, dark=0)

storage_capacity

storage_capacity(png: bytes, row: int) -> int | None

What this row's tooltip says the storage holds, or None where none is open.

row is the storage bar's own index down the corner, the same 0/1/2 the stock rows are read at. Tapping a bar drops the tooltip open under it and tapping again puts it away, so the caller owns that toggle; this only reads whatever is on the frame it is handed.

None is the answer for a frame with no tooltip on it, which the caller wants rather than a guess: the village showing through where the panel would be is not a number, and a resource without a ceiling is simply left out of the comparison.

Source code in src/ai_coc/parsers/scout.py
def storage_capacity(png: bytes, row: int) -> int | None:
    """What this row's tooltip says the storage holds, or None where none is open.

    `row` is the storage bar's own index down the corner, the same 0/1/2 the
    stock rows are read at. Tapping a bar drops the tooltip open under it and
    tapping again puts it away, so the caller owns that toggle; this only reads
    whatever is on the frame it is handed.

    None is the answer for a frame with no tooltip on it, which the caller wants
    rather than a guess: the village showing through where the panel would be is
    not a number, and a resource without a ceiling is simply left out of the
    comparison.
    """
    image = open_frame(png)
    left, top, right, bottom = CAPACITY_BOX
    shift = CAPACITY_PITCH * row
    mask = ink_mask(
        image.crop((left, top + shift, right, bottom + shift)), saturation=STOCK_INK_SATURATION
    )
    # The last number on the line, because everything before it is the label:
    # 最大儲存量 and its colon are cut away by the tolerance, and anything they
    # leave behind lands to the left of the capacity itself.
    found = split_numbers(mask, CAPACITY_TOLERANCE)
    return found[-1] if found else None

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 = open_frame(png)
    gold, elixir, dark = (
        _read_row(image, (left, top, STOCK_RIGHT, bottom), STOCK_DIGIT_TOLERANCE)
        for left, (top, bottom) in zip(
            (STOCK_LEFT, STOCK_LEFT, STOCK_DARK_LEFT), STOCK_ROW_BOUNDS, strict=True
        )
    )
    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