← Back to projects

Kaggriculture agent

Current state of main.py.

LAND_COSTS = {1: 1000, 2: 2000, 3: 4000}  # cost of the n-th quadrant bought beyond NW

# v6: land only gets bought once the land we already have is nearly fully
# planted/built (buying tiles we can't use yet is dead capital), and capped
# at 2 extra quadrants (3 total, never the 4th/SE). This came from watching
# top replays: strong players stop expanding and work fewer tiles harder
# instead. Locally validated head-to-head against the prior (day+cash-only,
# 4-quadrant) trigger: 7/10 wins, avg margin +3737. See findings.txt.
LAND_UTILIZATION_TRIGGER = 0.85
MAX_EXTRA_QUADRANTS = 2

TARGET_HANDS = 8
# Realistic per-unit daily service capacity: water + harvest (which may take
# several actions per tile) + travel between tiles, out of 24 turns/day.
PLANTS_PER_UNIT_CAPACITY = 4
LAND_SURPLUS_TARGET = 500
MIN_CASH_RESERVE = 300  # never let discretionary spending push money below this
LAND_MIN_DAY = 3  # let income establish before committing to more land
PHASE1_END_DAY = 10
ANIMAL_PHASE_DAY = 0
PHASE1_CROPS = ["CARROT", "TOMATO"]
PHASE2_CROPS = ["STRAWBERRY", "STRAWBERRY", "STRAWBERRY", "TOMATO", "TOMATO", "MELON"]
SEED_COST = {"WHEAT": 10, "CARROT": 20, "TOMATO": 50, "STRAWBERRY": 100, "MELON": 80}

# Tested and rejected: shifting the mix toward wheat/carrot and trickle-
# selling premium goods (on the theory that mirrored opponents dumping the
# same crops crash the shared market) measured WORSE locally (914-2941 vs
# starter's ~3400s, down from 3672-13825) than just selling everything
# immediately and reinvesting fast. The reinvestment-velocity flywheel from
# v3 outweighs the glut-avoidance benefit against a general opponent pool.
# See findings.txt.
PREMIUM_ITEMS = set()
SELL_TRICKLE_FRACTION = 1.0
SELL_TRICKLE_MIN = 0

# v6: both goose and cow are pursued from day 0 (ANIMAL_PHASE_DAY = 0 above),
# not gated behind a large cash cushion -- watching top replays showed
# strong players building 3-4 animal structures immediately, in parallel
# with their first crops, not as a late-game upside add-on. try_spend's cash
# reserve floor already prevents this from starving other spending when
# money is genuinely tight. Locally validated head-to-head against the
# prior (day-15, cow-gated-behind-$5000) version: 9/10 wins, avg margin
# +18641 -- the single biggest lever found this session. See findings.txt.
#
# v7: v6's real (non-local) episodes showed our animal count flatlining at
# exactly MAX_GOOSE+MAX_COW=8 by day ~9 in every game and never growing again,
# while opponents who beat us badly kept scaling PASTURE/COW well past that
# (10-14 by day 27) and largely skipped GOOSE entirely (coop=0 in 2 of 3
# losses). Cow also has a better steady-state $/day than goose (0.5/day x
# $160 vs 1/day x $50 = $80 vs $50, for a higher but still fast-payback $400
# vs $300 cost), so this looked like a big lever: raise MAX_COW well past the
# old cap and give cow first claim on cash/tiles.
#
# It measured badly, repeatedly, in local self-play against unmodified v6:
# MAX_COW=16 with animals funded before land: 0/10, avg margin -17885 (16
# pastures + 3 coops crowded a 25-tile starting quadrant, starving the crops
# that were actually earning money). Tying the cap to unlocked-land size
# instead of a flat number: still 0/10. MAX_COW=10 even after fixing a real
# bug this surfaced (see WHEAT_BUY_TRIGGER note below): still 1/10, -8773. A
# sweep of MAX_COW in {4,5,6,8,10} vs unmodified v6 showed avg margin falling
# monotonically as the cap rises past 4 -- more cows is a net loss once our
# own crop/land economy is already using the land well, contradicting the
# real-episode read that "more cows won." Likely reconciliation: those
# opponents were probably ahead for other reasons too (see the v6 "Moritz
# Huber" counter-example in findings.txt), and we were comparing ourselves to
# a mix of real strategies while self-play only tells us how a change fares
# against our OWN crop-heavy style specifically. Trusted the controlled local
# measurement over the correlational real-replay read.
#
# What DID hold up locally: MAX_COW left at 4 but MAX_GOOSE trimmed to 3 (one
# fewer coop, freeing a tile/cash for crops) with COW given first claim on
# any shared cash/tile via ANIMAL_TYPES order -- 9/15 wins vs unmodified v6,
# avg margin +3662. Small, but real and consistent, unlike every larger-cap
# variant above. See findings.txt.
ANIMAL_TYPES = ["COW", "GOOSE"]
ANIMAL_STRUCTURE = {"GOOSE": "COOP", "COW": "PASTURE"}
ANIMAL_COST = {"GOOSE": 300, "COW": 400}
MAX_GOOSE = 3
MAX_COW = 4
WHEAT_FEED_BATCH = 10  # wheat picked up per shed trip to feed animals
WHEAT_BUY_TRIGGER = 10  # restock feed once shed wheat falls below this
WIND_DOWN_DAY = 28  # stop new investment and liquidate everything near season end

# Persists across turns within one game process (module-level state).
_state = {"plant_idx": 0}


def crop_mix(day):
    return PHASE1_CROPS if day < PHASE1_END_DAY else PHASE2_CROPS


def agent(obs):
    player = obs["player"]
    me = obs["farms"][player]
    private = obs["private"]
    day = obs["day"]
    tiles = me["tiles"]
    board_size = len(tiles)

    shed = private["shed"]
    seeds = private["seeds"]
    inventories = private["inventories"]
    unlocked = me["unlocked_quadrants"]
    money = me["money"]

    farmer_pos = tuple(me["farmer"])
    hand_positions = [tuple(p) for p in me["hands"]]
    n_units = 1 + len(hand_positions)
    unit_positions = [farmer_pos] + hand_positions
    unit_invs = [inventories[i] if i < len(inventories) else {} for i in range(n_units)]

    def step_towards(cur, target):
        cx, cy = cur
        tx, ty = target
        dx, dy = tx - cx, ty - cy
        if dx == 0 and dy == 0:
            return "PASS"
        if abs(dx) >= abs(dy):
            return "EAST" if dx > 0 else "WEST"
        return "SOUTH" if dy > 0 else "NORTH"

    half = board_size // 2
    shed_adjacent = [(half - 1, half - 1), (half, half - 1), (half - 1, half), (half, half)]

    def nearest_shed_tile(pos):
        return min(shed_adjacent, key=lambda p: abs(p[0] - pos[0]) + abs(p[1] - pos[1]))

    # ---------------- scan the farm ----------------
    weed_tiles, water_jobs, harvest_jobs, empty_tiles = [], [], [], []
    empty_structs, feed_jobs, animal_harvest_jobs, fert_jobs, care_jobs = [], [], [], [], []
    occupied_structs = 0
    coop_count = 0
    pasture_count = 0
    active_plant_count = 0
    unlocked_tile_count = 0  # drives land_utilization for the land-buy gate below

    for y in range(board_size):
        for x in range(board_size):
            t = tiles[y][x]
            if t is None:
                empty_tiles.append((x, y))
                unlocked_tile_count += 1
            elif t == "LOCKED":
                continue
            elif isinstance(t, dict):
                unlocked_tile_count += 1
                kind = t.get("kind")
                if kind == "WEED":
                    weed_tiles.append((x, y))
                elif kind == "PLANT":
                    active_plant_count += 1
                    if not t.get("watered_today", False):
                        water_jobs.append((x, y))
                    if t.get("yield_units", 0) > 0:
                        harvest_jobs.append((x, y))
                elif kind in ("COOP", "PASTURE"):
                    if kind == "COOP":
                        coop_count += 1
                    else:
                        pasture_count += 1
                    if t.get("animal") is None:
                        empty_structs.append((x, y, kind))
                    else:
                        occupied_structs += 1
                        if not t.get("fed_today", False):
                            feed_jobs.append((x, y))
                        if t.get("yield_units", 0) > 0:
                            animal_harvest_jobs.append((x, y))
                        if t.get("fertilizer_available", False):
                            fert_jobs.append((x, y))
                        if not t.get("cared_today", False):
                            care_jobs.append((x, y))

    animal_phase = day >= ANIMAL_PHASE_DAY
    n_structs_existing = len(empty_structs) + occupied_structs

    # Reserve tiles for animal structures before planting claims every empty
    # tile, otherwise crops always win the empty-tile race and coops never
    # get built. Reserving the full cap's worth of tiles immediately would
    # still monopolize the board before we can afford animals to fill them,
    # so build only a small buffer of empty (unoccupied) structures ahead of
    # current cash-limited demand -- the buffer refills as animals get
    # placed, so building tracks the real bottleneck (cash, via try_spend
    # below) rather than front-loading land.
    COW_BUILD_AHEAD = 3
    GOOSE_BUILD_AHEAD = 1
    empty_cow_structs = sum(1 for s in empty_structs if s[2] == "PASTURE")
    empty_goose_structs = sum(1 for s in empty_structs if s[2] == "COOP")
    build_plan = []  # list of (pos, animal_type)
    if animal_phase:
        if pasture_count < MAX_COW and empty_cow_structs < COW_BUILD_AHEAD:
            need = min(MAX_COW - pasture_count, COW_BUILD_AHEAD - empty_cow_structs)
            for pos in empty_tiles[:need]:
                build_plan.append((pos, "COW"))
        if coop_count < MAX_GOOSE and empty_goose_structs < GOOSE_BUILD_AHEAD:
            start = len(build_plan)
            need = min(MAX_GOOSE - coop_count, GOOSE_BUILD_AHEAD - empty_goose_structs)
            for pos in empty_tiles[start : start + need]:
                build_plan.append((pos, "GOOSE"))
    build_reserve = [pos for pos, _ in build_plan]
    plantable_tiles = [p for p in empty_tiles if p not in build_reserve]

    # Cap how many tiles we let stay actively planted at once. HARVEST does
    # not necessarily drain a tile's whole yield in a single action (a crop
    # can need several harvest calls), hands don't exist yet during hour 0
    # of each day, and travel between tiles costs turns too -- so real
    # per-day service capacity per unit is well under 24 water-or-harvest
    # actions. Planting more tiles than that lets watering fall behind,
    # which cascades into weeds (observed: seeds stuck at the cash floor for
    # most of the game had 14-18 weeds by day 8 from exactly this). Cap
    # total simultaneously-growing tiles instead of always filling every
    # empty one.
    max_active_plants = (TARGET_HANDS + 1) * PLANTS_PER_UNIT_CAPACITY
    plant_room = max(0, max_active_plants - active_plant_count)
    plantable_tiles = plantable_tiles[:plant_room]

    # ---------------- planting assignments (seed-budget aware) ----------------
    mix = crop_mix(day)
    seed_budget = {c: seeds.get(c, 0) for c in set(mix)}
    plant_assignments = {}
    idx = _state["plant_idx"]
    for pos in plantable_tiles:
        for _try in range(len(mix)):
            crop = mix[idx % len(mix)]
            idx += 1
            if seed_budget.get(crop, 0) > 0:
                plant_assignments[pos] = crop
                seed_budget[crop] -= 1
                break
    _state["plant_idx"] = idx

    # ---------------- unit assignment ----------------
    assigned = [None] * n_units

    # FEED requires the unit to be physically carrying wheat (unlike seeds,
    # which are auto-available). Handle it as its own pickup -> carry -> feed
    # pipeline so animals don't starve and escape while we wait on a generic
    # job match.
    pending_feed = list(feed_jobs)

    for u in range(n_units):
        if not pending_feed:
            break
        pos = unit_positions[u]
        if unit_invs[u].get("WHEAT", 0) > 0 and pos in pending_feed:
            assigned[u] = ["FEED"]
            pending_feed.remove(pos)

    for u in range(n_units):
        if assigned[u] is not None or not pending_feed:
            continue
        if unit_invs[u].get("WHEAT", 0) > 0:
            pos = unit_positions[u]
            target = min(pending_feed, key=lambda p: abs(p[0] - pos[0]) + abs(p[1] - pos[1]))
            assigned[u] = [step_towards(pos, target)]
            pending_feed.remove(target)

    if pending_feed:
        # Nobody is carrying wheat: send one idle unit to fetch a batch from the shed.
        for u in range(n_units):
            if assigned[u] is not None:
                continue
            if shed.get("WHEAT", 0) <= 0:
                break
            pos = unit_positions[u]
            target = nearest_shed_tile(pos)
            if pos == target:
                assigned[u] = ["PICKUP", "WHEAT", WHEAT_FEED_BATCH]
            else:
                assigned[u] = [step_towards(pos, target)]
            break

    # Animal placement: a unit already carrying an animal walks it to a
    # matching empty structure and places it; otherwise send an idle unit to
    # pick one up from the shed.
    def animal_in_inv(inv):
        for a in ANIMAL_TYPES:
            if inv.get(a, 0) > 0:
                return a
        return None

    for u in range(n_units):
        if assigned[u] is not None or not empty_structs:
            continue
        carried = animal_in_inv(unit_invs[u])
        if not carried:
            continue
        struct_kind = ANIMAL_STRUCTURE[carried]
        target = None
        for s in empty_structs:
            if s[2] == struct_kind:
                target = (s[0], s[1])
                break
        if target is None:
            continue
        pos = unit_positions[u]
        if pos == target:
            assigned[u] = ["PLACE", carried]
        else:
            assigned[u] = [step_towards(pos, target)]
        empty_structs.remove((target[0], target[1], struct_kind))

    if animal_phase and empty_structs:
        for a in ANIMAL_TYPES:
            struct_kind = ANIMAL_STRUCTURE[a]
            if not any(s[2] == struct_kind for s in empty_structs):
                continue
            if shed.get(a, 0) <= 0:
                continue
            for u in range(n_units):
                if assigned[u] is not None:
                    continue
                pos = unit_positions[u]
                target = nearest_shed_tile(pos)
                if pos == target:
                    assigned[u] = ["PICKUP", a, 1]
                else:
                    assigned[u] = [step_towards(pos, target)]
                break
            break

    # ---------------- generic prioritized job list for everyone else ----------------
    jobs = []
    for p in weed_tiles:
        jobs.append((0, p, ["DIG"]))
    for p in water_jobs:
        jobs.append((1, p, ["WATER"]))
    for p in harvest_jobs:
        jobs.append((2, p, ["HARVEST"]))
    for p in animal_harvest_jobs:
        jobs.append((2, p, ["HARVEST"]))
    for p in fert_jobs:
        jobs.append((2, p, ["COLLECT_FERTILIZER"]))
    for pos, crop in plant_assignments.items():
        jobs.append((3, pos, ["PLANT", crop]))
    for pos, animal_type in build_plan:
        build_kind = ANIMAL_STRUCTURE[animal_type]
        jobs.append((4, pos, ["BUILD_COOP" if build_kind == "COOP" else "BUILD_PASTURE"]))
    for p in care_jobs:
        jobs.append((5, p, ["CARE"]))

    jobs_by_pri = sorted(jobs, key=lambda j: j[0])
    used_job_idx = set()

    pos_to_job = {}
    for ji, (pri, pos, act) in enumerate(jobs_by_pri):
        if pos not in pos_to_job:
            pos_to_job[pos] = ji

    for u in range(n_units):
        if assigned[u] is not None:
            continue
        pos = unit_positions[u]
        if pos in pos_to_job and pos_to_job[pos] not in used_job_idx:
            ji = pos_to_job[pos]
            assigned[u] = jobs_by_pri[ji][2]
            used_job_idx.add(ji)

    for u in range(n_units):
        if assigned[u] is not None:
            continue
        pos = unit_positions[u]
        best_ji, best_key = None, None
        for ji, (pri, jpos, act) in enumerate(jobs_by_pri):
            if ji in used_job_idx:
                continue
            d = abs(jpos[0] - pos[0]) + abs(jpos[1] - pos[1])
            key = (pri, d)
            if best_key is None or key < best_key:
                best_ji, best_key = ji, key
        if best_ji is not None:
            used_job_idx.add(best_ji)
            _, jpos, act = jobs_by_pri[best_ji]
            assigned[u] = act if pos == jpos else [step_towards(pos, jpos)]
        else:
            assigned[u] = ["PASS"]

    farmer_action = assigned[0]
    hand_actions = assigned[1:]

    # ---------------- market orders (budgeted against a cash reserve) ----------------
    market_orders = []
    available = [money]  # boxed so the nested helper can mutate it

    def try_spend(cost):
        if cost <= 0:
            return True
        if available[0] - cost >= MIN_CASH_RESERVE:
            available[0] -= cost
            return True
        return False

    def fib_cost(n):
        a, b = 1, 1
        for _ in range(n):
            a, b = b, a + b
        return a

    # Keep enough wheat in the shed to feed animals, and only sell the rest.
    # Must stay above the buy trigger or we'd sell down to the reserve and
    # immediately rebuy, wasting money on a pointless sell/buy cycle -- the
    # buy trigger below scales with structure count, so the sell reserve must
    # scale with it too (a bug here previously decoupled them: buy trigger
    # rose with animal count but the sell reserve stayed flat, so every cycle
    # we bought wheat up to the higher trigger then immediately sold most of
    # it back down to the old flat reserve, burning cash on the spread every
    # turn -- see findings.txt). Sell everything (no reserve) once the season
    # is winding down, since unsold shed inventory doesn't count towards the
    # final score.
    wheat_buy_trigger = max(WHEAT_BUY_TRIGGER, n_structs_existing * 2)
    if not animal_phase or day >= WIND_DOWN_DAY:
        wheat_reserve = 0
    else:
        wheat_reserve = wheat_buy_trigger + 5

    # If the shed is getting full, stop trickling and sell everything --
    # losing produce to shed-cap overflow is worse than a bad price.
    shed_total = sum(v for k, v in shed.items() if k not in ANIMAL_TYPES)
    shed_near_full = shed_total >= 80

    for item, qty in shed.items():
        if item in ANIMAL_TYPES or qty <= 0:
            continue
        sell_qty = qty
        if item == "WHEAT":
            sell_qty = max(0, qty - wheat_reserve)
        elif item in PREMIUM_ITEMS and day < WIND_DOWN_DAY and not shed_near_full:
            # Trickle premium goods out instead of dumping the whole stack in
            # one order, so the price has a chance to drift back up between
            # sales instead of getting driven straight to the $1 floor.
            sell_qty = max(SELL_TRICKLE_MIN, int(qty * SELL_TRICKLE_FRACTION))
            sell_qty = min(sell_qty, qty)
        if sell_qty > 0:
            market_orders.append(["SELL", item, sell_qty])

    # Tested and rejected: scaling the hand target down to land size (e.g.
    # total_tiles // 10) to save cash for land purchases on low-income seeds
    # measured MUCH worse (18/20 losses vs starter, avg 1111) than a flat
    # target -- throughput from enough hands working the tiles you already
    # have matters more than the marginal cash saved. See findings.txt.
    hires_today = me.get("hires_today", 0)
    n = hires_today
    while n < TARGET_HANDS:
        if try_spend(fib_cost(n)):
            market_orders.append(["HIRE"])
            n += 1
        else:
            break

    if day < WIND_DOWN_DAY:
        # Land only once what we already have is nearly full (see
        # LAND_UTILIZATION_TRIGGER above) -- buying tiles we can't use yet is
        # dead capital, and capped at MAX_EXTRA_QUADRANTS (3 total, never the
        # 4th/SE).
        n_extra = len(unlocked) - 1
        land_utilization = (active_plant_count + n_structs_existing) / max(1, unlocked_tile_count)
        if day >= LAND_MIN_DAY and n_extra < MAX_EXTRA_QUADRANTS and land_utilization >= LAND_UTILIZATION_TRIGGER:
            next_cost = LAND_COSTS[n_extra + 1]
            if available[0] - next_cost - MIN_CASH_RESERVE >= LAND_SURPLUS_TARGET:
                if try_spend(next_cost):
                    market_orders.append(["BUY_LAND"])

        # Size the seed buffer to actual land, not a flat number -- otherwise
        # a small quadrant gets over-bought (starving land purchases) or a
        # fully unlocked board gets under-bought (leaving it half-empty).
        total_plantable = len(plantable_tiles)
        for crop in sorted(set(mix)):
            frac = mix.count(crop) / len(mix)
            target = max(4, int(total_plantable * frac * 1.5) + 4)
            have = seeds.get(crop, 0)
            need = max(0, target - have)
            if need <= 0:
                continue
            unit_cost = SEED_COST[crop]
            afford_n = min(need, max(0, (available[0] - MIN_CASH_RESERVE) // unit_cost))
            if afford_n > 0:
                try_spend(afford_n * unit_cost)
                market_orders.append(["BUY_SEED", crop, afford_n])

        if animal_phase:
            # Wheat feed demand scales with how many structures exist, not a
            # flat batch -- more animals all eating daily needs more buffer
            # than the old flat trigger/quantity sized for ~8 animals.
            # (wheat_buy_trigger computed above, shared with the sell reserve.)
            wheat_buy_qty = max(20, n_structs_existing * 3)
            if n_structs_existing > 0 and shed.get("WHEAT", 0) < wheat_buy_trigger:
                afford_qty = min(wheat_buy_qty, max(0, (available[0] - MIN_CASH_RESERVE) // 25))
                if afford_qty > 0 and try_spend(afford_qty * 25):
                    market_orders.append(["BUY_PRODUCT", "WHEAT", afford_qty])
            for a in ANIMAL_TYPES:
                struct_kind = ANIMAL_STRUCTURE[a]
                free_structs = sum(1 for s in empty_structs if s[2] == struct_kind)
                carried = sum(inv.get(a, 0) for inv in unit_invs)
                owned_or_incoming = shed.get(a, 0) + carried
                if free_structs > owned_or_incoming and try_spend(ANIMAL_COST[a]):
                    market_orders.append(["BUY_ANIMAL", a, 1])

    market_orders = market_orders[:10]

    return {"farmer": farmer_action, "hands": hand_actions, "market": market_orders}